/** * Zustand Store — Agent 状态管理 * * 管理 Agent 的实时状态:当前会话、消息流、流式输出、工具调用状态、Token 统计。 */ import { create } from 'zustand'; import { useSessionStore } from './session-store'; // L-1: 消息 ID 防碰撞计数器 let _msgIdCounter = 0; export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; /** * L-16 修复: 提取上下文窗口默认值为命名常量 * - Ollama 默认 4096(与 OllamaAdapter.DEFAULT_CONTEXT_WINDOW 保持一致) * - 云端 Provider(DeepSeek/Agnes)默认 1M(与各 adapter MODEL_INFO 保持一致) * 实际值会通过 getContextWindow() 或 listModels() 获取,此处仅为 UI 占位默认值 */ const DEFAULT_OLLAMA_CONTEXT_WINDOW = 4096; const DEFAULT_CLOUD_CONTEXT_WINDOW = 1_000_000; // ===== 消息类型 ===== export type MessageRole = 'user' | 'assistant' | 'system' | 'tool'; export interface ToolCallInfo { id: string; name: string; args: Record; 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 { id: string; iteration: number; state: string; states: string[]; startedAt: number; completedAt?: number; thought?: string; toolCalls?: ToolCallInfo[]; tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }; /** * 创建该 step 的 runId,用于跨 run 隔离 * 防御场景:abort 重发、并发 run、traceSteps 未清空时仍能正确识别新旧 step 边界 */ runId?: string; } // ===== Store 状态 ===== interface AgentState { // 会话 currentSessionId: string | null; messages: ChatMessage[]; agentStatus: AgentStatus; currentIteration: number; maxIterations: number; // Token 统计 tokenUsage: TokenUsage; // Trace traceSteps: TraceStep[]; // 流式 isStreaming: boolean; // P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮 configLoaded: boolean; // Run 标识(用于过滤旧流事件) currentRunId: string | null; // Provider provider: string; model: string; contextWindow: number; // Actions setCurrentSession: (id: string | null) => void; setMessages: (messages: ChatMessage[]) => void; addMessage: (message: ChatMessage) => void; updateMessage: (id: string, updates: Partial) => 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; // P1-6 修复: 配置加载完成标志的 setter setConfigLoaded: (loaded: boolean) => void; setCurrentRunId: (runId: string | null) => void; addTraceStep: (step: TraceStep) => void; updateTraceStep: (iteration: number, updates: Partial) => void; updateTraceStepById: (id: string, updates: Partial) => void; updateLastTraceStep: (updates: Partial) => void; updateTokenUsage: (usage: Partial) => void; setProvider: (provider: string, model: string) => void; setMaxIterations: (max: number) => void; setCurrentIteration: (n: number) => void; saveTraceData: () => void; clearMessages: () => void; abort: () => void; } export const useAgentStore = create((set, get) => ({ // ===== 初始状态 ===== currentSessionId: null, messages: [], agentStatus: 'idle', currentIteration: 0, maxIterations: 20, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, traceSteps: [], isStreaming: false, configLoaded: false, currentRunId: null, provider: '', model: '', contextWindow: 0, // ===== Actions ===== setCurrentSession: (id) => { set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null }); // 从数据库加载该会话的消息 if (id && window.metona?.sessions?.getMessages) { window.metona.sessions.getMessages(id).then((msgs) => { // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息 if (get().currentSessionId !== id) return; const messages = (msgs as Array<{ id: string; role: string; content: string; reasoningContent?: string; toolCalls?: unknown[]; toolResult?: unknown; attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>; iteration?: number; timestamp: number; }>) // v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中 // 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片 .filter((m) => m.role !== 'tool') .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, iteration: m.iteration, timestamp: m.timestamp, })); set({ messages }); }).catch((err) => { console.error('[AgentStore]', err); }); } // 从数据库加载该会话的 trace 步骤和 token 用量 if (id && window.metona?.sessions?.getTrace) { window.metona.sessions.getTrace(id).then((data) => { // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace if (get().currentSessionId !== id) return; if (data) { if (data.traceSteps) { // 兼容旧数据:为缺少 id/states 的 trace 步骤补全 const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({ ...t, id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`, states: t.states ?? [t.state], })); set({ traceSteps: steps }); } if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage }); } }).catch((err) => { console.error('[AgentStore]', err); }); } }, 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 }); // v0.3.0 修复: 同步 session-store,确保 Sidebar 高亮和 Ctrl+[/] 可用 useSessionStore.getState().setCurrentSession(sessionId); } catch (err) { console.error('[AgentStore]', 'Failed to create session:', err); set({ agentStatus: 'error', isStreaming: false }); get().addMessage({ id: genMsgId('error'), role: 'system', content: `错误: 无法创建会话 — ${(err as Error).message}`, timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) import('metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {}); return; } } const userMessage: ChatMessage = { id: genMsgId('user'), role: 'user', content, // 用户可见内容(纯文本) timestamp: Date.now(), attachments: attachments && attachments.length > 0 ? attachments : undefined, }; set((s) => ({ messages: [...s.messages, userMessage], agentStatus: 'thinking', isStreaming: true, currentRunId: null, // 将在首个 streamEvent 中由 runId 设置 currentIteration: 0, // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // 历史 trace 仍在 DB 中,切换会话回来可恢复 tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, })); // 自动更新会话标题为用户第一条消息 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((err) => { console.error('[AgentStore]', err); }); } if (sessionId && window.metona?.agent?.sendMessage) { // 构建发给 LLM 的消息 // images 参数由 ChatInput 传入(已从附件中提取 base64 data URL) let llmContent = content; const llmImages: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }> = [ ...(images ?? []), ]; if (attachments && attachments.length > 0) { const parts: string[] = []; for (const att of attachments) { if (att.type === 'image') { // 图片已在 images 参数中处理,跳过 continue; } // 非图片文件:JSON 结构化,原始文本内容(不编码) const ext = att.name.split('.').pop() ?? 'unknown'; const rawContent = att.textContent ?? ''; parts.push(JSON.stringify({ file_name: att.name, file_type: ext, content: rawContent, })); } llmContent = parts.length > 0 ? (content ? content + '\n\n' : '') + parts.join('\n') : content; } const messageWithImages = { ...userMessage, content: llmContent, images: llmImages.length > 0 ? llmImages : undefined, }; window.metona.agent.sendMessage(messageWithImages, sessionId).catch((err) => { console.error('[AgentStore]', 'IPC sendMessage failed:', err); set({ agentStatus: 'error', isStreaming: false }); get().addMessage({ id: genMsgId('error'), role: 'system', content: `错误: 消息发送失败 — ${(err as Error).message}`, timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) import('metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {}); }); } }, updateLastAssistantMessage: (delta: string) => { set((s) => { const messages = [...s.messages]; const lastIdx = messages.length - 1; const lastMsg = messages[lastIdx]; if (lastMsg?.role === 'assistant') { // 不可变更新:创建新对象而非直接突变 messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta }; } else { messages.push({ id: genMsgId('assistant'), role: 'assistant', content: delta, timestamp: Date.now(), iteration: s.currentIteration || undefined, }); } return { messages }; }); }, setAgentStatus: (status) => set({ agentStatus: status }), setStreaming: (streaming) => set({ isStreaming: streaming }), setConfigLoaded: (loaded) => set({ configLoaded: loaded }), setCurrentRunId: (runId) => set({ currentRunId: runId }), addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })), updateTraceStep: (iteration, updates) => set((s) => ({ traceSteps: s.traceSteps.map((t) => t.iteration === iteration ? { ...t, ...updates } : t, ), })), // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) updateTraceStepById: (id, updates) => set((s) => ({ traceSteps: s.traceSteps.map((t) => t.id === id ? { ...t, ...updates } : t, ), })), updateLastTraceStep: (updates) => set((s) => { if (s.traceSteps.length === 0) return s; const steps = [...s.traceSteps]; steps[steps.length - 1] = { ...steps[steps.length - 1], ...updates }; return { traceSteps: steps }; }), updateTokenUsage: (usage) => set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), setProvider: (provider, model) => { // L-16 修复: 使用命名常量替代魔法数字 // v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 const initialCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; set({ provider, model, contextWindow: initialCtx }); // v0.3.1: 异步读取实际配置(所有 Provider) if (window.metona?.config?.get) { // Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; // P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值 const expectedProvider = provider; window.metona.config.get(configKey).then((v) => { if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 if (v != null && typeof v === 'number' && v > 0) { set({ contextWindow: v }); } }).catch((err) => { console.error('[AgentStore]', err); }); } }, setMaxIterations: (max) => set({ maxIterations: max }), setCurrentIteration: (currentIteration) => set({ currentIteration }), saveTraceData: () => { const { currentSessionId, traceSteps, tokenUsage } = get(); if (currentSessionId && window.metona?.sessions?.saveTrace) { window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch((err) => { console.error('[AgentStore]', err); }); } }, clearMessages: () => set({ messages: [], agentStatus: 'idle', currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, traceSteps: [], isStreaming: false, currentRunId: null, }), abort: () => { const sessionId = get().currentSessionId; // 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED) // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 set({ agentStatus: 'idle', isStreaming: false }); if (sessionId && window.metona?.agent?.abortSession) { window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); }); } }, }));