Files
metona-ai-desktop/src/stores/agent-store.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

1023 lines
39 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';
// 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.traceStepsstartedAt <
* 锚点消息 created_at,严格小于 —— 锚点触发的 run 与消息同毫秒落库,等值
* 属于"锚点侧"必须丢弃);本函数对前端内存态做同一语义的过滤,保证截断后
* TraceViewer 不再显示已被撤销轮次的"幽灵步骤"(切换会话再切回时 DB 与
* 内存两侧口径一致)。legacy 步骤缺 startedAt 时保守保留(无时间依据可判废)。
*/
export function trimTraceStepsByAnchor<T extends { startedAt?: number }>(
steps: T[],
anchorTimestamp: number,
): T[] {
return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp);
}
/**
* 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; // 文本文件内容
/**
* 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<string, AgentStatus>;
/**
* v0.7.3 P1-4: 模型视觉能力探测缓存(modelId → supportsVision)。
* 仅存探测为 false 的条目即可生效(缺失/undefined = 未知 → 上传门控保守放行)。
* 数据源:window.metona.llm.listModels()Ollama 侧为 /api/show 能力探测)。
*/
modelVisionCaps: Record<string, boolean>;
// 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;
// v0.3.18 修复: 工具就绪标志的 setter
setToolsReady: (ready: boolean) => void;
setCurrentRunId: (runId: string | null) => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => 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<void>;
saveTraceData: () => void;
clearMessages: () => Promise<void>;
/**
* 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<void>;
/**
* P2-11: 重新生成——删除最后一条用户消息(含)之后的所有消息并重发原内容
*/
regenerate: () => Promise<void>;
}
export const useAgentStore = create<AgentState>((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<TokenUsage>;
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 不为 falsedone 未到达时 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.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);
});
}
},
// 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<string, boolean> = {};
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)写入 traceStepssaveTrace 原样 JSON.stringify
// 进 sessions.metadata,长会话多图可把单行撑到数十 MB。复用显示层裁剪
// toDisplayResultdataUrl 剥离 + 任意 >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:clearMessagesDB 侧同步删除 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,
);
},
}));