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(() => {});
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user