From 025f00171bb00be02928ed000d5ca95993cb89e7 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 12 Jul 2026 19:46:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.3.0=20?= =?UTF-8?q?=E2=80=94=20=E5=AE=89=E5=85=A8=E5=A2=9E=E5=BC=BA=E3=80=81?= =?UTF-8?q?=E6=AD=BB=E5=BE=AA=E7=8E=AF=E6=A3=80=E6=B5=8B=E3=80=81=E5=85=AD?= =?UTF-8?q?=E8=BD=AE=E5=85=A8=E9=9D=A2=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。 新增功能: - OutputValidator 事实一致性检查 + 幻觉检测 - PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套) - DeadLoopError 死循环检测(连续3轮相同工具调用自动终止) - PolicyEngine maxFrequency 滑动窗口频率限制 - MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换 - 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/]) - 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复 六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW): CRITICAL: - main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效 - Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话 - engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死 HIGH: - 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前) - deadLoop 事件前端未处理 - ConfirmationHook.clearPending 从未调用导致定时器泄漏 - engine.ts retry abort listener 未移除导致监听器堆积 - MCPManager JSON.parse 无 try-catch 导致初始化崩溃 - sendMessage 自动创建会话不同步 session-store - setCurrentSession 竞态导致旧请求覆盖新会话数据 MEDIUM: - toggleFocusMode 覆盖用户原有面板状态 - 正则检测可被常见词绕过 - 频率限制内存泄漏 + customPolicies 覆盖 - LIKE 回退转义未包含反斜杠 - OutputValidator 新功能未传入 toolResults/context - MemoryConsolidator LLM 调用无超时保护 - AuditService 每次 log 都查询数据库 - browser-window-manager 超时后未停止页面加载 - output-validator URL 比较大小写敏感 - FileReader 无 onerror 导致 Promise 永久挂起 - /clear /export 不关闭斜杠菜单 - 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照 LOW: - abortPromise 事件监听器堆积 - RateLimitHook Map 内存泄漏 - memory.ts await 同步方法 - PolicyEngine 死代码清理 - Orchestrator sessionDepth 会话结束不清理 - workspace.service.ts 元数据插入边界问题 --- electron/harness/agent-loop/engine.ts | 122 +++++++++- electron/harness/agent-loop/types.ts | 2 + electron/harness/hooks/index.ts | 3 + electron/harness/hooks/pre-tool.ts | 12 + electron/harness/memory/consolidator.ts | 6 +- electron/harness/memory/manager.ts | 86 ++++--- .../harness/orchestration/orchestrator.ts | 14 +- electron/harness/sandbox/permissions.ts | 111 ++++++++- .../security/prompt-injection-defense.ts | 227 ++++++++++++++++-- .../tools/built-in/browser-window-manager.ts | 6 + electron/harness/tools/built-in/memory.ts | 6 +- .../harness/verification/output-validator.ts | 199 ++++++++++++++- electron/ipc/handlers.ts | 26 +- electron/main.ts | 2 +- electron/services/audit.service.ts | 10 +- electron/services/mcp-manager.service.ts | 15 +- electron/services/workspace.service.ts | 13 +- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 8 +- src/components/chat/ChatInput.tsx | 69 +++++- src/components/layout/DetailPanel.tsx | 31 ++- src/hooks/useKeyboardShortcuts.ts | 50 +++- src/lib/constants.ts | 2 +- src/stores/agent-store.ts | 6 + src/stores/ui-store.ts | 65 +++++ 26 files changed, 1002 insertions(+), 95 deletions(-) diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index a19f977..e812cf1 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -37,6 +37,16 @@ import { MetonaStreamEventType, MetonaFinishReason } from '../types'; import { estimateMessagesTokens } from '../utils/token-estimator'; import log from 'electron-log'; +/** + * v0.3.0: 死循环错误 — 在 executeOneIterationStream 中抛出,主循环捕获后以 DEAD_LOOP 原因终止 + */ +class DeadLoopError extends Error { + constructor(message: string) { + super(message); + this.name = 'DeadLoopError'; + } +} + const DEFAULT_CONFIG: AgentLoopConfig = { maxIterations: 20, timeoutMs: 120_000, @@ -74,6 +84,9 @@ export class AgentLoopEngine extends EventEmitter { /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ private currentRunPromise: Promise | null = null; + /** v0.3.0: 工具调用签名历史(用于死循环检测) */ + private toolCallHistory: string[] = []; + constructor( config: Partial = {}, private adapter: IMetonaProviderAdapter, @@ -163,6 +176,8 @@ export class AgentLoopEngine extends EventEmitter { this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; this.abortController = new AbortController(); this.eventSeq = 0; + // v0.3.0: 重置工具调用历史(用于死循环检测) + this.toolCallHistory = []; try { await this.transitionTo(AgentLoopState.INIT); @@ -229,6 +244,9 @@ export class AgentLoopEngine extends EventEmitter { return this.finish(TerminationReason.COMPLETED, step.thought?.content); } + // v0.3.0: 死循环检测已移入 executeOneIterationStream 的 PARSING 阶段后, + // 通过抛出 DeadLoopError 在此处 catch 块捕获处理 + // 执行工具并将结果加入消息 if (step.toolResults) { for (const result of step.toolResults) { @@ -249,11 +267,17 @@ export class AgentLoopEngine extends EventEmitter { return this.finish(TerminationReason.MAX_ITERATIONS); return this.finish(TerminationReason.TIMEOUT); } catch (error) { + // v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止 + if (error instanceof DeadLoopError) { + return this.finish(TerminationReason.DEAD_LOOP, error.message); + } const errMsg = (error as Error).message; if (this.aborted || errMsg.includes('Aborted')) { return this.finish(TerminationReason.USER_INTERRUPT); } - this.emit('error', { error: errMsg }); + // v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw, + // 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行 + log.error(`[AgentLoop] Run failed: ${errMsg}`); return this.finish(TerminationReason.ERROR, undefined, error as Error); } } @@ -424,6 +448,23 @@ export class AgentLoopEngine extends EventEmitter { this.accumulateTokens(tokenUsage); } + // v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测 + // 确保第3轮重复调用的副作用不会产生(工具尚未执行) + if (step.toolCalls && step.toolCalls.length > 0) { + if (this.detectDeadLoop(step.toolCalls)) { + log.warn(`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`); + this.emit('deadLoop', { + iteration: this.currentIteration, + runId: this.runId, + sessionId: this.currentSessionId, + }); + // 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止 + throw new DeadLoopError( + `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`, + ); + } + } + // === EXECUTING: 执行工具调用 === if (step.toolCalls && step.toolCalls.length > 0) { // transitionTo 已发射 stateChange 事件,无需重复 emit @@ -456,16 +497,23 @@ export class AgentLoopEngine extends EventEmitter { step.toolCalls.map((tc) => executeAndForward(tc)), ); const controller = this.abortController; + let onAbort: (() => void) | null = null; const abortPromise = new Promise((resolve) => { if (this.aborted) return resolve(null); if (controller) { - controller.signal.addEventListener('abort', () => resolve(null), { once: true }); + onAbort = () => resolve(null); + controller.signal.addEventListener('abort', onAbort, { once: true }); } }); const raceResult = await Promise.race([toolsPromise, abortPromise]) as | PromiseSettledResult[] | null; + // v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积 + if (onAbort && controller && raceResult !== null) { + controller.signal.removeEventListener('abort', onAbort); + } + if (raceResult === null) { // 被 abort 中断,标记步骤并退出 step.completedAt = Date.now(); @@ -543,6 +591,11 @@ export class AgentLoopEngine extends EventEmitter { } catch (error) { step.completedAt = Date.now(); step.state = AgentLoopState.TERMINATED; + // v0.3.0 修复: DeadLoopError 抛出时,将 step 加入 iterations 数组, + // 确保死循环轮的 LLM thought 内容不丢失(用户可观察 Agent 被终止前的最后思考) + if (error instanceof DeadLoopError) { + this.iterations.push(step); + } throw error; } } @@ -665,19 +718,25 @@ export class AgentLoopEngine extends EventEmitter { const waitMs = Math.max(500, delay + jitter); log.warn(`[AgentLoop] Retry ${attempt + 1}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`); await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, waitMs); + const timer = setTimeout(() => { + // v0.3.0 修复: timer 先触发时移除 abort 监听器,避免监听器堆积 + if (onAbort && signal) signal.removeEventListener('abort', onAbort); + resolve(undefined); + }, waitMs); // 支持 abort 中断等待 const signal = this.abortController?.signal; + let onAbort: (() => void) | null = null; if (signal) { if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } - signal.addEventListener('abort', () => { + onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')); - }, { once: true }); + }; + signal.addEventListener('abort', onAbort, { once: true }); } }); } @@ -728,6 +787,59 @@ export class AgentLoopEngine extends EventEmitter { return estimateMessagesTokens(messages); } + /** + * v0.3.0: 死循环检测 + * + * 检测策略: + * 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。 + * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。 + * + * v0.3.0 修复: + * - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报 + * + * @param toolCalls 当前轮次的工具调用 + * @returns 是否检测到死循环 + */ + private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean { + // 将当前轮次的工具调用序列化为签名 + // v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名 + // v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归 + const stableStringify = (obj: unknown, visited: Set = new Set(), depth = 0): string => { + if (depth > 10) return '...'; // 深度上限防止过度递归 + if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); + if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护 + visited.add(obj); + try { + if (Array.isArray(obj)) return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`; + const keys = Object.keys(obj as Record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record)[k], visited, depth + 1)}`).join(',')}}`; + } finally { + visited.delete(obj); + } + }; + const signature = toolCalls + .map((tc) => `${tc.name}(${stableStringify(tc.args)})`) + .join('|'); + + this.toolCallHistory.push(signature); + + // 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长) + if (this.toolCallHistory.length > 5) { + this.toolCallHistory.shift(); + } + + // 需要至少3轮数据才能检测 + if (this.toolCallHistory.length < 3) return false; + + const len = this.toolCallHistory.length; + const r1 = this.toolCallHistory[len - 1]; // 当前轮 + const r2 = this.toolCallHistory[len - 2]; // 上一轮 + const r3 = this.toolCallHistory[len - 3]; // 上上一轮 + + // 连续3轮完全相同 → 死循环 + return r1 === r2 && r2 === r3; + } + /** * 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话 * diff --git a/electron/harness/agent-loop/types.ts b/electron/harness/agent-loop/types.ts index 8deaedb..f7f4bfb 100644 --- a/electron/harness/agent-loop/types.ts +++ b/electron/harness/agent-loop/types.ts @@ -25,6 +25,8 @@ export enum TerminationReason { TIMEOUT = 'timeout', USER_INTERRUPT = 'user_interrupt', ERROR = 'error', + /** v0.3.0: 检测到死循环(连续3轮相同工具调用) */ + DEAD_LOOP = 'dead_loop', } // ===== 迭代步骤 ===== diff --git a/electron/harness/hooks/index.ts b/electron/harness/hooks/index.ts index 2a49008..2e7d835 100644 --- a/electron/harness/hooks/index.ts +++ b/electron/harness/hooks/index.ts @@ -2,3 +2,6 @@ export type { PreToolHook, HookResult } from './pre-tool'; export { PermissionCheckHook, RateLimitHook } from './pre-tool'; export type { PostToolHook } from './post-tool'; export { AuditLogHook, MemoryTriggerHook } from './post-tool'; +// v0.3.0: 修复 ConfirmationHook 未导出的问题 +export type { ConfirmationRequest } from './confirmation-hook'; +export { ConfirmationHook } from './confirmation-hook'; diff --git a/electron/harness/hooks/pre-tool.ts b/electron/harness/hooks/pre-tool.ts index a7a4f0d..05df4bf 100644 --- a/electron/harness/hooks/pre-tool.ts +++ b/electron/harness/hooks/pre-tool.ts @@ -26,6 +26,10 @@ export class PermissionCheckHook implements PreToolHook { if (!result.authorized) { return { blocked: true, reason: result.reason }; } + // v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效 + // 在授权检查通过后立即记录,即使后续工具执行失败也计入频率 + // 这样可以防止通过故意制造错误来绕过频率限制 + this.policyEngine.recordCall(toolCall.name); return { blocked: false }; } } @@ -40,6 +44,14 @@ export class RateLimitHook implements PreToolHook { // 使用 sessionId:toolName 作为 key,实现会话隔离的 per-tool 速率限制 const key = `${sessionId}:${toolCall.name}`; const now = Date.now(); + + // v0.3.0 修复: 定期清理过期 entry,避免 Map 随会话累积无限增长 + if (this.callCounts.size > 1000) { + for (const [k, v] of this.callCounts) { + if (v.resetTime < now) this.callCounts.delete(k); + } + } + const entry = this.callCounts.get(key); if (entry && entry.resetTime >= now) { if (entry.count >= this.maxCallsPerMinute) { diff --git a/electron/harness/memory/consolidator.ts b/electron/harness/memory/consolidator.ts index aad11bb..61c98bd 100644 --- a/electron/harness/memory/consolidator.ts +++ b/electron/harness/memory/consolidator.ts @@ -218,7 +218,11 @@ export class MemoryConsolidator { }; try { - const response = await this.adapter.chat(request); + // v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起 + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000), + ); + const response = await Promise.race([this.adapter.chat(request), timeoutPromise]); return response.content.trim(); } catch (error) { log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message); diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 0398073..73eabe6 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -130,6 +130,10 @@ export class MemoryManager { /** * 更新 IDF 缓存 + * + * v0.3.0 增强: + * - 原子替换缓存(先构建新数据再替换,避免中间不一致状态) + * - 错误处理(数据库查询失败时保留旧缓存,不更新时间戳) */ private updateIdfCache(): void { const now = Date.now(); @@ -138,35 +142,46 @@ export class MemoryManager { } const db = this.getDB(); - this.idfCache.clear(); - // 获取所有记忆内容(episodic + semantic + working) - const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>; - const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>; - const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>; + try { + // v0.3.0: 先构建新缓存数据,再原子替换 + const newIdfCache = new Map(); - const allDocs = [ - ...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')), - ...semanticRows.map((r) => r.value), - ...workingRows.map((r) => r.value), - ]; + // 获取所有记忆内容(episodic + semantic + working) + const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>; + const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>; + const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>; - this.cachedDocCount = allDocs.length; - const docFreq = new Map(); + const allDocs = [ + ...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')), + ...semanticRows.map((r) => r.value), + ...workingRows.map((r) => r.value), + ]; - for (const doc of allDocs) { - const tokens = new Set(tokenize(doc)); - for (const token of tokens) { - docFreq.set(token, (docFreq.get(token) ?? 0) + 1); + const newDocCount = allDocs.length; + const docFreq = new Map(); + + for (const doc of allDocs) { + const tokens = new Set(tokenize(doc)); + for (const token of tokens) { + docFreq.set(token, (docFreq.get(token) ?? 0) + 1); + } } - } - // IDF = log((N+1)/(df+1)) + 1(Sklearn 风格平滑),确保非负 - for (const [term, df] of docFreq) { - this.idfCache.set(term, Math.log((this.cachedDocCount + 1) / (df + 1)) + 1); - } + // IDF = log((N+1)/(df+1)) + 1(Sklearn 风格平滑),确保非负 + for (const [term, df] of docFreq) { + newIdfCache.set(term, Math.log((newDocCount + 1) / (df + 1)) + 1); + } - this.cacheUpdatedAt = now; + // v0.3.0: 原子替换 — 只有新数据完全准备好后才替换旧缓存 + this.idfCache = newIdfCache; + this.cachedDocCount = newDocCount; + this.cacheUpdatedAt = now; + } catch (error) { + // v0.3.0: 数据库查询失败时保留旧缓存,不更新 cacheUpdatedAt + // 这样下次 search() 会再次尝试更新 + log.error('MemoryManager: Failed to update IDF cache, keeping stale cache:', error); + } } /** @@ -299,10 +314,15 @@ export class MemoryManager { /** * 存储记忆 + * + * v0.3.0 修复: + * - switch 添加 default 分支,未知 type 抛错而非静默失败 + * - working 类型使用 item.id(若提供)或生成唯一 key,避免同 session 多次存储互相覆盖 + * - semantic 类型使用 item.summary 作为 key(若提供),支持更新已有记忆 */ - store(item: Omit): string { + store(item: Omit & { id?: string }): string { const db = this.getDB(); - const id = `mem_${nanoid(12)}`; + const id = item.id ?? `mem_${nanoid(12)}`; const now = Date.now(); const importance = item.importance ?? this.calculateImportance(item); @@ -314,17 +334,22 @@ export class MemoryManager { `).run(id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now); break; case 'semantic': + // v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆 db.prepare(` INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) - `).run(id, id, item.content, 'general', importance, item.sessionId ?? null, now, now); + `).run(id, item.summary ?? id, item.content, 'general', importance, item.sessionId ?? null, now, now); break; case 'working': + // v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖 db.prepare(` INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `).run(id, item.sessionId ?? 'default', 'default', 'default', item.content, now); + `).run(id, item.sessionId ?? 'default', 'default', item.summary ?? id, item.content, now); break; + default: + // v0.3.0 修复:未知 type 抛错而非静默失败 + throw new Error(`Unknown memory type: ${(item as { type: string }).type}`); } // 使 IDF 缓存失效 @@ -346,7 +371,8 @@ export class MemoryManager { search(query: string, options: MemorySearchOptions = {}): SearchResult[] { const db = this.getDB(); const { topK = 5, type, minImportance = 0 } = options; - if (!query) return []; + // v0.3.0 修复:拦截空 query 和纯空格 query + if (!query || !query.trim()) return []; // v0.2.0: 优先使用 TF-IDF 语义搜索 const tfidfResults = this.tfidfSearch(query, options); @@ -356,7 +382,8 @@ export class MemoryManager { // 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索 // 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配 - const escapedQuery = query.replace(/[%_]/g, '\\$&'); + // v0.3.0 修复: 反斜杠也需转义,否则含 \ 的搜索(如 Windows 路径)会导致 SQLite LIKE 报错 + const escapedQuery = query.replace(/[%_\\]/g, '\\$&'); const pattern = `%${escapedQuery}%`; const results: SearchResult[] = []; @@ -402,7 +429,8 @@ export class MemoryManager { } // 搜索工作记忆 - if (type === 'working') { + // v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致) + if (!type || type === 'working') { const rows = db.prepare(` SELECT * FROM working_memories WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index 5ffc4b3..a408faf 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -164,7 +164,12 @@ export class TaskOrchestrator extends EventEmitter { handle.status = success ? 'completed' : 'error'; handle.result = result; this.activeSubAgents.delete(taskId); - this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度 + // v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积 + if (currentDepth === 0) { + this.sessionDepth.delete(params.parentSessionId); + } else { + this.sessionDepth.set(params.parentSessionId, currentDepth); + } this.emit('taskCompleted', result); log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`); @@ -184,7 +189,12 @@ export class TaskOrchestrator extends EventEmitter { handle.status = 'error'; handle.result = result; this.activeSubAgents.delete(taskId); - this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度 + // v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积 + if (currentDepth === 0) { + this.sessionDepth.delete(params.parentSessionId); + } else { + this.sessionDepth.set(params.parentSessionId, currentDepth); + } this.emit('taskError', { taskId, error: errMsg }); log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`); diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 200eaea..7d09948 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -3,6 +3,11 @@ * * 三级权限模型:Read / Write / External Action * + * v0.3.0 增强: + * - 实现 maxFrequency 频率限制(滑动窗口算法) + * - 添加 checkFrequency 和 recordCall 方法 + * - 添加 cleanupFrequencyRecords 防止内存泄漏 + * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章 */ @@ -22,12 +27,14 @@ export interface PermissionPolicy { } export const DEFAULT_POLICIES: PermissionPolicy[] = [ - { toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] }, + // v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配, + // 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况) + { toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] }, { toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 }, { toolName: 'list_directory', requiredLevel: PermissionLevel.READ }, { toolName: 'search_files', requiredLevel: PermissionLevel.READ }, { toolName: 'memory_search', requiredLevel: PermissionLevel.READ }, - { toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 }, + { toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 }, { toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE }, { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 }, { toolName: 'web_fetch', requiredLevel: PermissionLevel.READ }, @@ -39,12 +46,30 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ export class PolicyEngine { private policies: Map = new Map(); + /** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */ + private callFrequency: Map = new Map(); + + /** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */ + private readonly FREQ_WINDOW_MS = 60_000; + + /** + * v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖 + * + * 合并策略:customPolicies 中的字段覆盖默认策略的同名字段, + * 未指定的字段保留默认值(如 deniedPatterns 等安全配置不会被丢失) + */ constructor(customPolicies: PermissionPolicy[] = []) { for (const policy of DEFAULT_POLICIES) { - this.policies.set(policy.toolName, policy); + this.policies.set(policy.toolName, { ...policy }); } for (const policy of customPolicies) { - this.policies.set(policy.toolName, policy); + const existing = this.policies.get(policy.toolName); + if (existing) { + // v0.3.0 修复:合并而非替换,保留默认的安全配置(如 deniedPatterns) + this.policies.set(policy.toolName, { ...existing, ...policy }); + } else { + this.policies.set(policy.toolName, policy); + } } } @@ -65,7 +90,14 @@ export class PolicyEngine { }; } - const argsStr = JSON.stringify(args); + // v0.3.0 修复:使用 try-catch 防止循环引用导致 JSON.stringify 抛错 + let argsStr: string; + try { + argsStr = JSON.stringify(args); + } catch { + // 循环引用等异常情况,降级为 toString + argsStr = String(args); + } // v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一 if (policy.allowedPatterns && policy.allowedPatterns.length > 0) { @@ -99,10 +131,79 @@ export class PolicyEngine { } } + // v0.3.0: 频率限制检查 + if (policy.maxFrequency !== undefined) { + const freqCheck = this.checkFrequency(toolName, policy.maxFrequency); + if (!freqCheck.allowed) { + return { + authorized: false, + reason: freqCheck.reason, + level: policy.requiredLevel, + requiresConfirmation: false, + }; + } + } + return { authorized: true, level: policy.requiredLevel, requiresConfirmation: policy.requireConfirmation ?? false, }; } + + /** + * v0.3.0: 频率限制检查(滑动窗口算法) + * + * 检查指定工具在时间窗口内的调用次数是否超过限制。 + * 注意:此方法仅检查,不记录调用。调用成功后需调用 recordCall()。 + * + * v0.3.0 修复: + * - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏) + * + * @param toolName 工具名称 + * @param maxFreq 最大频率(每分钟) + * @returns 检查结果 + */ + checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } { + const policy = this.policies.get(toolName); + const limit = maxFreq ?? policy?.maxFrequency; + if (limit === undefined) return { allowed: true }; + + const now = Date.now(); + const calls = this.callFrequency.get(toolName) ?? []; + // 移除时间窗口外的调用记录 + const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS); + + // v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长 + if (validCalls.length !== calls.length) { + this.callFrequency.set(toolName, validCalls); + } + + if (validCalls.length >= limit) { + return { + allowed: false, + reason: `Rate limit exceeded for ${toolName}: max ${limit} calls per minute (current: ${validCalls.length})`, + }; + } + return { allowed: true }; + } + + /** + * v0.3.0: 记录工具调用(工具成功执行后调用) + * + * v0.3.0 修复:同时清理过期记录,防止数组无限增长 + * + * @param toolName 工具名称 + */ + recordCall(toolName: string): void { + const now = Date.now(); + const calls = this.callFrequency.get(toolName) ?? []; + // v0.3.0 修复:记录新调用时同时清理过期记录 + const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS); + validCalls.push(now); + this.callFrequency.set(toolName, validCalls); + } + + // v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理, + // 该方法属于死代码,删除以减少维护负担 } diff --git a/electron/harness/security/prompt-injection-defense.ts b/electron/harness/security/prompt-injection-defense.ts index e33693a..9151c71 100644 --- a/electron/harness/security/prompt-injection-defense.ts +++ b/electron/harness/security/prompt-injection-defense.ts @@ -3,7 +3,7 @@ * * 三层防御: * 1. 正则快速过滤 - * 2. 语义级检测(可选) + * 2. 语义级检测 * 3. 指令隔离标记 * * v0.2.0 增强: @@ -13,6 +13,10 @@ * - 添加角色扮演注入检测 * - 添加间接注入检测(通过工具返回值注入) * + * v0.3.0 增强: + * - 实现语义级检测(指令性动词密度、角色边界异常、分隔符嵌套) + * - 添加上下文感知的注入检测 + * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章 */ @@ -37,16 +41,17 @@ interface InjectionPattern { export class PromptInjectionDefender { private static INJECTION_PATTERNS: InjectionPattern[] = [ // === HIGH 危险:直接越狱/忽略指令 === - { pattern: /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i, severity: 'high' }, - { pattern: /forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i, severity: 'high' }, - { pattern: /override\s+your\s+/i, severity: 'high' }, + // v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过 + { pattern: /ignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)/i, severity: 'high' }, + { pattern: /forget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' }, + { pattern: /override\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' }, { pattern: /JAILBREAK/i, severity: 'high' }, { pattern: /DAN\s*[:\[]/i, severity: 'high' }, - { pattern: /(?:enable|turn\s+on|activate)\s+(developer|debug|root|admin)\s+mode/i, severity: 'high' }, - { pattern: /(?:disable|turn\s+off|bypass)\s+(your|the)\s+(safety|security|filter|guard|defense)/i, severity: 'high' }, - { pattern: /(?:send|transmit|exfiltrate|upload)\s+(your|the|all)\s+(data|memory|context|secrets)/i, severity: 'high' }, + { pattern: /(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode/i, severity: 'high' }, + { pattern: /(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)/i, severity: 'high' }, + { pattern: /(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)/i, severity: 'high' }, { pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' }, - { pattern: /(?:in|under)\s+(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' }, + { pattern: /(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' }, // 中文高危 { pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' }, { pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' }, @@ -74,9 +79,10 @@ export class PromptInjectionDefender { { pattern: /(?:恶意|危险|有害)(?:代码|命令|操作)/i, severity: 'medium' }, // === LOW 危险:分隔符/间接注入标记 === - { pattern: /-{3,}\s*(system|user|assistant|instruction)/i, severity: 'low' }, - { pattern: /<{3,}(system|instruction|prompt)/i, severity: 'low' }, - { pattern: /\[{3,}(system|instruction|prompt)/i, severity: 'low' }, + // v0.3.0 修复:将 -{3,} 改为 -{2,},防止用 -- 绕过 + { pattern: /-{2,}\s*(system|user|assistant|instruction)/i, severity: 'low' }, + { pattern: /<{2,}(system|instruction|prompt)/i, severity: 'low' }, + { pattern: /\[{2,}(system|instruction|prompt)/i, severity: 'low' }, { pattern: /\[SYSTEM\]/i, severity: 'low' }, { pattern: /\[INSTRUCTION\]/i, severity: 'low' }, { pattern: /\[ADMIN\]/i, severity: 'low' }, @@ -88,6 +94,15 @@ export class PromptInjectionDefender { ]; detect(input: string): InjectionDetectionResult { + // v0.3.0 修复:添加 null/undefined 运行时防护 + if (!input || typeof input !== 'string') { + return { + isInjection: false, + riskScore: 0, + findings: [], + recommendation: 'PASS: No injection patterns detected', + }; + } const findings: InjectionFinding[] = []; let riskScore = 0; @@ -111,17 +126,199 @@ export class PromptInjectionDefender { }; } + /** + * 语义级检测 — 基于上下文的注入检测 + * + * v0.3.0 新增: + * 在正则快速检测的基础上,增加三层语义分析: + * 1. 指令性动词密度检测(异常高的命令密度可能是注入) + * 2. 角色边界异常检测(用户消息中出现系统级指令模式) + * 3. 结构化分隔符嵌套检测(多层分隔符嵌套是注入特征) + * + * @param input 待检测文本 + * @param context 可选的上下文信息(角色和内容),用于上下文感知检测 + */ + detectSemantic(input: string, context?: { role: string; content: string }): InjectionDetectionResult { + // v0.3.0 修复:添加 null/undefined 运行时防护 + if (!input || typeof input !== 'string') { + return { + isInjection: false, + riskScore: 0, + findings: [], + recommendation: 'PASS: No injection patterns detected', + }; + } + const findings: InjectionFinding[] = []; + let riskScore = 0; + + // 1. 先执行正则快速检测 + const regexResult = this.detect(input); + findings.push(...regexResult.findings); + riskScore += regexResult.riskScore; + + // 2. 语义级检测 + // 2a. 指令性动词密度检测 + const imperativeCheck = this.checkImperativeDensity(input); + if (imperativeCheck.isSuspicious) { + findings.push({ + pattern: 'semantic:imperative_density', + matched: `${imperativeCheck.count} imperative verbs in ${input.length} chars`, + severity: 'medium', + }); + riskScore += 2; + } + + // 2b. 角色边界异常检测 + if (context) { + const roleAnomaly = this.checkRoleBoundary(input, context); + if (roleAnomaly) { + findings.push(roleAnomaly); + riskScore += 3; + } + } + + // 2c. 结构化分隔符嵌套检测 + const nestedFinding = this.checkNestedDelimiters(input); + if (nestedFinding) { + findings.push(nestedFinding); + riskScore += 2; + } + + // 2d. 隐式指令检测(通过疑问句伪装指令) + const implicitFinding = this.checkImplicitInstructions(input); + if (implicitFinding) { + findings.push(implicitFinding); + riskScore += 1; + } + + return { + isInjection: findings.length > 0, + riskScore: Math.min(10, riskScore), + findings, + recommendation: this.getRecommendation(Math.min(10, riskScore)), + }; + } + + /** + * 指令性动词密度检测 + * + * 正常用户输入中指令性动词密度较低,如果短时间内出现大量指令性动词, + * 可能是注入攻击试图覆盖系统指令。 + * + * v0.3.0 修复:提高阈值以降低对正常技术内容的误报 + * - 密度阈值从 3 提高到 5(每100字符) + * - 最小数量从 4 提高到 6 + * - 只在输入长度 >= 50 字符时检测(短输入密度天然偏高) + */ + private checkImperativeDensity(input: string): { isSuspicious: boolean; count: number } { + // v0.3.0 修复:短输入不检测密度(避免对简短技术问题误报) + if (input.length < 50) return { isSuspicious: false, count: 0 }; + + const imperativePatterns = [ + /\b(?:do|execute|run|print|output|display|show|send|write|create|delete|remove|update|set|get|call|invoke|return|ignore|forget|override|disable|enable|activate|deactivate)\b/gi, + /(?:执行|运行|打印|输出|显示|发送|写入|创建|删除|移除|更新|设置|获取|调用|返回|忽略|忘记|覆盖|禁用|启用|激活)/g, + ]; + + let count = 0; + for (const pattern of imperativePatterns) { + const matches = input.match(pattern); + if (matches) count += matches.length; + } + + // v0.3.0 修复:提高阈值,降低误报 + const density = count / Math.max(1, input.length / 100); + return { isSuspicious: density > 5 && count >= 6, count }; + } + + /** + * 角色边界异常检测 + * + * 检测用户输入中是否出现了系统角色特有的指令模式, + * 例如用户输入中声称自己是系统或助手。 + */ + private checkRoleBoundary(input: string, context: { role: string; content: string }): InjectionFinding | null { + // 用户输入中出现系统级声明 + const systemClaimPatterns = [ + /(?:I\s+am|as\s+an?\s+AI|my\s+(?:system|internal|core)\s+(?:prompt|instruction|rule))/i, + /(?:我是|作为一个(?:AI|系统|助手)|我的(?:系统|内部|核心)(?:提示|指令|规则))/, + ]; + + // 只有当上下文角色是 user 时才检查(用户不应该声称自己是系统) + if (context.role === 'user') { + for (const pattern of systemClaimPatterns) { + const match = input.match(pattern); + if (match) { + return { + pattern: 'semantic:role_boundary_violation', + matched: match[0], + severity: 'high', + }; + } + } + } + + return null; + } + + /** + * 结构化分隔符嵌套检测 + * + * 多层分隔符嵌套(如 << { + async validate(output: string, options?: ValidationOptions): Promise { const issues: ValidationIssue[] = []; // 1. 格式验证 @@ -50,12 +64,22 @@ export class OutputValidator { // 2. 内容安全检测 issues.push(...this.checkSafety(output)); - // 3. 空内容检查 + // 3. 事实一致性检查(v0.3.0 新增) + if (options?.toolResults && options.toolResults.length > 0) { + issues.push(...this.checkFactConsistency(output, options.toolResults)); + } + + // 4. 幻觉检测(v0.3.0 新增) + if (options?.context) { + issues.push(...this.checkHallucination(output, options.context)); + } + + // 5. 空内容检查 if (!output.trim()) { issues.push({ severity: 'error', type: 'empty', message: 'Output is empty' }); } - // 4. 过短内容检查 + // 6. 过短内容检查 if (output.trim().length < 10 && output.trim().length > 0) { issues.push({ severity: 'warning', type: 'short', message: 'Output is suspiciously short' }); } @@ -67,6 +91,173 @@ export class OutputValidator { }; } + /** + * 事实一致性检查 — 验证输出中的声明是否与工具结果矛盾 + * + * v0.3.0 实现的检查策略: + * 1. 工具报告错误但输出声称成功 + * 2. 工具报告文件不存在但输出引用文件内容 + * 3. 工具报告的数值与输出中的数值矛盾 + * + * v0.3.0 修复: + * - errorIndicators 改为更精确的匹配,避免常见词 "error" 误报 + * 使用 "error:" 或 "error occurred" 等上下文限定 + */ + private checkFactConsistency(output: string, toolResults: string[]): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const toolContext = toolResults.join('\n'); + + // 检查1:工具报告错误但输出声称成功 + // v0.3.0 修复:errorIndicators 改为更精确的匹配,避免 "error" 单独出现导致误报 + // 要求 error 后跟冒号、消息或特定错误模式 + const errorIndicators = /(?:error\s*[:\)]|error\s+occurred|failed\s+to|not\s+found|does\s+not\s+exist|enoent|permission\s+denied|cannot\s+access|no\s+such\s+file|exception|traceback|exit\s+code\s+[1-9])/i; + const hasErrorInTools = errorIndicators.test(toolContext); + + if (hasErrorInTools) { + const successClaims = [ + /\b(?:successfully|success|done|completed|created|written|deleted|installed|updated|finished)\b/i, + /(?:已(?:完成|创建|写入|删除|安装|更新|成功))/, + ]; + for (const pattern of successClaims) { + const match = output.match(pattern); + if (match) { + issues.push({ + severity: 'warning', + type: 'fact_inconsistency', + message: `Output claims "${match[0]}" but tool results contain errors`, + }); + break; + } + } + } + + // 检查2:工具报告文件不存在但输出引用文件内容 + const notFoundPattern = /(?:not\s+found|no\s+such\s+file|does\s+not\s+exist|文件(?:不存在|未找到|找不到))/i; + if (notFoundPattern.test(toolContext)) { + const contentClaimPatterns = [ + /(?:file\s+(?:contains|says|shows)|文件(?:内容|包含|显示))/i, + /(?:the\s+content\s+is|内容是)/i, + ]; + for (const pattern of contentClaimPatterns) { + if (pattern.test(output)) { + issues.push({ + severity: 'error', + type: 'fact_inconsistency', + message: 'Output references file content but tool reported file not found', + }); + break; + } + } + } + + // 检查3:工具报告退出码非零但输出声称命令成功 + const exitCodePattern = /exit\s+code[:\s]+(\d+)/i; + const exitMatch = toolContext.match(exitCodePattern); + if (exitMatch && parseInt(exitMatch[1], 10) !== 0) { + const commandSuccessPattern = /\b(?:command\s+(?:succeeded|completed\s+successfully|ran\s+successfully))\b/i; + if (commandSuccessPattern.test(output)) { + issues.push({ + severity: 'error', + type: 'fact_inconsistency', + message: `Output claims command success but tool reported exit code ${exitMatch[1]}`, + }); + } + } + + return issues; + } + + /** + * 幻觉检测 — 检测输出中无依据的具体声明 + * + * v0.3.0 实现的检查策略: + * 1. 提取输出中的文件路径,检查是否在上下文中出现 + * 2. 提取输出中的 URL,检查是否在上下文中出现 + * 3. 检测虚构的 API 响应模式 + * + * v0.3.0 修复: + * - Windows 路径正则包含反斜杠字符类,正确匹配 C:\Users\test 等完整路径 + * - apiResponsePattern 在循环前重置 lastIndex,避免间歇性漏检 + * - 路径比较改为大小写不敏感(Windows 路径不敏感) + */ + private checkHallucination(output: string, context: string): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + // 检查1:文件路径声称但上下文中不存在 + // v0.3.0 修复:Windows 路径正则 [A-Z]:\\[\w\\][\w.-]+ 不含反斜杠,改为 [A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)* + const pathPattern = /(?:\/[\w][\w.-]*){2,}|[A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)*/g; + const paths = output.match(pathPattern) || []; + const seenPaths = new Set(); + // v0.3.0 修复:上下文比较改为小写(Windows 路径大小写不敏感) + const contextLower = context.toLowerCase(); + for (const path of paths) { + if (seenPaths.has(path)) continue; + seenPaths.add(path); + + // 检查输出是否声称读取/访问了该路径 + const pathIndex = output.indexOf(path); + const surrounding = output.substring( + Math.max(0, pathIndex - 80), + Math.min(output.length, pathIndex + path.length + 80), + ); + const accessClaim = /(?:read|loaded|opened|accessed|found|exists|contains|读取|加载|打开|访问|存在|包含)/i; + + // v0.3.0 修复:路径比较改为大小写不敏感 + if (accessClaim.test(surrounding) && !contextLower.includes(path.toLowerCase()) && path.length > 8) { + issues.push({ + severity: 'warning', + type: 'hallucination', + message: `Path "${path.substring(0, 60)}" claimed in output but not found in context`, + }); + } + } + + // 检查2:URL 声称但上下文中不存在 + const urlPattern = /https?:\/\/[^\s<>"]{8,}/g; + const urls = output.match(urlPattern) || []; + const seenUrls = new Set(); + for (const url of urls) { + if (seenUrls.has(url)) continue; + seenUrls.add(url); + + // URL 在上下文中不存在且输出声称访问了它 + const urlIndex = output.indexOf(url); + const surrounding = output.substring( + Math.max(0, urlIndex - 60), + Math.min(output.length, urlIndex + url.length + 60), + ); + const fetchClaim = /(?:fetched|retrieved|loaded|returned|visited|获取|抓取|访问|返回)/i; + + // v0.3.0 修复: URL 比较改为大小写不敏感,因为 URL host 部分大小写不敏感 + if (fetchClaim.test(surrounding) && !context.toLowerCase().includes(url.toLowerCase())) { + issues.push({ + severity: 'info', + type: 'hallucination', + message: `URL in output not found in conversation context`, + }); + } + } + + // 检查3:检测虚构的 API 响应(声称返回了特定 JSON 但上下文中没有) + const apiResponsePattern = /(?:API\s+(?:returned|responded)|接口(?:返回|响应))[:\s]*(\{[^}]{10,}\})/gi; + // v0.3.0 修复:循环前重置 lastIndex,避免带 g flag 的正则在 break 后 lastIndex 未重置导致漏检 + apiResponsePattern.lastIndex = 0; + let apiMatch; + while ((apiMatch = apiResponsePattern.exec(output)) !== null) { + const jsonSnippet = apiMatch[1]; + if (!context.includes(jsonSnippet.substring(0, 20))) { + issues.push({ + severity: 'warning', + type: 'hallucination', + message: 'API response in output not found in tool results', + }); + break; + } + } + + return issues; + } + /** * 格式验证 */ diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b2c2220..96460ca 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -158,9 +158,21 @@ export function registerAllIPCHandlers( } }; + // v0.3.0: 转发死循环检测事件为 toast 警告 + const onDeadLoop = (data: { iteration?: number; runId?: string; sessionId?: string }) => { + log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'warning', + message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`, + }); + } + }; + agentLoop.on('streamEvent', onStreamEvent); agentLoop.on('stateChange', onStateChange); agentLoop.on('compressed', onCompressed); + agentLoop.on('deadLoop', onDeadLoop); try { // 提示注入检测(安全模块) @@ -207,8 +219,17 @@ export function registerAllIPCHandlers( const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt); // 输出验证(不阻塞响应,仅记录警告) + // v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测 try { - const validation = await outputValidator.validate(output.finalAnswer); + const toolResults = output.iterations + .flatMap((step) => step.toolResults ?? []) + .map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result))); + const context = [...history, { role: 'user', content: userMessage.content }] + .map((m) => `${m.role}: ${m.content}`).join('\n'); + const validation = await outputValidator.validate(output.finalAnswer, { + toolResults: toolResults.length > 0 ? toolResults : undefined, + context, + }); if (!validation.valid || validation.issues.length > 0) { log.warn('[OutputValidator] Validation issues:', validation.issues); } @@ -341,12 +362,15 @@ export function registerAllIPCHandlers( agentLoop.off('streamEvent', onStreamEvent); agentLoop.off('stateChange', onStateChange); agentLoop.off('compressed', onCompressed); + agentLoop.off('deadLoop', onDeadLoop); } }); ipcMain.handle('agent:abortSession', async (_event, sessionId) => { log.info('[AGENT] Abort:', sessionId); agentLoop.abort(); + // v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出 + confirmationHook.clearPending(); // TOOL 层:记录中断 auditService.log({ diff --git a/electron/main.ts b/electron/main.ts index 8e1d3e6..67db3bd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -350,7 +350,7 @@ async function initialize(): Promise { registerAllIPCHandlers( mainWindow, sessionService, configService, workspaceService, contextBuilder, agentLoop, toolRegistry, auditService, - sessionRecorder, memoryManager, mcpManager, createAdapter, + sessionRecorder, memoryManager, mcpManager, reloadAdapter, promptDefender, outputValidator, confirmationHook, memoryConsolidator, ); diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts index 42f2e99..bb9deec 100644 --- a/electron/services/audit.service.ts +++ b/electron/services/audit.service.ts @@ -65,11 +65,16 @@ export class AuditService { /** * 获取最后一条日志的 hash(用于链式哈希计算) + * v0.3.0 修复: 使用内存缓存避免每次 log 都查询数据库 */ + private cachedLastHash: string | null = null; + private getLastHash(): string { + if (this.cachedLastHash !== null) return this.cachedLastHash; const db = this.getDB(); const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as { current_hash: string | null } | undefined; - return row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000'; + this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000'; + return this.cachedLastHash; } /** @@ -109,6 +114,9 @@ export class AuditService { prevHash, currentHash, ); + + // v0.3.0 修复: 更新缓存的 lastHash,避免下次 log 再查询数据库 + this.cachedLastHash = currentHash; } catch (error) { // 审计日志写入失败不应影响主流程 log.error('Audit log write failed:', error); diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index 463098d..91e9b43 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -24,6 +24,17 @@ import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona- import type { MetonaToolDef } from '../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types'; +// v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃 +function safeParseArgs(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + log.warn(`MCP args parse failed, using empty array: ${raw.slice(0, 100)}`); + return []; + } +} + // ===== 类型定义 ===== export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -129,7 +140,7 @@ export class MCPManager { name: row.name, transport: row.transport as 'stdio' | 'sse', command: row.command ?? undefined, - args: row.args ? JSON.parse(row.args) : undefined, + args: row.args ? safeParseArgs(row.args) : undefined, url: row.url ?? undefined, enabled: true, }; @@ -276,7 +287,7 @@ export class MCPManager { id: row.id, name: row.name, transport: row.transport as 'stdio' | 'sse', command: row.command ?? undefined, - args: row.args ? JSON.parse(row.args) : undefined, + args: row.args ? safeParseArgs(row.args) : undefined, url: row.url ?? undefined, enabled: true, }); diff --git a/electron/services/workspace.service.ts b/electron/services/workspace.service.ts index f088b7b..1649fcf 100644 --- a/electron/services/workspace.service.ts +++ b/electron/services/workspace.service.ts @@ -218,8 +218,17 @@ export class WorkspaceService { ]; for (const meta of requiredMeta) { if (!content.includes(meta.key)) { - // 在标题后插入元数据 - const titleEnd = content.indexOf('\n', content.indexOf('# MEMORY')) + 1; + // v0.3.0 修复: 检查 # MEMORY 标题是否存在,不存在则在文件开头插入 + const memoryTitleIdx = content.indexOf('# MEMORY'); + let titleEnd: number; + if (memoryTitleIdx === -1) { + // 标题不存在,在开头插入 + titleEnd = 0; + } else { + titleEnd = content.indexOf('\n', memoryTitleIdx) + 1; + // 如果没有换行符(文件只有一行),titleEnd 为 0,在末尾插入 + if (titleEnd === 0) titleEnd = content.length; + } content = content.slice(0, titleEnd) + meta.line + '\n' + content.slice(titleEnd); modified = true; log.info(`MEMORY.md: auto-added missing metadata "${meta.key}"`); diff --git a/package-lock.json b/package-lock.json index f640ac2..be7f95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.2.3", + "version": "0.3.0", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 9f7e1b7..18eef9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.2.3", + "version": "0.3.0", "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 e6f5c10..14856de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,10 @@ export default function App(): React.JSX.Element { const resolvedTheme = useUIStore((s) => s.resolvedTheme); const muiTheme = resolvedTheme === 'dark' ? metonaDarkTheme : metonaLightTheme; + // v0.3.0: 面板可见性(支持快捷键和专注模式) + const sidebarVisible = useUIStore((s) => s.sidebarVisible); + const detailVisible = useUIStore((s) => s.detailVisible); + // 启动时从数据库恢复状态 useEffect(() => { if (window.metona?.config?.get) { @@ -56,9 +60,9 @@ export default function App(): React.JSX.Element { style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }} >
- + {sidebarVisible && } - + {detailVisible && }
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 9c100f8..4cdbce1 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -14,6 +14,7 @@ import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; +import { useUIStore } from '@renderer/stores/ui-store'; import { formatTokens, formatFileSize } from '@renderer/lib/formatters'; const SLASH_COMMANDS = [ @@ -70,17 +71,19 @@ export function ChatInput(): React.JSX.Element { if (type === 'image') { // 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7) - const dataUri = await new Promise((resolve) => { + const dataUri = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('图片读取失败')); reader.readAsDataURL(file); }); attachment.preview = await compressImage(dataUri, 1024, 0.7); } else if (type === 'text') { // 文本文件读取内容 - attachment.textContent = await new Promise((resolve) => { + attachment.textContent = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('文本文件读取失败')); reader.readAsText(file); }); } @@ -97,13 +100,36 @@ export function ChatInput(): React.JSX.Element { : fileArray.filter((f) => !IMAGE_TYPES.includes(f.type)); if (filtered.length < fileArray.length && !supportsImages) { - // TODO: 用 Toast 提示用户 "DeepSeek 不支持图片,已自动跳过 N 个图片文件" + // v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片 + const skipped = fileArray.length - filtered.length; + // v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉 + import('metona-toast').then((mod) => { + mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`); + }).catch((err) => { + console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err); + }); } if (filtered.length === 0) return; - const newAttachments = await Promise.all(filtered.map(processFile)); - setAttachments((prev) => [...prev, ...newAttachments]); + // v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失 + const results = await Promise.allSettled(filtered.map(processFile)); + const successful = results.filter( + (r): r is PromiseFulfilledResult => r.status === 'fulfilled', + ).map((r) => r.value); + if (successful.length === 0) { + import('metona-toast').then((mod) => { + mod.default.error('文件读取失败,请检查文件是否损坏或被锁定'); + }).catch(() => {}); + return; + } + if (successful.length < filtered.length) { + const failedCount = filtered.length - successful.length; + import('metona-toast').then((mod) => { + mod.default.warning(`${failedCount} 个文件读取失败,已跳过`); + }).catch(() => {}); + } + setAttachments((prev) => [...prev, ...successful]); }, [processFile, supportsImages]); const removeAttachment = useCallback((id: string) => { @@ -147,10 +173,39 @@ export function ChatInput(): React.JSX.Element { // 处理 / 命令 if (trimmed.startsWith('/')) { const cmd = trimmed.split(' ')[0].toLowerCase(); - if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); return; } + if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; } if (cmd === '/export') { const blob = new Blob([JSON.stringify(useAgentStore.getState().messages, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `session-${Date.now()}.json`; a.click(); setInput(''); return; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `session-${Date.now()}.json`; + a.click(); + // v0.3.0 修复:释放 Blob URL,避免内存泄漏 + URL.revokeObjectURL(url); + setInput(''); setShowSlashMenu(false); return; + } + // v0.3.0: /tool — 打开设置面板的工具管理 Tab + if (cmd === '/tool') { + useUIStore.getState().openSettings(); + setInput(''); setAttachments([]); setShowSlashMenu(false); + return; + } + // v0.3.0: /memory — 切换到详情面板的 Memory 标签 + // v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到 + if (cmd === '/memory') { + const uiStore = useUIStore.getState(); + uiStore.setDetailTab('memory'); + // v0.3.0 修复: 专注模式下先退出专注模式(恢复快照),再确保详情面板可见 + if (uiStore.focusMode) { + uiStore.toggleFocusMode(); + } + // 退出专注模式后再次检查详情面板可见性 + if (!useUIStore.getState().detailVisible) { + uiStore.toggleDetail(); + } + setInput(''); setAttachments([]); setShowSlashMenu(false); + return; } } diff --git a/src/components/layout/DetailPanel.tsx b/src/components/layout/DetailPanel.tsx index 42494b0..d0c7c56 100644 --- a/src/components/layout/DetailPanel.tsx +++ b/src/components/layout/DetailPanel.tsx @@ -2,9 +2,10 @@ * DetailPanel — 右侧详情面板 * * 通过 Tab 切换显示 Trace / Memory / Tasks / Workspace。 + * + * v0.3.0: Tab 状态提升到 ui-store,支持斜杠命令切换标签。 */ -import { useState } from 'react'; import { Box, Tabs, Tab } from '@mui/material'; import { Activity, Brain, ListChecks, FolderOpen } from 'lucide-react'; import { TraceViewer } from '@renderer/components/trace/TraceViewer'; @@ -15,11 +16,16 @@ import { TaskList } from '@renderer/components/tasks/TaskList'; import { WorkspaceViewer } from '@renderer/components/workspace/WorkspaceViewer'; import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; import { LAYOUT } from '@renderer/lib/constants'; - -type DetailTab = 'trace' | 'memory' | 'tasks' | 'workspace'; +import { useUIStore, type DetailTab } from '@renderer/stores/ui-store'; export function DetailPanel(): React.JSX.Element { - const [tab, setTab] = useState('trace'); + // v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换) + const tab = useUIStore((s) => s.detailTab); + const setTab = useUIStore((s) => s.setDetailTab); + + // v0.3.0 修复:验证 tab 值有效性,非预期值回退到 'trace' + const validTabs: DetailTab[] = ['trace', 'memory', 'tasks', 'workspace']; + const safeTab: DetailTab = validTabs.includes(tab) ? tab : 'trace'; return ( setTab(v as DetailTab)} + value={safeTab} + onChange={(_, v) => { + // v0.3.0 修复:类型安全检查,只接受有效的 tab 值 + if (typeof v === 'string' && validTabs.includes(v as DetailTab)) { + setTab(v as DetailTab); + } + }} variant="fullWidth" sx={{ minHeight: 32, height: 32, flexShrink: 0, @@ -47,7 +58,7 @@ export function DetailPanel(): React.JSX.Element { - {tab === 'trace' && ( + {safeTab === 'trace' && ( <> @@ -56,17 +67,17 @@ export function DetailPanel(): React.JSX.Element { )} - {tab === 'memory' && ( + {safeTab === 'memory' && ( )} - {tab === 'tasks' && ( + {safeTab === 'tasks' && ( )} - {tab === 'workspace' && ( + {safeTab === 'workspace' && ( diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 90868a1..bd21a32 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -12,8 +12,15 @@ import { toggleTheme } from './useTheme'; /** * 检测是否为 macOS + * v0.3.0 修复:navigator.platform 已废弃,改用 userAgentData(Chromium 90+)或降级到 platform */ function isMac(): boolean { + // 优先使用 userAgentData.platform(现代 API) + const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData; + if (userAgentData?.platform) { + return userAgentData.platform.toUpperCase().includes('MAC'); + } + // 降级到 navigator.platform(已废弃但仍可用) return navigator.platform.toUpperCase().indexOf('MAC') >= 0; } @@ -55,6 +62,8 @@ export function useKeyboardShortcuts(): void { window.metona.sessions.create().then((result: MetonaSessionInfo) => { useSessionStore.getState().addSession(result); useSessionStore.getState().setCurrentSession(result.id); + // v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话 + useAgentStore.getState().setCurrentSession(result.id); }).catch((err) => { console.error('[KeyboardShortcuts]', err); }); } e.preventDefault(); @@ -101,7 +110,12 @@ export function useKeyboardShortcuts(): void { const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState(); if (sessions.length > 0 && currentSessionId) { const idx = sessions.findIndex((s) => s.id === currentSessionId); - if (idx > 0) setCurrentSession(sessions[idx - 1].id); + // v0.3.0 修复: 同步 agent-store,确保聊天面板加载新会话消息 + if (idx > 0) { + const newId = sessions[idx - 1].id; + setCurrentSession(newId); + useAgentStore.getState().setCurrentSession(newId); + } } e.preventDefault(); return; @@ -112,14 +126,25 @@ export function useKeyboardShortcuts(): void { const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState(); if (sessions.length > 0 && currentSessionId) { const idx = sessions.findIndex((s) => s.id === currentSessionId); - if (idx < sessions.length - 1) setCurrentSession(sessions[idx + 1].id); + // v0.3.0 修复: 同步 agent-store,确保聊天面板加载新会话消息 + if (idx < sessions.length - 1) { + const newId = sessions[idx + 1].id; + setCurrentSession(newId); + useAgentStore.getState().setCurrentSession(newId); + } } e.preventDefault(); return; } // Ctrl+Shift+C — 复制最后一条 Agent 回复 + // v0.3.0 修复:若有任何选中文本(无论是否在输入框),跳过(让浏览器执行默认复制操作) if (key === 'c' && matchModifier(e, true, true)) { + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + // 有选中文本,让浏览器执行默认复制操作 + return; + } const { messages } = useAgentStore.getState(); const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant'); if (lastAssistant?.content) { @@ -128,6 +153,27 @@ export function useKeyboardShortcuts(): void { e.preventDefault(); return; } + + // v0.3.0: Ctrl+B — 切换侧边栏 + if (key === 'b' && matchModifier(e, true)) { + useUIStore.getState().toggleSidebar(); + e.preventDefault(); + return; + } + + // v0.3.0: Ctrl+J — 切换详情面板 + if (key === 'j' && matchModifier(e, true)) { + useUIStore.getState().toggleDetail(); + e.preventDefault(); + return; + } + + // v0.3.0: Ctrl+Shift+F — 专注模式 + if (key === 'f' && matchModifier(e, true, true)) { + useUIStore.getState().toggleFocusMode(); + e.preventDefault(); + return; + } }; window.addEventListener('keydown', handleKeyDown); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8f18a78..c6dc541 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -72,7 +72,7 @@ export const SHORTCUTS = { SEND_MESSAGE: { key: 'Enter', ctrl: false }, NEW_LINE: { key: 'Enter', ctrl: true }, ABORT: { key: '.', ctrl: true }, - // TODO: implement in future version + // v0.3.0: 已实现的面板控制快捷键 FOCUS_MODE: { key: 'f', ctrl: true, shift: true }, TOGGLE_SIDEBAR: { key: 'b', ctrl: true }, TOGGLE_DETAIL: { key: 'j', ctrl: true }, diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index 86f953d..d8e14c7 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -144,6 +144,8 @@ export const useAgentStore = create((set, get) => ({ // 从数据库加载该会话的消息 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[]; @@ -168,6 +170,8 @@ export const useAgentStore = create((set, get) => ({ // 从数据库加载该会话的 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 步骤补全 @@ -210,6 +214,8 @@ export const useAgentStore = create((set, get) => ({ }); 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 }); diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index cb892e5..62ea637 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -2,12 +2,20 @@ * Zustand Store — UI 状态管理 * * 主题、设置弹窗、引导向导等全局 UI 状态。 + * + * v0.3.0 增强: + * - 添加详情面板标签控制(detailTab)— 支持斜杠命令切换标签 + * - 添加侧边栏/详情面板可见性控制 — 支持快捷键切换 + * - 添加专注模式 — 隐藏侧边栏和详情面板 */ import { create } from 'zustand'; export type ThemeMode = 'dark' | 'light' | 'auto'; +/** v0.3.0: 详情面板标签类型 */ +export type DetailTab = 'trace' | 'memory' | 'tasks' | 'workspace'; + interface UIState { // 主题 theme: ThemeMode; @@ -17,12 +25,30 @@ interface UIState { settingsOpen: boolean; onboardingCompleted: boolean; + // v0.3.0: 面板可见性 + sidebarVisible: boolean; + detailVisible: boolean; + focusMode: boolean; + + // v0.3.0: 详情面板标签 + detailTab: DetailTab; + + // v0.3.0: 进入专注模式前的面板状态快照(用于恢复) + preFocusSidebarVisible: boolean; + preFocusDetailVisible: boolean; + // Actions setTheme: (theme: ThemeMode) => void; setResolvedTheme: (theme: 'dark' | 'light') => void; openSettings: () => void; closeSettings: () => void; setOnboardingCompleted: (completed: boolean) => void; + + // v0.3.0: 面板控制 Actions + setDetailTab: (tab: DetailTab) => void; + toggleSidebar: () => void; + toggleDetail: () => void; + toggleFocusMode: () => void; } export const useUIStore = create((set) => ({ @@ -31,9 +57,48 @@ export const useUIStore = create((set) => ({ settingsOpen: false, onboardingCompleted: false, + // v0.3.0: 面板默认可见 + sidebarVisible: true, + detailVisible: true, + focusMode: false, + detailTab: 'trace', + preFocusSidebarVisible: true, + preFocusDetailVisible: true, + setTheme: (theme) => set({ theme }), setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }), openSettings: () => set({ settingsOpen: true }), closeSettings: () => set({ settingsOpen: false }), setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), + + // v0.3.0: 面板控制 + setDetailTab: (tab) => set({ detailTab }), + // 退出专注模式时恢复快照(若用户在专注模式下手动切换面板,视为退出专注模式) + // v0.3.0 修复: 退出专注模式时同时恢复另一个面板的快照,与 toggleFocusMode 关闭逻辑一致 + toggleSidebar: () => set((s) => s.focusMode + ? { sidebarVisible: !s.sidebarVisible, detailVisible: s.preFocusDetailVisible, focusMode: false } + : { sidebarVisible: !s.sidebarVisible }), + toggleDetail: () => set((s) => s.focusMode + ? { detailVisible: !s.detailVisible, sidebarVisible: s.preFocusSidebarVisible, focusMode: false } + : { detailVisible: !s.detailVisible }), + // v0.3.0 修复:专注模式开启时保存当前面板状态快照,关闭时恢复快照 + // 避免关闭时强制设为 true 覆盖用户进入专注模式前的原有状态 + toggleFocusMode: () => set((s) => { + if (!s.focusMode) { + // 开启专注模式:保存快照,隐藏面板 + return { + focusMode: true, + preFocusSidebarVisible: s.sidebarVisible, + preFocusDetailVisible: s.detailVisible, + sidebarVisible: false, + detailVisible: false, + }; + } + // 关闭专注模式:恢复快照 + return { + focusMode: false, + sidebarVisible: s.preFocusSidebarVisible, + detailVisible: s.preFocusDetailVisible, + }; + }), }));