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 跳过
1023 lines
39 KiB
TypeScript
1023 lines
39 KiB
TypeScript
/**
|
||
* 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<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 保持一致)
|
||
* - 云端 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; // 文本文件内容
|
||
/**
|
||
* 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 不为 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<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)写入 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,
|
||
);
|
||
},
|
||
}));
|