feat: MetonaAI Desktop 初始项目
- Electron + React + TypeScript 架构 - 三栏布局: Sidebar | ChatPanel | DetailPanel - 9 个内置工具 (文件系统/网络/记忆/命令) - SQLite 持久化 (better-sqlite3) - MUI 暗色/亮色主题系统 - Agent Loop ReAct 状态机引擎 - DeepSeek / Agnes AI / Ollama Provider 适配器 - MCP 协议集成 - 系统托盘 + 全局快捷键 - Tailwind CSS v4 + Tailwind Merge - 修复: Sidebar 缺失 TextField 导入导致黑屏
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Zustand Store — Agent 状态管理
|
||||
*
|
||||
* 管理 Agent 的实时状态:当前会话、消息流、流式输出、工具调用状态、Token 统计。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { useSessionStore } from './session-store';
|
||||
|
||||
// ===== 消息类型 =====
|
||||
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
status: 'pending' | 'executing' | 'success' | 'error' | 'blocked';
|
||||
result?: unknown;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'image' | 'text' | 'other';
|
||||
size: number;
|
||||
preview?: string; // 图片 base64 data URL
|
||||
textContent?: string; // 文本文件内容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
iteration?: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
reasoningContent?: string;
|
||||
attachments?: AttachmentInfo[];
|
||||
}
|
||||
|
||||
// ===== Agent 状态 =====
|
||||
|
||||
export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
||||
|
||||
// ===== Token 统计 =====
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
// ===== Trace 步骤 =====
|
||||
|
||||
export interface TraceStep {
|
||||
iteration: number;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
thought?: string;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
}
|
||||
|
||||
// ===== Store 状态 =====
|
||||
|
||||
interface AgentState {
|
||||
// 会话
|
||||
currentSessionId: string | null;
|
||||
messages: ChatMessage[];
|
||||
agentStatus: AgentStatus;
|
||||
currentIteration: number;
|
||||
maxIterations: number;
|
||||
|
||||
// Token 统计
|
||||
tokenUsage: TokenUsage;
|
||||
|
||||
// Trace
|
||||
traceSteps: TraceStep[];
|
||||
|
||||
// 流式
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
|
||||
// Provider
|
||||
provider: string;
|
||||
model: string;
|
||||
|
||||
// Actions
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (id: string, updates: Partial<ChatMessage>) => void;
|
||||
sendMessage: (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => void;
|
||||
updateLastAssistantMessage: (delta: string) => void;
|
||||
setAgentStatus: (status: AgentStatus) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
appendStreamingContent: (delta: string) => void;
|
||||
clearStreamingContent: () => void;
|
||||
addTraceStep: (step: TraceStep) => void;
|
||||
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
||||
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
||||
setProvider: (provider: string, model: string) => void;
|
||||
setMaxIterations: (max: number) => void;
|
||||
saveTraceData: () => void;
|
||||
clearMessages: () => void;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set, get) => ({
|
||||
// ===== 初始状态 =====
|
||||
currentSessionId: null,
|
||||
messages: [],
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
maxIterations: 20,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
|
||||
// ===== Actions =====
|
||||
|
||||
setCurrentSession: (id) => {
|
||||
set({ currentSessionId: id, messages: [], traceSteps: [], tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } });
|
||||
|
||||
// 从数据库加载该会话的消息
|
||||
if (id && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(id).then((msgs) => {
|
||||
const messages = (msgs as Array<{
|
||||
id: string; role: string; content: string;
|
||||
reasoningContent?: string; toolCalls?: unknown[];
|
||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
||||
timestamp: number;
|
||||
}>).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as ChatMessage['role'],
|
||||
content: m.content,
|
||||
reasoningContent: m.reasoningContent,
|
||||
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
||||
attachments: m.attachments as AttachmentInfo[] | undefined,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
set({ messages });
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 从数据库加载该会话的 trace 步骤和 token 用量
|
||||
if (id && window.metona?.sessions?.getTrace) {
|
||||
window.metona.sessions.getTrace(id).then((data) => {
|
||||
if (data) {
|
||||
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
|
||||
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((s) => ({ messages: [...s.messages, message] })),
|
||||
|
||||
updateMessage: (id, updates) =>
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === id ? { ...m, ...updates } : m,
|
||||
),
|
||||
})),
|
||||
|
||||
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
|
||||
let sessionId = get().currentSessionId;
|
||||
|
||||
// 没有当前会话时自动创建
|
||||
if (!sessionId && window.metona?.sessions?.create) {
|
||||
try {
|
||||
const session = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean };
|
||||
useSessionStore.getState().addSession({
|
||||
id: session.id, title: session.title, createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt, messageCount: session.messageCount,
|
||||
pinned: session.pinned, archived: session.archived,
|
||||
});
|
||||
sessionId = session.id;
|
||||
set({ currentSessionId: sessionId });
|
||||
} catch {
|
||||
// 创建失败时静默
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `msg_${Date.now()}_user`,
|
||||
role: 'user',
|
||||
content, // 用户可见内容(纯文本)
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
messages: [...s.messages, userMessage],
|
||||
agentStatus: 'thinking',
|
||||
isStreaming: true,
|
||||
streamingContent: '',
|
||||
}));
|
||||
|
||||
// 自动更新会话标题为用户第一条消息
|
||||
if (sessionId && window.metona?.sessions?.getMessages) {
|
||||
window.metona.sessions.getMessages(sessionId).then((msgs) => {
|
||||
if (msgs.length <= 1) {
|
||||
// 这是第一条消息,更新标题
|
||||
const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
|
||||
window.metona?.sessions?.rename(sessionId, title);
|
||||
useSessionStore.getState().updateSession(sessionId, { title });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (sessionId && window.metona?.agent?.sendMessage) {
|
||||
// 构建发给 LLM 的 content:纯用户文本 + 文件 JSON(无文字描述)
|
||||
let llmContent = content;
|
||||
const images: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [];
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
const parts: string[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.type === 'image' && att.preview) {
|
||||
// 图片:直接加入 images 数组
|
||||
images.push({ url: att.preview, detail: 'auto' });
|
||||
} else {
|
||||
// 所有文件:JSON 格式,Base64 编码内容
|
||||
const ext = att.name.split('.').pop() ?? 'unknown';
|
||||
let base64Content = '';
|
||||
if (att.textContent) {
|
||||
base64Content = btoa(unescape(encodeURIComponent(att.textContent)));
|
||||
} else if (att.preview) {
|
||||
// 二进制文件从 data URL 提取 base64
|
||||
base64Content = att.preview.split(',')[1] ?? '';
|
||||
}
|
||||
parts.push(JSON.stringify({
|
||||
file_name: att.name,
|
||||
file_type: ext,
|
||||
context_encode: 'Base64',
|
||||
context: base64Content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
llmContent = parts.join('\n') + (content ? '\n\n' + content : '');
|
||||
}
|
||||
|
||||
const messageWithImages = {
|
||||
...userMessage,
|
||||
content: llmContent,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
};
|
||||
window.metona.agent.sendMessage(messageWithImages, sessionId).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistantMessage: (delta: string) => {
|
||||
set((s) => {
|
||||
const messages = [...s.messages];
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
lastMsg.content += delta;
|
||||
} else {
|
||||
messages.push({
|
||||
id: `msg_${Date.now()}_assistant`,
|
||||
role: 'assistant',
|
||||
content: delta,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
return { messages };
|
||||
});
|
||||
},
|
||||
|
||||
setAgentStatus: (status) => set({ agentStatus: status }),
|
||||
|
||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||
|
||||
appendStreamingContent: (delta) =>
|
||||
set((s) => ({ streamingContent: s.streamingContent + delta })),
|
||||
|
||||
clearStreamingContent: () => set({ streamingContent: '' }),
|
||||
|
||||
addTraceStep: (step) =>
|
||||
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
||||
|
||||
updateTraceStep: (iteration, updates) =>
|
||||
set((s) => ({
|
||||
traceSteps: s.traceSteps.map((t) =>
|
||||
t.iteration === iteration ? { ...t, ...updates } : t,
|
||||
),
|
||||
})),
|
||||
|
||||
updateTokenUsage: (usage) =>
|
||||
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
||||
|
||||
setProvider: (provider, model) => set({ provider, model }),
|
||||
|
||||
setMaxIterations: (max) => set({ maxIterations: max }),
|
||||
|
||||
saveTraceData: () => {
|
||||
const { currentSessionId, traceSteps, tokenUsage } = get();
|
||||
if (currentSessionId && window.metona?.sessions?.saveTrace) {
|
||||
window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () =>
|
||||
set({
|
||||
messages: [],
|
||||
agentStatus: 'idle',
|
||||
currentIteration: 0,
|
||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
traceSteps: [],
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
}),
|
||||
|
||||
abort: () => {
|
||||
const sessionId = get().currentSessionId;
|
||||
set({ agentStatus: 'idle', isStreaming: false });
|
||||
if (sessionId && window.metona?.agent?.abortSession) {
|
||||
window.metona.agent.abortSession(sessionId).catch(() => {});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Zustand Store — 会话管理
|
||||
*
|
||||
* 会话列表、当前会话、搜索、置顶、归档。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
searchQuery: string;
|
||||
|
||||
// Actions
|
||||
setSessions: (sessions: Session[]) => void;
|
||||
setCurrentSession: (id: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
addSession: (session: Session) => void;
|
||||
updateSession: (id: string, updates: Partial<Session>) => void;
|
||||
removeSession: (id: string) => void;
|
||||
pinSession: (id: string, pinned: boolean) => void;
|
||||
archiveSession: (id: string, archived: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
// 初始状态
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
searchQuery: '',
|
||||
|
||||
// Actions
|
||||
setSessions: (sessions) => set({ sessions }),
|
||||
setCurrentSession: (id) => set({ currentSessionId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
addSession: (session) =>
|
||||
set((s) => ({ sessions: [session, ...s.sessions] })),
|
||||
updateSession: (id, updates) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, ...updates } : sess,
|
||||
),
|
||||
})),
|
||||
removeSession: (id) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.filter((sess) => sess.id !== id),
|
||||
currentSessionId: s.currentSessionId === id ? null : s.currentSessionId,
|
||||
})),
|
||||
pinSession: (id, pinned) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, pinned } : sess,
|
||||
),
|
||||
})),
|
||||
archiveSession: (id, archived) =>
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((sess) =>
|
||||
sess.id === id ? { ...sess, archived } : sess,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Zustand Store — UI 状态管理
|
||||
*
|
||||
* 主题、设置弹窗、引导向导等全局 UI 状态。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'auto';
|
||||
|
||||
interface UIState {
|
||||
// 主题
|
||||
theme: ThemeMode;
|
||||
resolvedTheme: 'dark' | 'light';
|
||||
|
||||
// 弹窗
|
||||
settingsOpen: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
|
||||
// Actions
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setResolvedTheme: (theme: 'dark' | 'light') => void;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
setOnboardingCompleted: (completed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
theme: 'auto',
|
||||
resolvedTheme: 'dark',
|
||||
settingsOpen: false,
|
||||
onboardingCompleted: false,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
|
||||
openSettings: () => set({ settingsOpen: true }),
|
||||
closeSettings: () => set({ settingsOpen: false }),
|
||||
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
|
||||
}));
|
||||
Reference in New Issue
Block a user