Files
metona-ai-desktop/src/stores/agent-store.ts
T
thzxx 516d8a728f feat: 升级至 v0.3.15 — MiMo 多模态 + 工作空间数据库继承 + 引导窗口上下文配置 + TS 错误修复
- feat(mimo): 适配器支持图片输入,将 images 转为 OpenAI 兼容 content parts 数组
- feat(workspace): 切换工作空间时支持继承数据库 agent.db(SQLite backup API 原子导出,自动 checkpoint WAL)
- feat(onboarding): 引导弹窗 LLM 配置加上下文长度字段(DeepSeek/Agnes=1000000、MiMo=131072、Ollama=null)
- fix(types): 修复 16 个 TypeScript 编译错误(setConfigLoaded 接口声明、10 个 IPC 返回类型加 error 字段、MUI TextField readOnly slot 迁移)
- chore(workspace): 移除死代码 traces 目录(trace 数据实际存储于数据库 sessions.metadata 字段,traces 目录从未被任何代码读写)
- docs(mimo): API 文档补充多模态输入章节和 curl/Python 代码示例
- chore: 版本号 0.3.14 → 0.3.15
2026-07-21 21:05:50 +08:00

449 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 保持一致)
* - 云端 ProviderDeepSeek/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<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 {
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<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;
// P1-6 修复: 配置加载完成标志的 setter
setConfigLoaded: (loaded: boolean) => void;
setCurrentRunId: (runId: string | null) => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => 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<AgentState>((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.numCtxDeepSeek 用 deepseek.contextWindowAgnes 用 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); });
}
},
}));