diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 364b03a..765c572 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -81,6 +81,13 @@ export class AgentLoopEngine extends EventEmitter { private workspacePath: string = ''; private abortController: AbortController | null = null; private eventSeq = 0; + /** + * v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数 + * 用于校正压缩判断——估算值可能偏低(尤其中文场景), + * 导致估算没到阈值不压缩,但真实 API 调用已经 413。 + * 压缩判断时取 max(估算值, 真实值) 作为实际占用。 + */ + private lastRealInputTokens = 0; /** 当前 run 的唯一标识(用于前端过滤旧流事件) */ private runId: string = ''; /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ @@ -192,6 +199,8 @@ export class AgentLoopEngine extends EventEmitter { this.currentIteration = 0; this.currentSessionId = sessionId; this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + // v0.3.18 修复: 每个 run 重置 lastRealInputTokens,避免跨 run 污染 + this.lastRealInputTokens = 0; this.abortController = new AbortController(); // C-2 修复: 将 abortController 的 signal 注入到 adapter, // 使正在进行的 fetch 可被用户中断,避免资源泄漏 @@ -485,6 +494,12 @@ export class AgentLoopEngine extends EventEmitter { completionTokens: event.usage.outputTokens ?? 0, totalTokens: event.usage.totalTokens ?? 0, }; + // v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断 + // 估算值可能偏低(尤其中文场景),导致不压缩但 API 413 + const realInput = event.usage.inputTokens ?? 0; + if (realInput > 0) { + this.lastRealInputTokens = realInput; + } } break; @@ -597,18 +612,28 @@ export class AgentLoopEngine extends EventEmitter { // === 上下文压缩(基于 token 使用率触发) === // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow - const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow; + // v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 + const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; const estimatedTokens = this.estimateMessagesTokens(request.messages); + // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 + // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) + // 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确 + const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens); const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; - if (estimatedTokens > compressionThreshold && request.messages.length > 10) { + // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4" + // 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。 + // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 + if (actualTokens > compressionThreshold && request.messages.length >= 4) { await this.transitionTo(AgentLoopState.COMPRESSING); const compressed = await this.compressMessages(request.messages); if (compressed) { // 原地替换数组内容,确保外层 messages 引用同步更新 request.messages.splice(0, request.messages.length, ...compressed); + // v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值 + this.lastRealInputTokens = 0; this.emit('compressed', { iteration: this.currentIteration, - originalTokens: estimatedTokens, + originalTokens: actualTokens, compressedTokens: this.estimateMessagesTokens(compressed), }); } @@ -961,7 +986,8 @@ export class AgentLoopEngine extends EventEmitter { /** * 估算消息列表的 token 数 - * 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字 + * 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字 + * v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值 * @see electron/harness/utils/token-estimator.ts */ private estimateMessagesTokens(messages: MetonaMessage[]): number { @@ -1022,21 +1048,48 @@ export class AgentLoopEngine extends EventEmitter { } /** - * 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话 + * 上下文压缩 — 将旧消息摘要为一条 assistant 消息,保留近期完整对话 + * + * v0.3.18 修复: 保留策略从"固定 10 条"改为"按 token 预算动态截断" + * 之前固定保留最近 10 条,若 10 条里有超长 tool_result(如 read_file 读了 5000 行), + * 压缩后仍超 token 限制,下一轮 LLM 调用会返回 413。 + * 现在按 effectiveContextWindow 的 50% 预算从末尾反向累加消息, + * 直到预算用尽或只剩 1 条可压缩消息。 * * 策略: - * 1. 保留最后 keepRecent 条消息(约 3 轮对话) - * 2. 将前面的所有消息交给 LLM 生成摘要 - * 3. 用 [Context Summary] system 消息 + 近期消息替换原数组 + * 1. 计算 keepBudget = effectiveContextWindow * 0.5(保留区预算) + * 2. 从末尾反向累加消息到 toKeep,直到累加 token 超过 keepBudget + * 3. 将前面的所有消息交给 LLM 生成摘要 + * 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组 + * 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断 * * @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组) */ private async compressMessages(messages: MetonaMessage[]): Promise { - const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool) - if (messages.length <= keepRecent) return null; + // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 + // 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN + const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; + const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50% + const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 - let toCompress = messages.slice(0, messages.length - keepRecent); - let toKeep = messages.slice(messages.length - keepRecent); + // 从末尾反向累加,确定 toKeep 范围 + let keepStartIdx = messages.length; + let keepTokens = 0; + for (let i = messages.length - 1; i >= 0; i--) { + const msgTokens = this.estimateMessagesTokens([messages[i]]); + if (keepTokens + msgTokens > keepBudget && i < messages.length - minKeepCount) { + break; + } + keepTokens += msgTokens; + keepStartIdx = i; + } + + // 至少保留 minKeepCount 条,且至少要有 1 条可压缩 + if (keepStartIdx < minKeepCount) keepStartIdx = minKeepCount; + if (keepStartIdx >= messages.length) return null; + + let toCompress = messages.slice(0, keepStartIdx); + let toKeep = messages.slice(keepStartIdx); // MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头 // OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息 @@ -1118,7 +1171,31 @@ export class AgentLoopEngine extends EventEmitter { timestamp: Date.now(), }; - log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`); + // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断 + // 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文 + const finalKeep = toKeep.map((msg) => { + const msgTokens = this.estimateMessagesTokens([msg]); + if (msgTokens > keepBudget && msg.content) { + // 截断内容,保留头部和尾部,中间用省略标记 + const halfBudget = Math.floor(keepBudget / 2); + // v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致) + // 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token, + // 导致截断后保留字符过多,实际 token 仍超 keepBudget + const charsPerToken = 1.0; + const keepChars = Math.floor(halfBudget * charsPerToken); + if (msg.content.length > keepChars * 2) { + const head = msg.content.slice(0, keepChars); + const tail = msg.content.slice(-keepChars); + return { + ...msg, + content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`, + }; + } + } + return msg; + }); + + log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`); // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 @@ -1127,7 +1204,7 @@ export class AgentLoopEngine extends EventEmitter { summaryMessage, // 审查修复: 插入占位 user 消息避免连续 assistant 消息 { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() }, - ...toKeep, + ...finalKeep, ]; } catch (error) { log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message); diff --git a/electron/harness/memory/consolidator.ts b/electron/harness/memory/consolidator.ts index 8bcb64b..78bd92e 100644 --- a/electron/harness/memory/consolidator.ts +++ b/electron/harness/memory/consolidator.ts @@ -25,6 +25,7 @@ import type { IMetonaProviderAdapter } from '../types/metona-adapter'; import type { MetonaRequest, MetonaMessage } from '../types'; import type { WorkspaceService } from '../../services/workspace.service'; import type { IterationStep } from '../agent-loop/types'; +import type { MemoryManager } from './manager'; /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; @@ -43,6 +44,11 @@ export interface ConsolidationResult { } export class MemoryConsolidator { + /** v0.3.18 修复: 跟踪进行中的 consolidate 任务,供应用退出时等待 */ + private runningPromise: Promise | null = null; + /** v0.3.18 修复: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉写入 */ + private memoryManager: MemoryManager | null = null; + constructor( private adapter: IMetonaProviderAdapter, private workspaceService: WorkspaceService, @@ -55,6 +61,47 @@ export class MemoryConsolidator { this.adapter = adapter; } + /** + * v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表 + */ + setMemoryManager(memoryManager: MemoryManager): void { + this.memoryManager = memoryManager; + } + + /** + * v0.3.18 修复: 是否有进行中的 consolidate 任务 + */ + isRunning(): boolean { + return this.runningPromise !== null; + } + + /** + * v0.3.18 修复: 等待进行中的 consolidate 任务完成(应用退出时调用) + * + * @param timeoutMs 超时时间(默认 35 秒,略大于 LLM 调用的 30 秒超时) + * @returns true 表示已完成,false 表示超时 + */ + async waitForCompletion(timeoutMs: number = 35_000): Promise { + if (!this.runningPromise) return true; + let timedOut = false; + let timerHandle: ReturnType | undefined; + const timer = new Promise((resolve) => { + timerHandle = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + }); + try { + await Promise.race([ + this.runningPromise.catch(() => {}), + timer, + ]); + return !timedOut; + } finally { + if (timerHandle) clearTimeout(timerHandle); + } + } + /** * 固化本次对话的重要记忆到 MEMORY.md * @@ -67,6 +114,32 @@ export class MemoryConsolidator { userMessage: string, assistantAnswer: string, iterations: IterationStep[], + ): Promise { + // v0.3.18 修复: 跟踪进行中的 consolidate,供应用退出时等待 + const promise = this.executeConsolidation(userMessage, assistantAnswer, iterations); + this.runningPromise = promise; + try { + return await promise; + } finally { + // 只有当前 promise 仍是自己时才清理,避免被后续调用覆盖 + if (this.runningPromise === promise) { + this.runningPromise = null; + } + } + } + + /** + * 实际执行固化的内部方法 + * + * v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉 + * 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。 + * 现在每条固化到 MEMORY.md 的条目也同步存入 semantic_memories 表, + * 使 TF-IDF 检索能命中跨会话的用户偏好/决策/项目上下文。 + */ + private async executeConsolidation( + userMessage: string, + assistantAnswer: string, + iterations: IterationStep[], ): Promise { try { // 1. 构建对话摘要 @@ -105,6 +178,27 @@ export class MemoryConsolidator { try { this.workspaceService.appendMemory(item.section, truncatedEntry); validEntries.push({ section: item.section, entry: truncatedEntry }); + + // v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉 + // 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。 + // 现在每条固化到 MEMORY.md 的条目也存入 semantic_memories, + // key 用 section(如"用户偏好"),value 用 entry 内容, + // confidence 按 section 语义分级(偏好/决策最高,待办最低) + if (this.memoryManager) { + try { + const confidence = this.getSectionConfidence(item.section); + this.memoryManager.store({ + type: 'semantic', + content: truncatedEntry, + summary: `[${item.section}] ${truncatedEntry.slice(0, 60)}`, + source: 'agent_thought', + importance: confidence, + }); + } catch (dbErr) { + // DB 写入失败不影响 MEMORY.md 已写入的结果,仅记录日志 + log.warn(`[MemoryConsolidator] Failed to sync to semantic_memories:`, dbErr); + } + } } catch (err) { log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err); skipped++; @@ -278,6 +372,29 @@ export class MemoryConsolidator { return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0; } + /** + * v0.3.18 修复: 按 MEMORY.md 分区语义返回 confidence(重要度) + * + * 用于同步写入 semantic_memories 时的 importance 字段: + * - 用户偏好/重要决策:0.9(长期有效,跨会话高价值) + * - 项目上下文/已知问题:0.7(项目级,中等价值) + * - 待办事项:0.5(短期,可能很快完成) + */ + private getSectionConfidence(section: string): number { + switch (section) { + case '用户偏好': + case '重要决策': + return 0.9; + case '项目上下文': + case '已知问题': + return 0.7; + case '待办事项': + return 0.5; + default: + return 0.6; + } + } + /** * 截断字符串 */ diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 9ab1d52..291caca 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -44,21 +44,37 @@ interface MemorySearchOptions { minImportance?: number; } -/** 分词:将文本拆分为词项(支持中英文) */ +/** + * 分词:将文本拆分为词项(支持中英文) + * + * v0.3.18 修复: 改进中文分词策略,减少 bigram 噪声 + * 之前对整段文本连续取 bigram,会跨越标点边界产生无意义组合 + * (如"开发规范。下一句"会产生"范。"和"。下"这类噪声 bigram), + * 降低 IDF 区分度。 + * 现在先按中英文标点切分子句,再在每个子句内做 bigram, + * 避免跨句组合,提升检索准确度。 + */ function tokenize(text: string): string[] { - // 转小写,按非字母数字字符分割 + // 转小写 const lower = text.toLowerCase(); // 英文词 const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? []; - // 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文 - const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? []; + + // v0.3.18 修复: 按中英文标点切分子句,再在每个子句内做 bigram + // 标点包括:中文句号/逗号/顿号/分号/感叹/问号 + 英文 .,;!?() + const sentences = lower.split(/[。,、;!?.,;!?()\n\r\t]/); const bigrams: string[] = []; - for (let i = 0; i < cjkChars.length - 1; i++) { - bigrams.push(cjkChars[i] + cjkChars[i + 1]); - } - // 单字 CJK 补 unigram(避免单字文档无 token) - if (cjkChars.length === 1) { - bigrams.push(cjkChars[0]); + for (const sentence of sentences) { + // 提取子句内的 CJK 字符(覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文) + const cjkChars = sentence.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g); + if (!cjkChars || cjkChars.length === 0) continue; + for (let i = 0; i < cjkChars.length - 1; i++) { + bigrams.push(cjkChars[i] + cjkChars[i + 1]); + } + // 单字 CJK 子句补 unigram(避免单字文档无 token) + if (cjkChars.length === 1) { + bigrams.push(cjkChars[0]); + } } return [...words, ...bigrams]; } diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts index 3b85311..f616120 100644 --- a/electron/harness/prompts/context-builder.ts +++ b/electron/harness/prompts/context-builder.ts @@ -15,6 +15,7 @@ * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章 */ +import log from 'electron-log'; import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types'; import type { WorkspaceFiles } from '../../services/workspace.service'; import { estimateStringTokens } from '../utils/token-estimator'; @@ -34,6 +35,30 @@ interface ContextBuildParams { * 上下文构建器 */ export class ContextBuilder { + /** + * v0.3.18 修复: 标记上次 build 是否使用了兜底身份(SOUL.md 为空或不存在) + * 供 handlers.ts 读取后决定是否向前端发送 toast 提示 + */ + private lastUsedFallbackRole = false; + /** + * v0.3.18 修复: 降级通知去重标志 + * 第一次降级时通知,之后不再重复通知(避免每次发消息都弹 toast) + * 当 SOUL.md 恢复内容后重置为 false,下次再降级时会重新通知 + */ + private fallbackRoleNotified = false; + + /** + * v0.3.18 修复: 获取上次 build 是否使用了兜底身份 + * 仅在首次降级(或恢复后再次降级)时返回 true,避免每次发消息都弹 toast + */ + isUsingFallbackRole(): boolean { + if (this.lastUsedFallbackRole && !this.fallbackRoleNotified) { + this.fallbackRoleNotified = true; + return true; + } + return false; + } + /** * 构建完整的 MetonaContext */ @@ -138,6 +163,8 @@ export class ContextBuilder { * * v0.3.14: 兜底身份定义使用 Metona 灵魂定义(含角色/原则/风格/边界/元指令) * SOUL.md 存在但内容为空(仅空白字符)时也走兜底分支 + * v0.3.18 修复: 降级时设置 lastUsedFallbackRole 标志 + 打 WARN 日志, + * 避免用户自定义人格丢失但不知情的"安静失败"问题 */ private buildRoleDefinition(soulContent?: string): string { const parts: string[] = []; @@ -145,8 +172,14 @@ export class ContextBuilder { // v0.3.14: SOUL.md 不存在或内容为空(仅空白)时使用兜底身份定义 if (soulContent && soulContent.trim()) { // SOUL.md 存在且有内容 — 直接使用其全部内容,不加任何固定前缀 + this.lastUsedFallbackRole = false; + // v0.3.18 修复: SOUL.md 恢复内容后重置通知标志,下次再降级时会重新通知 + this.fallbackRoleNotified = false; parts.push(soulContent); } else { + // v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 handlers.ts 读取后发 toast + this.lastUsedFallbackRole = true; + log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity'); // 兜底身份定义(Metona 灵魂定义) parts.push(`# Metona — 灵魂定义 > "想清楚再动手,做对比做快重要" @@ -255,7 +288,8 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre /** * Token 估算 - * 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字 + * 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字 + * v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值 * @see electron/harness/utils/token-estimator.ts */ private estimateTokens( diff --git a/electron/harness/utils/token-estimator.ts b/electron/harness/utils/token-estimator.ts index be1bc9c..d4c4769 100644 --- a/electron/harness/utils/token-estimator.ts +++ b/electron/harness/utils/token-estimator.ts @@ -2,10 +2,15 @@ * Token 估算工具 — 跨 Provider 通用 * * 策略:智能字符估算,区分中文字符与 ASCII 字符 - * - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token + * - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token * - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token * - 其他 Unicode(emoji 等):1 字符 ≈ 1 token * + * v0.3.18 修复: CJK 系数从 1.5 调整为 1.0 + * 实测 DeepSeek/GLM 等 BPE tokenizer 对中文约 0.6-0.8 token/字, + * 原系数 1.5 导致中文场景估算偏高约 2 倍,80% 阈值实际在 40-50% 就触发压缩, + * 造成上下文过早丢失。1.0 仍保守(留 ~25% 安全裕度),但更接近真实值。 + * * 对比旧的 `length / 2` 方案: * - 中文场景:估算准确度从 ~50% 提升到 ~90% * - 英文场景:从偏低变为接近真实 @@ -19,9 +24,10 @@ const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\ /** * L-17 修复: 提取魔法系数为命名常量,便于统一调整 + * v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值 * @see project_memory.md — Token estimation coefficients */ -const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token +const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8) const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token const OTHER_TOKEN_RATIO = 1; // 其他 Unicode(emoji 等):1 字符 ≈ 1 token const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index bef7341..047c545 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -146,6 +146,15 @@ export function registerAllIPCHandlers( const workspaceFiles = workspaceService.getFiles(); const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath()); + // v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户 + // 避免用户自定义人格丢失但不知情的"安静失败"问题 + if (contextBuilder.isUsingFallbackRole() && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'info', + message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格', + }); + } + // 检索与用户消息相关的记忆,注入到 System Prompt 动态区 try { const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 }); @@ -265,12 +274,26 @@ export function registerAllIPCHandlers( } }; - // M-6: 转发上下文压缩事件为 toast 通知 + // M-6: 转发上下文压缩事件为 toast 通知 + streamEvent 通知 + // v0.3.18 修复: 同时通过 streamEvent 转发压缩数据,前端 store 据以更新 token 显示 + // 解决"压缩触发但前端 token 显示不降"的缺陷 const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => { + const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0)); if (!mainWindow.isDestroyed()) { + // toast 通知用户压缩已发生 mainWindow.webContents.send('toast:show', { type: 'info', - message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens`, + message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`, + }); + // 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store + mainWindow.webContents.send('agent:streamEvent', { + type: 'compressed', + sessionId, + iteration: data.iteration ?? 0, + originalTokens: data.originalTokens, + compressedTokens: data.compressedTokens, + savedTokens, + timestamp: Date.now(), }); } }; diff --git a/electron/main.ts b/electron/main.ts index e37ba1c..0e737d4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -14,7 +14,7 @@ * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理 */ -import { app, shell, Menu, BrowserWindow } from 'electron'; +import { app, shell, Menu, BrowserWindow, ipcMain } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { electronApp, optimizer } from '@electron-toolkit/utils'; @@ -331,15 +331,34 @@ async function initialize(): Promise { agentLoop.setWorkspacePath(workspaceInfo.path); // P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop + // v0.3.18 修复: MCP 完成后广播 tools:ready 事件,前端据以启用发送按钮 + // 之前 MCP 初始化是异步的,用户在 MCP 完成前发送消息会用到不全的工具集 + // v0.3.18 修复: 维护 toolsReady 标志 + 暴露 tools:isReady 查询,解决竞态条件 + // MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件 + // 可能在前端监听器注册前就发出,导致前端永久错过事件、输入框永久禁用 + let toolsReady = false; mcpManager.initialize().then(() => { agentLoop.setTools(toolRegistry.listTools()); log.info('[MCP] Tools registered and synced to AgentLoop'); + toolsReady = true; + // 广播工具就绪事件给所有窗口 + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('tools:ready', { toolCount: toolRegistry.size }); + } }).catch((err) => { log.warn('MCP Manager initialization error:', err); + // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送 + toolsReady = true; + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('tools:ready', { toolCount: toolRegistry.size }); + } }); // ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)===== const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService); + // v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表 + // 实现 DB 记忆与 MEMORY.md 双轨交叉,避免"AI 记得一半"的不一致 + memoryConsolidator.setMemoryManager(memoryManager); // ===== Task Orchestrator(子任务委派)===== const orchestrator = new TaskOrchestrator( @@ -473,6 +492,9 @@ async function initialize(): Promise { promptDefender, outputValidator, confirmationHook, memoryConsolidator, orchestrator, ); + // v0.3.18 修复: 注册 tools:isReady 查询,前端注册 onReady 监听器后立即查询, + // 解决 tools:ready 事件在监听器注册前发出的竞态条件 + ipcMain.handle('tools:isReady', () => ({ ready: toolsReady, toolCount: toolRegistry.size })); }, }); @@ -541,12 +563,13 @@ async function initialize(): Promise { (global as Record).shutdownInProgress = true; event.preventDefault(); - // 内部异步清理,加 5 秒超时保护防止卡死 + // v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待 + // 之前 5 秒超时会导致进行中的 consolidate 被 kill,记忆丢失 const shutdownTimeout = setTimeout(() => { log.warn('[Shutdown] Timeout reached, forcing exit'); if (databaseService) { databaseService.close(); databaseService = null; } app.exit(0); - }, 5_000); + }, 40_000); (async () => { try { @@ -555,6 +578,19 @@ async function initialize(): Promise { log.error('[Shutdown] MCP shutdown failed:', err); } cleanupBrowser(); + + // v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失 + // consolidate 内部有 30 秒 LLM 超时保护,此处等待 35 秒足够覆盖 + if (memoryConsolidator.isRunning()) { + log.info('[Shutdown] Waiting for memory consolidation to complete...'); + const completed = await memoryConsolidator.waitForCompletion(35_000); + if (!completed) { + log.warn('[Shutdown] Memory consolidation timed out, proceeding with exit'); + } else { + log.info('[Shutdown] Memory consolidation completed'); + } + } + if (databaseService) { databaseService.close(); databaseService = null; } clearTimeout(shutdownTimeout); app.exit(0); diff --git a/electron/preload.ts b/electron/preload.ts index fb37c60..7062c23 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -167,6 +167,14 @@ const metonaAPI = { tools: { list: () => ipcRenderer.invoke('tools:list'), toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled), + /** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */ + onReady: (callback: (data: { toolCount: number }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data); + ipcRenderer.on('tools:ready', listener); + return () => ipcRenderer.removeListener('tools:ready', listener); + }, + /** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */ + isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>, }, // ===== 数据管理 ===== diff --git a/package-lock.json b/package-lock.json index ece8e3d..97b3da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.16", + "version": "0.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.16", + "version": "0.3.18", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 1874607..f1c5a15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.16", + "version": "0.3.18", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/App.tsx b/src/App.tsx index 70d9e89..e3c43ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,27 @@ export default function App(): React.JSX.Element { // 无 IPC 可用时也标记为已加载(避免永久禁用) useAgentStore.getState().setConfigLoaded(true); } + + // v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) + // 未就绪前 sendMessage 会被阻止,避免用户在工具不全时发送消息 + // v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态 + if (window.metona?.tools?.onReady) { + const unsubscribe = window.metona.tools.onReady((data) => { + console.log('[App] Tools ready:', data.toolCount, 'tools'); + useAgentStore.getState().setToolsReady(true); + }); + // 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态 + window.metona.tools.isReady?.().then((status) => { + if (status.ready) { + console.log('[App] Tools already ready (polled):', status.toolCount, 'tools'); + useAgentStore.getState().setToolsReady(true); + } + }).catch((err) => { console.error('[App] tools.isReady query failed:', err); }); + return unsubscribe; + } else { + // 无 IPC 可用时直接标记为就绪(避免永久禁用) + useAgentStore.getState().setToolsReady(true); + } }, []); return ( diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 68c2817..d7b7af7 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -47,6 +47,8 @@ export function ChatInput(): React.JSX.Element { const abort = useAgentStore((s) => s.abort); const isStreaming = useAgentStore((s) => s.isStreaming); const configLoaded = useAgentStore((s) => s.configLoaded); + // v0.3.18 修复: 工具未就绪时禁用发送按钮 + const toolsReady = useAgentStore((s) => s.toolsReady); const tokenUsage = useAgentStore((s) => s.tokenUsage); const currentSessionId = useSessionStore((s) => s.currentSessionId); const provider = useAgentStore((s) => s.provider); @@ -173,6 +175,7 @@ export function ChatInput(): React.JSX.Element { if (!trimmed && attachments.length === 0) return; if (isStreaming) return; if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送 + if (!toolsReady) return; // v0.3.18: 工具未就绪时禁止发送 // 处理 / 命令 if (trimmed.startsWith('/')) { @@ -328,8 +331,8 @@ export function ChatInput(): React.JSX.Element { onChange={handleChange} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'} - disabled={isStreaming || !configLoaded} + placeholder={configLoaded ? (toolsReady ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '工具加载中...') : '正在加载配置...'} + disabled={isStreaming || !configLoaded || !toolsReady} multiline rows={1} sx={{ @@ -365,7 +368,7 @@ export function ChatInput(): React.JSX.Element { 中断 ) : ( - )} diff --git a/src/components/trace/TokenUsage.tsx b/src/components/trace/TokenUsage.tsx index 4c95e30..2bc6339 100644 --- a/src/components/trace/TokenUsage.tsx +++ b/src/components/trace/TokenUsage.tsx @@ -17,9 +17,12 @@ export function TokenUsage(): React.JSX.Element { const contextWindow = useAgentStore((s) => s.contextWindow); const maxTokens = contextWindow; - const usagePercent = tokenUsage.totalTokens > 0 - ? Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100) + // v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens + // 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比 + const contextPercent = maxTokens > 0 && tokenUsage.lastInputTokens > 0 + ? Math.min((tokenUsage.lastInputTokens / maxTokens) * 100, 100) : 0; + // 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成) const inputPercent = tokenUsage.totalTokens > 0 ? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100 : 0; @@ -50,20 +53,28 @@ export function TokenUsage(): React.JSX.Element { - 总计 + 累计消耗 {tokenUsage.totalTokens > 0 ? formatTokens(tokenUsage.totalTokens) : '-'} - 上下文 + 上下文占用 80 ? 'error.main' : usagePercent > 60 ? 'warning.main' : 'text.secondary', + color: contextPercent > 80 ? 'error.main' : contextPercent > 60 ? 'warning.main' : 'text.secondary', }}> - {tokenUsage.totalTokens > 0 ? `${usagePercent.toFixed(1)}%` : '-'} + {tokenUsage.lastInputTokens > 0 ? `${formatTokens(tokenUsage.lastInputTokens)} (${contextPercent.toFixed(1)}%)` : '-'} + {tokenUsage.lastCompressedSaved > 0 && ( + + 压缩节省 + + {formatTokens(tokenUsage.lastCompressedSaved)} + + + )} 迭代 diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 0adfc7a..f3f1b83 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -104,6 +104,10 @@ export function useAgentStream(): void { toolCall?: { id: string; name: string; args: Record }; toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number }; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + /** v0.3.18 修复: 上下文压缩事件数据 */ + savedTokens?: number; + originalTokens?: number; + compressedTokens?: number; error?: { code: string; message: string }; state?: string; }; @@ -345,9 +349,13 @@ export function useAgentStream(): void { if (data.usage) { const cur = getStore().tokenUsage; getStore().updateTokenUsage({ + // 累计值:所有轮次累加,只增不减 inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0), outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0), totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0), + // v0.3.18 修复: 单次上下文占用 — 替换不累加 + // 反映当前对话上下文的真实大小,压缩后会大幅下降 + lastInputTokens: data.usage.inputTokens ?? 0, }); // 同步更新当前 Trace 步骤的 token 用量 @@ -361,6 +369,12 @@ export function useAgentStream(): void { } break; + // v0.3.18 修复: 上下文压缩事件 — 记录节省的 token,供 UI 显示压缩效果 + case 'compressed': { + getStore().applyCompression(data.savedTokens ?? 0); + break; + } + // 流结束 case 'done': // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index 49aa541..ccd098d 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -61,9 +61,24 @@ 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 步骤 ===== @@ -107,6 +122,10 @@ interface AgentState { // P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮 configLoaded: boolean; + // v0.3.18 修复: 工具就绪标志 — MCP 初始化完成前禁用发送按钮 + // 避免用户在工具不全时发送消息,导致子任务委派等 MCP 工具不可用 + toolsReady: boolean; + // Run 标识(用于过滤旧流事件) currentRunId: string | null; @@ -126,12 +145,19 @@ interface AgentState { 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; updateTraceStep: (iteration: number, updates: Partial) => void; updateTraceStepById: (id: string, updates: Partial) => void; updateLastTraceStep: (updates: Partial) => void; updateTokenUsage: (usage: Partial) => void; + /** + * v0.3.18 修复: 应用上下文压缩事件 + * 记录压缩节省的 token 数,用于 UI 展示压缩效果 + */ + applyCompression: (savedTokens: number) => void; setProvider: (provider: string, model: string) => void; setMaxIterations: (max: number) => void; setCurrentIteration: (n: number) => void; @@ -147,10 +173,11 @@ export const useAgentStore = create((set, get) => ({ agentStatus: 'idle', currentIteration: 0, maxIterations: 20, - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, traceSteps: [], isStreaming: false, configLoaded: false, + toolsReady: false, currentRunId: null, provider: '', model: '', @@ -159,7 +186,7 @@ export const useAgentStore = create((set, get) => ({ // ===== Actions ===== setCurrentSession: (id) => { - set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null }); + 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) { @@ -206,7 +233,19 @@ export const useAgentStore = create((set, get) => ({ })); set({ traceSteps: steps }); } - if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage }); + // v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段 + if (data.tokenUsage) { + const old = data.tokenUsage as Partial; + set({ + tokenUsage: { + inputTokens: old.inputTokens ?? 0, + outputTokens: old.outputTokens ?? 0, + totalTokens: old.totalTokens ?? 0, + lastInputTokens: old.lastInputTokens ?? 0, + lastCompressedSaved: old.lastCompressedSaved ?? 0, + }, + }); + } } }).catch((err) => { console.error('[AgentStore]', err); }); } @@ -227,6 +266,12 @@ export const useAgentStore = create((set, get) => ({ 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-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {}); + return; + } + // 没有当前会话时自动创建 if (!sessionId && window.metona?.sessions?.create) { try { @@ -272,7 +317,7 @@ export const useAgentStore = create((set, get) => ({ // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // 历史 trace 仍在 DB 中,切换会话回来可恢复 - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, })); // 自动更新会话标题为用户第一条消息 @@ -362,6 +407,8 @@ export const useAgentStore = create((set, get) => ({ setStreaming: (streaming) => set({ isStreaming: streaming }), setConfigLoaded: (loaded) => set({ configLoaded: loaded }), + // v0.3.18 修复: 工具就绪标志 setter + setToolsReady: (ready) => set({ toolsReady: ready }), setCurrentRunId: (runId) => set({ currentRunId: runId }), @@ -394,6 +441,11 @@ export const useAgentStore = create((set, get) => ({ 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,从配置读取 @@ -430,7 +482,7 @@ export const useAgentStore = create((set, get) => ({ messages: [], agentStatus: 'idle', currentIteration: 0, - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, traceSteps: [], isStreaming: false, currentRunId: null, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 38b794a..a1aaa45 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -260,6 +260,10 @@ interface MetonaToolInfo { interface MetonaToolsAPI { list: () => Promise; toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; + /** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */ + onReady: (callback: (data: { toolCount: number }) => void) => () => void; + /** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态) */ + isReady: () => Promise<{ ready: boolean; toolCount: number }>; } // ===== SearXNG API =====