/** * Zustand Store — Agent 状态管理 * * 管理 Agent 的实时状态:当前会话、消息流、流式输出、工具调用状态、Token 统计。 */ import { create } from 'zustand'; import { useSessionStore } from './session-store'; // v0.7.2 A4: Trace 落库前复用显示层裁剪(剥离 base64/超长字符串),防止 // view_image 截图等超大工具结果(单张 ~6.7MB base64)原样写入 sessions.metadata import { toDisplayResult } from '../lib/tool-result-display'; // v0.7.3 P3-3: Trace 生命周期 —— metadata 只保留最近 N 个 run 的步骤 import { keepRecentRuns } from '../lib/trace-lifecycle'; // v0.8.0 P1-1a: 后台会话回放经事件总线灌入 useAgentStream 管线 import { emitStreamBus } from '../lib/stream-event-bus'; // v0.8.0 P1-1b/c: 文案出层 import { t } from '../lib/i18n'; import '../lib/i18n-strings'; // L-1: 消息 ID 防碰撞计数器 let _msgIdCounter = 0; export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; /** * v0.7.3 P1-2: 按锚点时间戳截断 traceSteps(编辑重发/重新生成的本地侧镜像)。 * * DB 侧 truncateMessagesAfter 已同步过滤 metadata.traceSteps(startedAt < * 锚点消息 created_at,严格小于 —— 锚点触发的 run 与消息同毫秒落库,等值 * 属于"锚点侧"必须丢弃);本函数对前端内存态做同一语义的过滤,保证截断后 * TraceViewer 不再显示已被撤销轮次的"幽灵步骤"(切换会话再切回时 DB 与 * 内存两侧口径一致)。legacy 步骤缺 startedAt 时保守保留(无时间依据可判废)。 */ export function trimTraceStepsByAnchor( steps: T[], anchorTimestamp: number, ): T[] { return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp); } /** * 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; // 文本文件内容 /** * v0.7.2 A5: 文本附件超过 512KB 上限时被截断。 * 上传入口(ChatInput)读取文件首段内容并附加截断标记, * 该标志随消息持久化并注入主进程附件提示(LLM 感知内容不完整)。 */ truncated?: boolean; } 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 { /** 累计输入 token(所有轮次累加) */ inputTokens: number; /** 累计输出 token(所有轮次累加) */ outputTokens: number; /** 累计总 token(所有轮次累加) */ totalTokens: number; /** * v0.3.18 修复: 最近一次 LLM 调用的输入 token(单次上下文占用) * 用于计算"上下文占用百分比"——与累计值区分。 * 压缩触发后,下一次 LLM 调用返回的 inputTokens 会大幅下降, * 反映压缩效果。累计值只增不减,无法体现压缩。 */ lastInputTokens: number; /** * v0.3.18 修复: 最近一次上下文压缩节省的 token 数 * 用于在 UI 显示"压缩节省了 X tokens" */ lastCompressedSaved: 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; // v0.3.18 修复: 工具就绪标志 — MCP 初始化完成前禁用发送按钮 // 避免用户在工具不全时发送消息,导致子任务委派等 MCP 工具不可用 toolsReady: boolean; // Run 标识(用于过滤旧流事件) currentRunId: string | null; // Provider provider: string; model: string; contextWindow: number; // v0.5.4: 多模态总开关(llm.multimodalEnabled)— 未开启时禁止上传图片 multimodalEnabled: boolean; /** * v0.7.2 P3-11: 后台会话运行状态图(sessionId → AgentStatus)。 * * 消息流/Trace 均按"当前会话"过滤(多会话隔离),用户切走会话后该会话 * 仍在运行的 Agent 对 UI 完全静默。此图由 useAgentStream 的 onStateChange * 管道全量维护(含非当前会话),供 Sidebar 点亮任意运行中会话的状态点。 * TERMINATED 时条目被移除(值域仅 thinking/executing 有意义,idle 不落图)。 */ sessionRunStates: Record; /** * v0.7.3 P1-4: 模型视觉能力探测缓存(modelId → supportsVision)。 * 仅存探测为 false 的条目即可生效(缺失/undefined = 未知 → 上传门控保守放行)。 * 数据源:window.metona.llm.listModels()(Ollama 侧为 /api/show 能力探测)。 */ modelVisionCaps: Record; // 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; // v0.3.18 修复: 工具就绪标志的 setter setToolsReady: (ready: boolean) => void; setCurrentRunId: (runId: string | null) => void; addTraceStep: (step: TraceStep) => void; updateTraceStepById: (id: string, updates: Partial) => void; updateLastTraceStep: (updates: Partial) => void; updateTokenUsage: (usage: Partial) => void; /** * v0.3.18 修复: 应用上下文压缩事件 * 记录压缩节省的 token 数,用于 UI 展示压缩效果 */ applyCompression: (savedTokens: number) => void; setProvider: (provider: string, model: string) => void; // v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步) setMultimodalEnabled: (enabled: boolean) => void; setMaxIterations: (max: number) => void; setCurrentIteration: (n: number) => void; /** * v0.7.2 P3-11: 维护后台会话运行状态图。 * status 传入 null 表示该会话运行已结束(TERMINATED / abort),移除条目。 */ updateSessionRunState: (sessionId: string, status: AgentStatus | null) => void; /** * v0.7.3 P1-4: 合并模型能力探测结果(仅记录 supportsVision === false 的模型)。 * 供 LLMSettings 加载列表后与 store 刷新动作调用。 */ applyModelCapabilities: (models: Array<{ id: string; supportsVision?: boolean }>) => void; /** * v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(启动 / Provider 切换 / * Ollama 模型下载完成后调用)。失败静默 —— 门控按未知模型保守放行。 */ refreshModelCapabilities: () => Promise; saveTraceData: () => void; clearMessages: () => Promise; /** * v0.7.4 P3-8: 删除当前会话时统一重置 Agent 状态(三处删除逻辑收敛)。 * 旧实现 ContextMenu/Sidebar/LogsSettings 各自 useAgentStore.setState 直接 * partial set,绕过 action 封装且漏清 sessionRunStates/agentStatus。 */ resetSessionState: () => void; abort: () => void; /** * P2-11: 编辑重发——截断该用户消息(含)之后的所有消息,重新发送修订内容 * @param messageId 原用户消息 ID * @param newContent 修订后的内容 */ editAndResend: (messageId: string, newContent: string) => Promise; /** * P2-11: 重新生成——删除最后一条用户消息(含)之后的所有消息并重发原内容 */ regenerate: () => Promise; } export const useAgentStore = create((set, get) => ({ // ===== 初始状态 ===== currentSessionId: null, messages: [], agentStatus: 'idle', currentIteration: 0, maxIterations: 20, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0, }, traceSteps: [], isStreaming: false, configLoaded: false, toolsReady: false, currentRunId: null, provider: '', model: '', contextWindow: 0, // v0.5.4: 多模态总开关(默认关闭,setProvider 时从配置加载) multimodalEnabled: false, // v0.7.2 P3-11: 后台会话运行状态图(初始为空) sessionRunStates: {}, // v0.7.3 P1-4: 模型视觉能力缓存(初始为空 = 全部未知 → 门控保守放行) modelVisionCaps: {}, // ===== Actions ===== setCurrentSession: (id) => { set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 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 }); } // v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段 if (data.tokenUsage) { const old = data.tokenUsage as Partial; set({ tokenUsage: { inputTokens: old.inputTokens ?? 0, outputTokens: old.outputTokens ?? 0, totalTokens: old.totalTokens ?? 0, lastInputTokens: old.lastInputTokens ?? 0, lastCompressedSaved: old.lastCompressedSaved ?? 0, }, }); } } }) .catch((err) => { console.error('[AgentStore]', err); }); } // v0.8.0 P1-1a: 后台会话运行内容恢复 —— 该会话正在后台运行(或刚结束但 // 缓冲未清)时,拉取主进程回放缓冲并按序灌入事件总线,重建消息/Trace 状态; // 实时事件随后经同一总线无缝衔接(runId 对齐由缓冲记录提供)。 if (id && window.metona?.agent?.getReplayState) { window.metona.agent .getReplayState(id) .then((r) => { // 竞态保护:用户已切走则丢弃 if (get().currentSessionId !== id) return; if (!r?.success || !r.data || r.data.events.length === 0) return; const { events, truncated, runId, isRunning } = r.data; // 恢复流式态与 run 归属:回放的内容事件需要 isStreaming=true 才能通过 // 准入层(non-streaming-content 过滤);终止事件(done/error)会正常 // 收尾并把 isStreaming 复位为 false。 if (runId) { set({ currentRunId: runId, isStreaming: true }); } for (const ev of events) { emitStreamBus(ev.channel, ev.payload); } if (truncated) { get().addMessage({ id: genMsgId('system'), role: 'system', content: t('agent.system.replay_truncated'), timestamp: Date.now(), }); } // 已运行中的会话:回放后实时事件继续;stateChange THINKING 自愈 //(P1-1b)会在流式态意外复位时恢复。此处对 isRunning 会话兜底确保 // isStreaming 不为 false(done 未到达时 bus 回放不会复位它)。 if (isRunning && runId && !get().isStreaming) { set({ isStreaming: true }); } }) .catch((err) => { console.error('[AgentStore] getReplayState failed:', 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; // v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用 if (!get().toolsReady) { import('@metona-team/metona-toast') .then((mod) => mod.default.warning('工具正在加载中,请稍候...')) .catch(() => {}); return; } // 没有当前会话时自动创建 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-team/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, // v0.7.2 P3-11: 当前会话进入运行态(Sidebar 状态点即时反馈,无需等待首个 stateChange) sessionRunStates: sessionId ? { ...s.sessionRunStates, [sessionId]: 'thinking' } : s.sessionRunStates, // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // 历史 trace 仍在 DB 中,切换会话回来可恢复 tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0, }, })); // 自动更新会话标题为用户第一条消息 // v0.7.4 竞态保险: rename 前先查当前标题,仅当仍是默认标题 '新会话' 才设置。 // 场景:LLM 标题生成(主进程 TitleGenerator,秒级)可能先于本异步 getMessages // (毫秒级)完成 —— 若 LLM 已把标题改为精炼标题,此处不得用截断标题覆盖。 // 旧实现无条件 rename,存在"LLM 标题被前端截断标题冲掉"的竞态窗口。 if (sessionId && window.metona?.sessions?.getMessages) { window.metona.sessions .getMessages(sessionId) .then((msgs) => { if (msgs.length <= 1) { // 这是第一条消息 —— 先查当前标题(含 LLM 标题可能已写入) return window.metona?.sessions ?.list() .then((sessions) => { const cur = sessions.find((s) => s.id === sessionId); if (cur && cur.title !== '新会话') return; // 已有标题(含 LLM 生成的),不覆盖 const title = content.length > 30 ? content.slice(0, 30) + '...' : content; return window.metona?.sessions?.rename(sessionId, title).then(() => { useSessionStore.getState().updateSession(sessionId, { title }); }); }) .catch((err) => { console.error('[AgentStore]', err); }); } }) .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).then( (r) => { // v0.8.0 P1-1c: 收尾兜底 —— agent:sendMessage 的 invoke 在整个 run 结束后 // 才 resolve。resolve 时若本地仍处于流式态,说明 DONE/ERROR 收尾事件 // 未到达(事件投递异常/主进程异常收尾),渲染层将永久卡在流式状态。 const st = get(); if (st.currentSessionId !== sessionId || !st.isStreaming) return; if (r && r.success === false) { // 主进程显式失败(注入拦截/防重入/adapter 加载失败等)——通常已广播 // ERROR 事件;若事件未达,本地以返回的 error 消息兜底收尾 set({ agentStatus: 'error', isStreaming: false, currentRunId: null }); get().addMessage({ id: genMsgId('error'), role: 'system', content: t('agent.error.generic', { message: r.error ?? '消息发送失败' }), timestamp: Date.now(), }); return; } // 成功 resolve:给事件投递留 2s 宽限,仍卡流式则强制收尾 setTimeout(() => { const s2 = get(); if (s2.currentSessionId !== sessionId || !s2.isStreaming) return; set({ agentStatus: 'idle', isStreaming: false, currentRunId: null }); s2.addMessage({ id: genMsgId('system'), role: 'system', content: t('agent.system.finalize_missing'), timestamp: Date.now(), }); s2.saveTraceData(); }, 2_000); }, (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-team/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 }), // v0.3.18 修复: 工具就绪标志 setter setToolsReady: (ready) => set({ toolsReady: ready }), setCurrentRunId: (runId) => set({ currentRunId: runId }), addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })), // 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 } })), // v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数 // 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映 applyCompression: (savedTokens) => set((s) => ({ tokenUsage: { ...s.tokenUsage, lastCompressedSaved: savedTokens } })), 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.5.4: 多模态开关随 provider/model 状态一并加载(App 初始化/设置保存共用此路径) if (window.metona?.config?.get) { window.metona.config .get('llm.multimodalEnabled') .then((v) => { set({ multimodalEnabled: v === true }); }) .catch(() => { /* 读取失败保持默认 false */ }); } // 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); }); } }, // v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费) setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }), setMaxIterations: (max) => set({ maxIterations: max }), setCurrentIteration: (currentIteration) => set({ currentIteration }), // v0.7.2 P3-11: 后台会话运行状态图维护(status=null 表示运行结束,移除条目) updateSessionRunState: (sessionId, status) => set((s) => { if (status === null) { if (!(sessionId in s.sessionRunStates)) return s; const { [sessionId]: _removed, ...rest } = s.sessionRunStates; void _removed; return { sessionRunStates: rest }; } return { sessionRunStates: { ...s.sessionRunStates, [sessionId]: status } }; }), // v0.7.3 P1-4: 合并模型能力探测结果(supportsVision=false 才入缓存) applyModelCapabilities: (models) => set((s) => { const additions: Record = {}; for (const m of models) { if (m?.id && m.supportsVision === false) additions[m.id] = false; } if (Object.keys(additions).length === 0) return s; return { modelVisionCaps: { ...s.modelVisionCaps, ...additions } }; }), // v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(失败静默,门控按未知放行) refreshModelCapabilities: async () => { try { const r = await window.metona?.llm?.listModels?.(); if (r?.success && Array.isArray(r.data)) { useAgentStore.getState().applyModelCapabilities(r.data); } } catch { /* 静默 —— 探测不可用时门控按未知模型保守放行 */ } }, saveTraceData: () => { const { currentSessionId, traceSteps, tokenUsage } = get(); if (currentSessionId && window.metona?.sessions?.saveTrace) { // v0.7.2 A4 根治: 落库前剥离工具结果中的 base64/超长字段。 // useAgentStream 把原始 toolResult.result(含 view_image dataUrl / web_browser // 截图 image,单张最大 ~6.7MB)写入 traceSteps;saveTrace 原样 JSON.stringify // 进 sessions.metadata,长会话多图可把单行撑到数十 MB。复用显示层裁剪 // toDisplayResult(dataUrl 剥离 + 任意 >8KB 字符串截断),内存态 traceSteps // 在入 store 时已同步瘦身(见 useAgentStream),此处对 restore 自 DB 的 // 旧格式数据兜底二次裁剪。 // v0.7.3 P3-3: 条目数同样收口 —— 只保留最近 20 个 run 的步骤 // (历史 run 的完整记录在 JSONL 录制文件中,metadata 仅需近程可读性)。 const recentSteps = keepRecentRuns(traceSteps); const sanitizedSteps = recentSteps.map((step) => step.toolCalls?.length ? { ...step, toolCalls: step.toolCalls.map((tc) => ({ ...tc, result: tc.result != null ? toDisplayResult(tc.result) : tc.result, })), } : step, ); window.metona.sessions .saveTrace(currentSessionId, { traceSteps: sanitizedSteps, tokenUsage }) .catch((err) => { console.error('[AgentStore]', err); }); } }, // v0.7.2 A1 根治: /clear 全链路修复 —— 此前只清前端 state,数据库消息与 // 滚动摘要游标原样保留,切换会话再切回时消息全部"复活"。 // 现契约:优先调用 sessions:clearMessages(DB 侧同步删除 messages + // session_summaries 游标 + metadata TRACE 快照),成功后才清本地 state; // 失败时保持 UI 与 DB 一致(都不清)并 toast 报错;流式进行中拒绝执行 // (与 editAndResend/regenerate 同口径,防止清空与引擎写入竞争)。 clearMessages: async () => { const { currentSessionId, isStreaming } = get(); if (isStreaming) { import('@metona-team/metona-toast') .then((mod) => mod.default.warning('Agent 正在回复中,请先中断再清空会话')) .catch(() => {}); return; } if (currentSessionId && window.metona?.sessions?.clearMessages) { try { const r = await window.metona.sessions.clearMessages(currentSessionId); if (r && r.success === false) { import('@metona-team/metona-toast') .then((mod) => mod.default.error(r.error ?? '清空会话失败,请重试')) .catch(() => {}); return; } } catch (err) { console.error('[AgentStore] clearMessages failed:', err); import('@metona-team/metona-toast') .then((mod) => mod.default.error(`清空会话失败:${(err as Error).message}`)) .catch(() => {}); return; } } set((s) => { // v0.7.2 P3-11: 同步熄灭当前会话的运行指示点 const { [currentSessionId ?? '']: _current, ...restRunStates } = s.sessionRunStates; void _current; return { messages: [], agentStatus: 'idle', currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0, }, traceSteps: [], isStreaming: false, currentRunId: null, sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates, }; }); }, resetSessionState: () => { set((s) => { const { currentSessionId } = s; const { [currentSessionId ?? '']: _current, ...restRunStates } = s.sessionRunStates; void _current; return { currentSessionId: null, messages: [], traceSteps: [], tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0, }, currentRunId: null, currentIteration: 0, agentStatus: 'idle', isStreaming: false, sessionRunStates: currentSessionId ? restRunStates : s.sessionRunStates, }; }); }, abort: () => { const sessionId = get().currentSessionId; // 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED) // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 set({ agentStatus: 'idle', isStreaming: false }); // v0.7.2 P3-11: 立即熄灭运行指示点。 // v0.8.0 P1-1b 修正注释与实现:此前注释称"中断未生效时 THINKING 事件会重新 // 点亮(自愈)",但 isStreaming=false 下内容事件仍被准入层丢弃。现两层自愈: // ① 本处 IPC 失败 → 回滚本地状态并恢复运行指示(下方 catch); // ② IPC 成功但引擎未真正停止 → useAgentStream 的 stateChange THINKING // 自愈(runId 匹配时恢复 isStreaming=true)。 if (sessionId) get().updateSessionRunState(sessionId, null); if (sessionId && window.metona?.agent?.abortSession) { window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); // v0.8.0 P1-1b: 中断请求失败 —— 引擎很可能仍在运行,回滚本地状态 // 并恢复运行指示与实时显示(内容事件不再被准入层丢弃) set({ agentStatus: 'thinking', isStreaming: true }); if (sessionId) get().updateSessionRunState(sessionId, 'thinking'); import('@metona-team/metona-toast') .then((mod) => mod.default.warning(t('agent.system.abort_failed'))) .catch(() => {}); }); } }, // ===== P2-11: 编辑重发 ===== editAndResend: async (messageId, newContent) => { const { messages, currentSessionId, isStreaming } = get(); if (isStreaming) { import('@metona-team/metona-toast') .then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发')) .catch(() => {}); return; } const idx = messages.findIndex((m) => m.id === messageId); if (idx < 0 || messages[idx].role !== 'user') return; const original = messages[idx]; if (!newContent.trim()) return; // DB 截断:删除该用户消息(含)之后的所有消息 if (currentSessionId && window.metona?.sessions?.truncateAfter) { try { await window.metona.sessions.truncateAfter(currentSessionId, messageId, true); } catch (err) { console.error('[AgentStore] truncateAfter failed:', err); import('@metona-team/metona-toast') .then((mod) => mod.default.error('消息截断失败,无法重发')) .catch(() => {}); return; } } // 本地截断(保留之前的消息,重置运行状态) // v0.7.3 P1-2: traceSteps 同步按锚点时间戳过滤 —— DB 侧 truncateMessagesAfter // 已过滤 metadata.traceSteps,此处对内存态做同一语义截断,消除幽灵步骤 set({ messages: messages.slice(0, idx), agentStatus: 'idle', isStreaming: false, currentIteration: 0, currentRunId: null, traceSteps: trimTraceStepsByAnchor(get().traceSteps, original.timestamp), }); // 从原消息附件重建图片参数(附件随消息保留重发) const images = (original.attachments ?? []) .filter((a) => a.type === 'image' && a.preview) .map((a) => ({ url: a.preview as string, detail: 'auto' as const })); get().sendMessage( newContent.trim(), images.length > 0 ? images : undefined, original.attachments, ); }, // ===== P2-11: 重新生成 ===== regenerate: async () => { const { messages, currentSessionId, isStreaming } = get(); if (isStreaming) { import('@metona-team/metona-toast') .then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成')) .catch(() => {}); return; } // 找最后一条用户消息 const lastUserIdx = messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user'); if ( lastUserIdx < 0 || lastUserIdx >= messages.length || messages[lastUserIdx].role !== 'user' ) { import('@metona-team/metona-toast') .then((mod) => mod.default.info('没有可重新生成的用户消息')) .catch(() => {}); return; } const lastUser = messages[lastUserIdx]; // DB 截断:删除该用户消息(含)之后的所有消息 if (currentSessionId && window.metona?.sessions?.truncateAfter) { try { await window.metona.sessions.truncateAfter(currentSessionId, lastUser.id, true); } catch (err) { console.error('[AgentStore] truncateAfter failed:', err); import('@metona-team/metona-toast') .then((mod) => mod.default.error('消息截断失败,无法重新生成')) .catch(() => {}); return; } } // 本地截断(v0.7.3 P1-2: traceSteps 同步过滤,语义与 editAndResend 一致) set({ messages: messages.slice(0, lastUserIdx), agentStatus: 'idle', isStreaming: false, currentIteration: 0, currentRunId: null, traceSteps: trimTraceStepsByAnchor(get().traceSteps, lastUser.timestamp), }); // 重发原内容(含原附件) const images = (lastUser.attachments ?? []) .filter((a) => a.type === 'image' && a.preview) .map((a) => ({ url: a.preview as string, detail: 'auto' as const })); get().sendMessage( lastUser.content, images.length > 0 ? images : undefined, lastUser.attachments, ); }, }));