- 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
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/**
|
||
* 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<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.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); });
|
||
}
|
||
},
|
||
}));
|