From 3c5aea8fb7c0602a1dde2e581218333532c1a5c7 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 12 Jul 2026 12:54:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E8=87=B3=20v0.2.1=20?= =?UTF-8?q?=E2=80=94=20=E6=B5=81=E5=BC=8F=E6=B8=B2=E6=9F=93=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E5=AE=89=E5=85=A8=E5=A2=9E=E5=BC=BA=E3=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=87=AA=E5=8A=A8=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1 --- docs/Agentic-Loop详解.md | 636 ++++++++++++++++++ electron/harness/agent-loop/engine.ts | 271 +++++++- electron/harness/hooks/confirmation-hook.ts | 222 ++++++ electron/harness/hooks/post-tool.ts | 4 +- electron/harness/hooks/pre-tool.ts | 5 +- electron/harness/memory/consolidator.ts | 277 ++++++++ electron/harness/memory/manager.ts | 282 +++++++- electron/harness/sandbox/permissions.ts | 24 +- electron/harness/sandbox/sandbox.ts | 74 +- .../security/prompt-injection-defense.ts | 108 ++- .../harness/tools/built-in/code-search.ts | 228 +++++++ electron/harness/tools/built-in/command.ts | 79 ++- .../harness/tools/built-in/diff-viewer.ts | 258 +++++++ .../harness/tools/built-in/file-editor.ts | 212 ++++++ electron/harness/tools/built-in/file-guard.ts | 15 +- electron/harness/tools/built-in/index.ts | 24 + .../harness/tools/built-in/task-manager.ts | 329 +++++++++ electron/harness/tools/registry.ts | 15 +- electron/harness/types/metona-response.ts | 4 +- electron/ipc/handlers.ts | 174 ++++- electron/main.ts | 81 ++- electron/preload.ts | 35 + electron/services/audit.service.ts | 120 +++- electron/services/database.service.ts | 47 +- package-lock.json | 58 +- package.json | 2 +- src/App.tsx | 2 + src/components/ConfirmationDialog.tsx | 221 ++++++ src/components/chat/AssistantMessage.tsx | 2 +- src/components/chat/MessageList.tsx | 8 +- src/components/common/ErrorBoundary.tsx | 53 ++ src/components/layout/DetailPanel.tsx | 51 +- src/components/layout/Sidebar.tsx | 40 +- src/components/memory/MemoryViewer.tsx | 440 ++++++++++++ src/components/settings/SettingsModal.tsx | 98 ++- src/components/tasks/TaskList.tsx | 378 +++++++++++ src/hooks/useAgentStream.ts | 195 +++++- src/lib/formatters.ts | 1 + src/lib/ipc-client.ts | 181 ----- src/stores/agent-store.ts | 43 +- src/types/global.d.ts | 98 ++- 41 files changed, 4972 insertions(+), 423 deletions(-) create mode 100644 docs/Agentic-Loop详解.md create mode 100644 electron/harness/hooks/confirmation-hook.ts create mode 100644 electron/harness/memory/consolidator.ts create mode 100644 electron/harness/tools/built-in/code-search.ts create mode 100644 electron/harness/tools/built-in/diff-viewer.ts create mode 100644 electron/harness/tools/built-in/file-editor.ts create mode 100644 electron/harness/tools/built-in/task-manager.ts create mode 100644 src/components/ConfirmationDialog.tsx create mode 100644 src/components/common/ErrorBoundary.tsx create mode 100644 src/components/memory/MemoryViewer.tsx create mode 100644 src/components/tasks/TaskList.tsx delete mode 100644 src/lib/ipc-client.ts diff --git a/docs/Agentic-Loop详解.md b/docs/Agentic-Loop详解.md new file mode 100644 index 0000000..c975f38 --- /dev/null +++ b/docs/Agentic-Loop详解.md @@ -0,0 +1,636 @@ +# 🔄 Agentic Loop(智能体循环)完全指南 + +> **一句话定义**:Agentic Loop 是驱动 AI Agent 持续自主完成复杂任务的核心调度循环——让 AI 不断重复"思考 → 行动 → 观察 → 再思考"的闭环过程,直到任务完成。 + +--- + +## 目录 + +- [一、核心概念](#一核心概念) +- [二、与传统 LLM 的本质区别](#二与传统-llm的本质区别) +- [三、运作机制:五阶闭环详解](#三运作机制五阶闭环详解) +- [四、三层分级体系](#四三层分级体系) +- [五、循环终止条件](#五循环终止条件) +- [六、核心伪代码实现](#六核心伪代码实现) +- [七、与主流 Agent 范式的关系](#七与主流-agent-范式的关系) +- [八、工具调用(Tool Use)六步闭环](#八工具调用tool-use六步闭环) +- [九、适用场景与不适用场景](#九适用场景与不适用场景) +- [十、实际应用案例](#十实际应用案例) +- [十一、关联循环体系](#十一关联循环体系) +- [十二、行业趋势与未来方向](#十二行业趋势与未来方向) +- [十三、参考来源](#十三参考来源) + +--- + +## 一、核心概念 + +### 什么是 Agentic Loop? + +**Agentic Loop(智能体循环)**是 AI Agent 架构中的核心运行机制,它让大语言模型(LLM)从传统的"一问一答"模式升级为能够持续自主地完成复杂任务的闭环系统。 + +如果把普通 AI 比作「随时待命的客服」,只负责被动应答;那带 Loop 的 AI Agent,就是 **不用催促、自主推进的全职实习生**,懂规划、会试错、能复盘、可收尾。 + +### 为什么需要它? + +传统的 LLM 交互是线性的"一问一答"——用户提问,模型一次性生成回答,交互即结束。一旦任务复杂、变数较多,就会直接"摆烂",需要人不断细化指令、手动推进进度。 + +而 **Agentic Loop 引入了「循环」和「反馈」两个关键要素**,使 AI 能够根据中间结果动态调整策略,边执行边验证,而不是死板地执行预设流程。 + +> 💡 **没有 Agent Loop,AI 只是聊天工具;有了 Agent Loop,AI 才是能落地干活的智能体。** + +--- + +## 二、与传统 LLM 的本质区别 + +| 维度 | 传统 LLM | Agentic Loop | +|------|----------|--------------| +| **交互方式** | 线性"一问一答" | 循环式"思考→行动→观察" | +| **任务能力** | 单次内容生成 | 持续自主完成复杂任务 | +| **反馈机制** | 无中间反馈 | 基于环境反馈动态调整 | +| **适应性** | 静态输出 | 动态纠错与策略优化 | +| **记忆能力** | 无状态 | 可选配持久化记忆 | +| **工具使用** | 不支持 | 原生支持多工具调用 | + +```mermaid +flowchart LR + subgraph Traditional["传统 LLM:单次交互"] + A1[用户提问] --> B1[LLM 一次性生成] + B1 --> C1[输出结果 · 结束] + end + + subgraph Agentic["Agentic Loop:闭环迭代"] + A2[设定目标] --> B2[思考决策] + B2 --> C2[行动执行] + C2 --> D2[观察反馈] + D2 --> E2{任务完成?} + E2 -->|否| B2 + E2 -->|是| F2[输出最终结果] + end +``` + +--- + +## 三、运作机制:五阶闭环详解 + +Agentic Loop 的完整工作流包含五个阶段,在多数任务中可简化为持续迭代的三步核心循环。 + +### 完整五阶段 + +#### ① 目标感知(Perceive) +人类只需给出 **最终目标和核心规则**,不用拆解步骤、不用预设细节。例如: +- "整理一份2026年新媒体行业报告" +- "排查这段代码的运行漏洞并修复" +- "规划一周减脂食谱+运动计划" + +这一步彻底告别了过去「精准逐字写提示词」的繁琐。 + +#### ② 思考决策(Reason) +Agent 结合自身知识库、记忆体系和工具能力,把大目标拆成可落地的小步骤,同时判断: +- 下一步该做什么? +- 需要调用什么工具? +- 优先执行哪项任务? + +> **核心区别:步骤不是人定的,是 AI 自己推理出来的。** + +#### ③ 行动执行(Act) +根据决策结果执行具体动作: +- 🔍 联网搜索信息 +- 💻 调用代码工具 / 执行 Shell 命令 +- 📄 读取本地文件 +- ✍️ 生成文案内容 +- 📊 批量处理数据 + +这是 AI 从「只会输出文字」到「可以真实做事」的关键一步。 + +#### ④ 观察反馈(Observe) +Agent 会主动感知执行后的真实状态: +- 搜索的信息是否全面? +- 代码是否运行成功? +- 生成的内容是否缺漏? +- 数据是否存在异常? + +它不再盲目输出,而是 **能看见自己的执行结果**。 + +#### ⑤ 复盘修正(Reflect/Correct) +这是 Loop 最核心的灵魂。Agent 会自我校验: +- 当前结果是否达标? +- 有没有遗漏步骤? +- 有没有错误漏洞? +- 是否需要调整方法? + +如果不满足终止条件,立刻修正策略、重启下一轮循环;如果达标,才终止任务、输出最终结果。 + +### 核心三步简化版 + +在实际工程中,五阶段常被精简为最核心的三步循环: + +``` +┌─────────────────────────────────────────────┐ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌────────┐│ +│ │ 推理 │──▶│ 行动 │──▶│ 观察 ││ +│ │ Reasoning│ │ Acting │ │Observ. ││ +│ └──────────┘ └──────────┘ └────────┘│ +│ ▲ │ │ +│ └──────────────────────────────┘ │ +│ 未完成则继续循环 │ +└─────────────────────────────────────────────┘ +``` + +> **简单总结:思考 → 行动 → 观察 → 评估 → 修正,往复循环,闭环落地。** + +--- + +## 四、三层分级体系 + +Agentic Loop 不是固定模板,随着记忆存储、工具管理、配套功能完善,可分为三个层级。开发中遇到的绝大多数问题——AI 重复执行相同操作、遗忘前文对话、多轮回答前后逻辑矛盾——根源基本都是 **任务复杂度与智能体层级不匹配**。 + +### 第一层:基础工具调用循环 + +**最简形态**,仅依靠大模型调用工具并输出回答,没有持久化记忆、没有外部状态存储。 + +``` +用户输入 → LLM 推理 → 调用工具 → 观察结果 → 回传 LLM → 输出最终答案 +``` + +**特点:** +- ✅ 处理独立、简短的一次性任务完全够用 +- ❌ 无法留存历史对话,每次启动都是全新空白状态 +- ❌ 上下文窗口是唯一临时存储载体,流程结束后所有状态清空 +- ❌ 用于多轮对话时会出现:重复检索运算、遗忘前文决策、前后自相矛盾 + +**适合场景**:单次问答、简单查询、一次性工具调用 + +--- + +### 第二层:内置完整生命周期的循环 + +升级后,循环内部新增标准化 **记忆操作流程**: +- 调用大模型前 **读取** 历史记忆数据 +- 智能体完成动作后 **写入/更新** 记忆 +- 整套循环形成完整闭环生命周期 + +#### 关键区分:记忆增强型 vs 记忆感知型 + +| 类型 | 说明 | 能力上限 | +|------|------|----------| +| **记忆增强型** | 仅被动检索信息注入上下文,不会主动管控内存 | 较低 —— 记忆是外部附加能力 | +| **记忆感知型** | 将内存作为核心工程模块,主动完成编码、存储、检索、注入、遗忘全套操作 | 较高 —— 在单次/跨会话中持续维护自身推理状态 | + +第二层是搭建 **记忆感知型智能体** 的起点。 + +#### 第二层常见挑战及缓解方案 + +| 挑战 | 说明 | 缓解方法 | +|------|------|----------| +| **检索噪声** | 语义相似但与当前查询实际不相关 | 设置相关性阈值 + 混合检索 + 多级过滤 | +| **陈旧记忆** | 快速变化领域中数据很快过时 | 设置 TTL(生存时间)策略 + 写时更新模式 | +| **工具定义过载** | 工具太多导致上下文膨胀,降低选择准确性 | 采用语义工具检索而非穷举所有工具 | + +**适合场景**:多轮对话、长周期任务、需要上下文延续的复杂工作流 + +--- + +### 第三层:Harness 工程体系级循环 + +工程师不仅能管控循环内部逻辑,还在循环外围搭建一套 **设计规范、功能完善的 Harness 框架**。系统操作分为两大板块: + +| 板块 | 说明 | 示例 | +|------|------|------| +| **循环内操作** | 程序自动执行 | 自动加载上下文、执行工具调用 | +| **循环外操作** | 智能体自主触发 | AI 判断是否需要额外信息、主动请求人工确认 | + +#### 第三层必需的三类优化手段 + +1. **上下文窗口监控**:实时统计每轮 Token 占用,提前预判溢出风险,及时触发压缩 +2. **对话压缩**:用精简摘要替代冗长聊天记录,原始消息永久保存在数据库,支持审计和按需展开 +3. **工具输出离线存储**:完整工具返回结果存入独立日志表,上下文仅保留一行引用标识 + +> **第三层的核心升级不在于内层的基础循环逻辑,而是循环外围一整套配套支撑系统:数据加载框架、运行约束管控、跨会话持久化存储层。此时整套 Harness 本身已经是一套独立、成熟、可单独运维的工程系统。** + +**适合场景**:生产级应用、企业级部署、高可靠性要求的复杂 Agent 系统 + +```mermaid +flowchart TB + L1["第一层: 基础工具调用循环\n✅ 单次任务 / 简单查询"] --> L2["第二层: 内置记忆的完整生命周期\n✅ 多轮对话 / 长周期任务"] + L2 --> L3["第三层: Harness 工程体系\n✅ 生产级 / 企业级部署"] + + style L1 fill:#e8f5e9,stroke:#4caf50 + style L2 fill:#fff3e0,stroke:#ff9800 + style L3 fill:#e3f2fd,stroke:#2196f3 +``` + +--- + +## 五、循环终止条件 + +任何循环都必须设置退出机制。设计完善的 Agentic Loop 会明确定义全部终止规则: + +### 主流终止条件 + +| # | 终止条件 | 说明 | +|---|----------|------| +| 1 | **模型输出最终回复** | 无待执行的工具调用(`tool_calls` 为空) | +| 2 | **系统校验任务完成** | Harness 主动校验目标已达成 | +| 3 | **达到最大迭代次数** | 默认通常设为 10~20 次,防止无限循环 | +| 4 | **运行时长超限** | 全局超时保护,双重管控资源消耗 | +| 5 | **不可恢复的系统错误** | 发生无法自动修复的异常 | +| 6 | **死循环检测** | 连续多轮重复执行相同操作,无任何进展 | +| 7 | **Agent 主动结束** | 智能体发出完成标记 | + +### ⚠️ 常见误区 + +> **模型不再发起工具调用 ≠ 用户需求已全部完成** + +模型可能输出追问、部分结果或需要补充交互的内容。任务是否真正闭环,需要 **Harness 主动校验**,不能单纯依靠模型停止调用工具来判断。任务流程越长、逻辑越复杂,二者的差距越明显。 + +### 卡死故障检测 + +成熟的 Harness 框架会缓存近期全部工具调用记录,识别以下停滞模式后直接终止流程并输出诊断日志: + +- 连续三轮用完全相同参数调用同一个工具 +- AI 在两种状态间反复来回切换、毫无进展 + +--- + +## 六、核心伪代码实现 + +以下是所有 AI 智能体、自动化工作流的底层通用代码逻辑(无编程基础也能看懂): + +```python +# ============================================ +# Agentic Loop 核心闭环逻辑(通用极简版) +# ============================================ + +def agent_loop(目标任务): + # ---- 1. 初始化 ---- + 当前状态 = "未完成" + 最大循环次数 = 20 # 防止无限死循环 + 历史执行记录 = [] + + # ---- 2. 主循环 ---- + while 当前状态 == "未完成" and 循环次数 < 最大循环次数: + + # ① 思考决策:根据目标 + 历史反馈 规划下一步动作 + 下一步动作 = llm_思考推理(目标任务, 历史执行记录) + + # ② 行动执行:调用工具、落地操作 + 执行结果 = 工具执行(下一步动作) + + # ③ 观察反馈:记录本次执行的所有数据和状态 + 历史执行记录.append({ + "动作": 下一步动作, + "结果": 执行结果, + "时间戳": 当前时间() + }) + + # ④ 复盘评估:判断是否达标、是否需要优化 + 任务是否完成 = llm_校验(目标任务, 执行结果) + + if 任务是否完成 == True: + 当前状态 = "已完成" + else: + # 未达标,自动修正策略,开启下一轮循环 + 修正策略 = llm_纠错优化(目标任务, 历史执行记录) + # (修正策略会自然融入下一轮 llm_思考推理) + + # ---- 3. 输出最终成果 ---- + return 最终整合结果(历史执行记录) +``` + +### 代码核心解读 + +| 要点 | 说明 | +|------|------| +| **`while` 循环是灵魂** | 普通 AI 没有 `while` 循环,只会执行一次输出;Agent Loop 依靠持续循环实现反复干活、反复优化 | +| **自带记忆迭代** | 每一轮都记录「动作 + 结果」,下一轮参考历史数据,越循环越精准 | +| **自带终止机制** | 最大循环次数 + 任务校验双保险,既保证自主迭代又避免无效死循环 | + +### 更底层的 LangChain 风格实现 + +```python +# ============================================ +# 用 LangChain 实现的核心循环(更接近真实工程) +# ============================================ + +while not done: + # 1. 构建上下文(system prompt + 历史消息 + 当前输入) + messages = build_context(system_prompt, history, current_input) + + # 2. 调用 LLM API(流式或非流式) + response = call_llm(messages) + + # 3. 解析响应 + if response.has_tool_calls: + # 4a. 有工具调用 → 逐个执行 + results = execute_tools(response.tool_calls) + # 5a. 结果追加到历史 + history.append(response) + history.append(results) + # → 回到步骤 1,进入下一次迭代 + else: + # 4b. 无工具调用 → 输出最终回复 + done = True + return response.content +``` + +> 一个简单的"帮我查天气"请求通常需要 **2~3 次**迭代;而复杂任务如"帮我改这个 bug"可能需要 **10+ 次**迭代——读文件、运行测试、编辑代码、再运行测试…… + +--- + +## 七、与主流 Agent 范式的关系 + +Agentic Loop 是多个 AI Agent 工程范式的共同基础设施: + +### 🔄 ReAct 模式(最基础、最具代表性) + +由 Shunyu Yao 等人于 **2022 年**在论文《ReAct: Synergizing Reasoning and Acting in Language Models》中提出。 + +- **核心思想**:将 **思维链推理(CoT)** 与 **外部环境交互行动** 相结合 +- **解决的问题**:弥补单纯 LLM 缺乏实时信息和容易产生幻觉的缺陷 +- **影响范围**:已成为现代 AI 代理设计的基准,深刻影响了 **LangChain** 和 **LlamaIndex** 等后续框架 + +**ReAct 的经典执行轨迹示例**(排查线上服务变慢): + +``` +Thought 1: 用户报告服务变慢,我需要先查看监控指标 +Action 1: query_monitoring(service="api-server") +Observation 1: CPU 使用率 95%,响应时间 P99 > 5s + +Thought 2: CPU 飙升且响应慢,可能是慢 SQL 导致的,检查数据库 +Action 2: query_database_logs(time_range="last_hour") +Observation 2: 发现全表扫描查询 SELECT * FROM orders WHERE ... + +Thought 3: 已定位根因——缺少索引导致的全表扫描,通知负责人修复 +Action 3: send_alert(channel="slack", message="发现慢SQL根因...") +Observation 3: ✅ 告警已发送 + +Final Answer: 服务变慢的原因是 orders 表缺少索引导致全表扫描, + 已发送告警给 DBA 团队,建议立即添加索引。 +``` + +### 📋 Plan-and-Execute(先规划再执行) + +在 Loop 中引入更复杂的任务分解和调度机制: +1. 先制定完整的分步计划(Plan) +2. 再逐步执行每个子任务(Execute) +3. 执行过程中可根据结果回溯调整计划 + +### 🔍 Reflection(反思机制) + +在 Loop 中增加 **自我评估和纠错**环节: +- 每步行动后评估效果 +- 从失败中学习 +- 提升长期表现 + +### 👤 Human-in-the-Loop(人机协同 / HITL) + +将人类作为核心参与者整合到 Agentic 生命周期中(而非仅仅作为监督者): +- 满足企业的法律、合规和安全要求 +- AI 在关键节点主动暂停,列出待确认项等待人工审核 +- **不是兜底 bug,而是架构设计的分层逻辑** + +### 🤖 Multi-Agent System(多智能体协作) + +多个 Agent 各自拥有独立的 Agentic Loop,通过协作模式共同完成任务: +- **并行模式**:多个 Agent 同时处理不同子任务 +- **顺序模式**:按流水线顺序传递处理 +- **循环模式**:Agent 之间形成协作循环 + +--- + +## 八、工具调用(Tool Use)六步闭环 + +大模型本质上是"静态文本生成器",训练后无法自动更新知识,也不能执行计算、查询私有数据或操作外部系统。要让模型"感知并行动",必须把外部能力包装成 **"工具"**,让模型按需调用。 + +### 六步闭环流程 + +``` +┌──────────────────────────────────────────────────────┐ +│ │ +│ ① 工具定义 → ② LLM 决策 → ③ 结构化调用 │ +│ ↑ │ │ +│ └── ⑥ 最终回答 ← ⑤ 结果回传 ← ④ 执行层运行 ←───┘│ +│ │ +└──────────────────────────────────────────────────────┘ +``` + +| 步骤 | 操作 | 说明 | +|------|------|------| +| **① 工具定义** | 用 JSON Schema 或框架装饰器描述函数名、功能、字段类型与语义 | 供 LLM 读取和理解 | +| **② LLM 决策** | 将"用户提问 + 可用工具描述"一起输入模型 | 模型内部判断是否需要调用工具 | +| **③ 结构化调用** | 模型输出 JSON 格式的调用指令 | 如 `{"name": "get_weather", "arguments": {"city": "London"}}` | +| **④ 执行层截获运行** | 框架根据 JSON 路由到真实函数 | 完成 API 请求、数据库查询、代码运行等 | +| **⑤ 结果回传** | 函数返回值作为新上下文再喂给 LLM | 让模型基于真实结果继续推理 | +| **⑥ 最终回答** | 模型结合原始提问与工具观察生成答案 | 若仍缺信息可循环 ②~⑤ | + +### LangChain 代码示例 + +```python +from langchain_openai import ChatOpenAI +from langchain_core.tools import tool +from langchain.agents import create_agent + +# ① 定义工具 +@tool +def get_weather(city: str) -> str: + """查询指定城市的天气""" + return f"{city}今天晴,25°C" + +# ② 创建带工具的 Agent(内部编译出 StateGraph 循环) +llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) +agent = create_agent(llm, [get_weather]) + +# ③ 运行(自动进入 Agentic Loop) +result = agent.invoke({ + "messages": [{"role": "user", "content": "北京今天天气怎么样?"}] +}) +print(result["messages"][-1].content) +# → 今天北京天气晴,气温25°C +``` + +--- + +## 九、适用场景与不适用场景 + +### ✅ 适合使用 Agentic Loop 的场景 + +| 场景特征 | 典型例子 | +|----------|----------| +| **步骤数无法事先确定** | "调研某个技术领域并输出完整报告" | +| **需要根据中间结果动态调整策略** | 搜索失败时换关键词重试;代码报错时换方案修复 | +| **任务完成度比速度更重要** | 深度资料分析、复杂代码重构 | +| **多步骤、长链路任务** | 自动办公流程、多轮内容创作 | +| **需要自我纠错的能力** | 排查 Bug、调试程序 | + +### ❌ 不适合使用 Agentic Loop 的场景 + +| 场景特征 | 原因 | 更好的选择 | +|----------|------|------------| +| **固定序列的工作流** | 高度可预测、步骤固定 | 确定性的代码 Pipeline | +| **简单任务** | 一次 LLM + 一次工具调用就能解决 | 直接函数调用 | +| **严格的延迟约束** | 每次迭代都要调 LLM,时间和 Token 成本累积 | 预计算 / 缓存 | + +--- + +## 十、实际应用案例 + +### 🖥️ 开发场景:Claude Code / Cursor / OpenClaw + +每一次与编程助手的交互会话,本质上都是一个 Agentic Loop: + +``` +读取用户请求 → 检查代码仓库 → 编辑文件 → 运行测试 → 识别报错 → 再次编辑 → ... → 构建成功 +``` + +这套 **推理 → 行动 → 观察结果** 的往复流程,如今几乎所有的生产级编程智能体都以它为核心。 + +### 📊 办公场景 + +- 自动整理会议纪要 → 提炼重点 → 拆解待办 → 生成执行方案 → 跟进任务进度 +- 读取表格数据 → 分析异常 → 生成可视化报告 → 给出优化建议 + +### ✍️ 内容场景 + +- 自主选题 → 搜索资料 → 梳理框架 → 撰写初稿 → 校对纠错 → 优化措辞 → 排版输出 + +### 🏠 智能硬件场景 + +扫地机器人避障规划、智能温控调节、自动驾驶路况判断——本质都是持续 **感知 → 决策 → 行动 → 修正** 的小型 Agent Loop 循环。 + +> **所有「越用越聪明、能自主干活」的 AI,核心都是 Agentic Loop。** + +--- + +## 十一、关联循环体系 + +Agentic Loop 并非孤立存在,外部多层循环会直接影响其架构设计: + +### 三大关联循环 + +```mermaid +flowchart TB + TL["🔄 训练循环
数据采集 → 梯度更新 → 效果评估 → 版本发布
离线流程 · 周期以天/周计"] + + AL["🔄 Agentic Loop(智能体循环)
推理 → 行动 → 观察 → 再推理
在线实时流程 · 以秒计"] + + FL["🔄 反馈循环
工具返回 → 用户修正 → 量化指标 → 评估监控"] + + HL["🔄 人工介入循环
AI 暂停 → 人工审核 → 确认/修改 → 继续运行"] + + AL -->|"产生交互数据"| FL + FL -->|"存入记忆库"| TL + AL -->|"触及权限边界"| HL + HL -->|"确认后继续"| AL +``` + +| 循环类型 | 性质 | 周期 | 说明 | +|----------|------|------|------| +| **训练循环** | 离线 | 天/周 | 大模型诞生的底层流程;现阶段与 Agentic Loop 完全解耦(模型权重固定) | +| **反馈循环** | 在线 | 实时 | 每次动作产生的反馈信号(工具返回、用户修正、量化指标);持续迭代进化的核心 | +| **人工介入循环** | 按需 | 不定 | AI 遇到无法自主决策的节点时主动暂停,等待人工确认 | + +### ⚠️ 关键边界:训练循环 vs Agentic Loop + +现阶段两类循环 **完全解耦**: +- 模型训练完成后权重固定,Agent 在静态权重之上运行 +- 对话中表现出的"记忆""学习""纠错",**并非更新模型权重**,只是从内存检索历史信息 +- 分清两者边界才能精准定位问题:需要优化记忆存储?还是重新训练大模型? + +--- + +## 十二、行业趋势与未来方向 + +### AI 技术的三次范式跃迁 + +``` +判别式 AI ──▶ 生成式 AI ──▶ Agentic AI + (分类/预测) (ChatGPT) (自主执行任务) +``` + +英伟达 CEO 黄仁勋在 **2025 GTC 大会**上宣称:我们即将步入 **Agentic AI 时代**。英伟达称 Agentic AI 为 **"人工智能的下一个前沿"**。 + +### 从 Prompt Engineering 到 Loop Engineering + +> **AI 的竞争,从「单次提示优化」变成了「循环系统设计」。** + +| 过去(Prompt Engineering) | 现在(Loop Engineering) | +|---------------------------|--------------------------| +| 反复打磨提示词 | 设计更合理的 Agent Loop | +| 细化指令、预设场景 | 优化推理逻辑、调整循环策略 | +| 人工弥补 AI 不智能 | 让 AI 自己试错、自己优化 | +| 单次交互质量 | 循环系统效能 | + +### 未来方向:打通全链路持续学习 + +当前智能体循环、模型训练循环、反馈循环分属三套独立开发体系。未来方向是将它们 **打通闭环**: + +``` +Agentic Loop 产出真实交互经验 + ↓ + 存入记忆库(高质量数据) + ↓ + 持续学习技术(Continual Learning) + ↓ + 把经验融入模型参数 + ↓ + 模型越来越强 → Agent 越来越聪明 +``` + +届时 **记忆存储的数据质量将直接决定训练素材质量**——规整清晰的聊天记录、精准提取的关键信息、可靠的反馈评价,能产出高质量训练数据;杂乱无章、无规划存储的对话,无法用于模型迭代。 + +--- + +## 十三、核心价值总结 + +Agentic Loop 赋予了 AI 系统四大核心能力: + +| 能力 | 图标 | 说明 | +|------|------|------| +| **自主性 (Autonomy)** | 🧠 | 无需持续人工干预即可持续推进任务 | +| **目标导向 (Goal-Directed)** | 🎯 | 理解抽象目标并将其转化为具体行动序列 | +| **动态适应 (Adaptive)** | 🔄 | 通过持续的环境反馈优化策略 | +| **多模态协作 (Multi-tool)** | 🔗 | 整合多源数据,支持跨工具/跨系统调用 | + +### 一句话总结 + +> **没有 Agentic Loop,AI Agent 就只是一个静态的问答工具;有了它,AI 才真正具备了"持续自主完成复杂任务"的能力。** +> +> **单次输出是工具,循环闭环才是智能。** + +--- + +## 参考来源 + +### 核心论文 +- Yao, S., et al. (2022). **ReAct: Synergizing Reasoning and Acting in Language Models** — [CSDN 解析](https://blog.csdn.net/enjoyedu/article/details/159416232) + +### 深度解析文章 +- [解读 Agent Loop(智能体循环)的三层分级体系](https://www.111cn.net/new/602954.htm) — 一聚教程网 +- [看懂 Agent Loop:AI 从「被动问答」到「自主干活」的核心密码](https://cloud.tencent.com/developer/article/2694843) — 腾讯云开发者社区 +- [推理 → 行动 → 观察:用 LangChain + Python 实现一个智能体循环](https://blog.csdn.net/m0_46510245/article/details/161346563) +- [OpenAI 解析 Codex CLI 核心机制:Agent Loop 工作流程详解](https://www.imooc.com/article/388435) +- [OpenClaw 源码深度解析:Agent Loop 如何调用 LLM 和工具](https://blog.csdn.net/ha_9527/article/details/158903245) +- [深入解析 Agent 内部机制:六大核心支柱](https://blog.csdn.net/l01011_/article/details/161116948) +- [Agent 底层运行逻辑拆解:读懂 Agent Loop 与 Turn 运行本质](https://blog.csdn.net/u013970991/article/details/161750174) + +### 设计模式与架构 +- [《Agentic Design Patterns》中文电子书](https://blog.csdn.net/weixin_54416957/article/details/159979689) — 涵盖反思、工具使用、规划、多智能体协作等模式 +- [AI 智能体的系统架构与核心设计模式](https://blog.csdn.net/Code1994/article/details/151221580) +- [Agentic Design Patterns 第5章:Tool Use 工具调用模式](https://blog.csdn.net/wangyaninglm/article/details/153145056) +- [LangChain 源码解析:Function Call 是如何被执行的](https://cloud.tencent.com/developer/article/2657653) +- [Orchestrator 为什么比 Agentic Loop 快:LLM 决策与执行分离](https://so.html5.qq.com/page/real/search_news?docid=70000021_0236a280df558552) + +### 行业趋势 +- [2026 版 Agentic AI 从原理到实战完整指南](https://blog.csdn.net/weixin_59191169/article/details/160925182) +- [CES 2025:Agentic AI 将如何改变未来的工作和生活](https://www.sohu.com/a/844233934_121902920) +- [2025 GTC 大会:Agentic AI 与 Robotic AI 的未来探讨](https://www.sohu.com/a/875875299_121902920) +- [IBM: What Is Agentic Reasoning?](https://www.ibm.com/think/topics/agentic-reasoning) +- [Human-in-the-Loop 人机协同策略](https://blog.csdn.net/Anspire/article/details/150609597) +- [一文读懂 Agentic AI 技术点滴](http://www.51testing.com/mobile/view.php?itemid=7804918) + +--- + +> 📝 **文档版本**:v2.0(增强版) +> 📅 **最后更新**:2026-07-11 +> 📖 **本文档基于公开技术资料整理,涵盖概念解析、分层体系、代码实现、工程实践与行业趋势 diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 2e0f356..7c1b9e9 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -65,6 +65,12 @@ export class AgentLoopEngine extends EventEmitter { private currentSessionId: string = ''; private currentRequestId: string = ''; private workspacePath: string = ''; + private abortController: AbortController | null = null; + private eventSeq = 0; + /** 当前 run 的唯一标识(用于前端过滤旧流事件) */ + private runId: string = ''; + /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ + private currentRunPromise: Promise | null = null; constructor( config: Partial = {}, @@ -127,13 +133,34 @@ export class AgentLoopEngine extends EventEmitter { sessionId: string, history: MetonaMessage[], systemPrompt: MetonaSystemPrompt, + ): Promise { + // C-4/H-6: 等待上一次 run 完全结束,防止并发 run 污染状态和旧 DONE 中断新流 + if (this.currentRunPromise) { + await this.currentRunPromise.catch(() => {}); + } + this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt); + try { + return await this.currentRunPromise; + } finally { + this.currentRunPromise = null; + } + } + + private async executeRunStream( + userMessage: MetonaMessage, + sessionId: string, + history: MetonaMessage[], + systemPrompt: MetonaSystemPrompt, ): Promise { this.startTime = Date.now(); this.aborted = false; + this.runId = `run_${nanoid(8)}`; this.iterations = []; this.currentIteration = 0; this.currentSessionId = sessionId; this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + this.abortController = new AbortController(); + this.eventSeq = 0; try { await this.transitionTo(AgentLoopState.INIT); @@ -220,7 +247,11 @@ export class AgentLoopEngine extends EventEmitter { return this.finish(TerminationReason.MAX_ITERATIONS); return this.finish(TerminationReason.TIMEOUT); } catch (error) { - this.emit('error', { error: (error as 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 }); return this.finish(TerminationReason.ERROR, undefined, error as Error); } } @@ -238,9 +269,19 @@ export class AgentLoopEngine extends EventEmitter { /** 中断循环 */ abort(): void { this.aborted = true; + this.abortController?.abort(); + this.abortController = null; this.emit('aborted'); } + /** + * 销毁引擎,清理所有监听器 + */ + destroy(): void { + this.abort(); + this.removeAllListeners(); + } + /** 获取当前状态 */ getState(): AgentLoopState { return this.currentState; @@ -279,8 +320,18 @@ export class AgentLoopEngine extends EventEmitter { // 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE if (event.type === MetonaStreamEventType.DONE) continue; - // 转发流式事件到渲染进程 - this.emit('streamEvent', event); + // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI + // RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) + if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') { + // 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收) + fullContent = ''; + reasoningContent = ''; + toolCallsBuffer.clear(); + continue; + } + + // 转发流式事件到渲染进程(注入 runId 供前端过滤旧流) + this.emit('streamEvent', { ...event, runId: this.runId }); switch (event.type) { case MetonaStreamEventType.TEXT_DELTA: @@ -322,6 +373,7 @@ export class AgentLoopEngine extends EventEmitter { break; case MetonaStreamEventType.ERROR: + // RETRY 已在循环入口过滤,此处只处理真正的错误 if (event.error) { throw new Error(event.error.message); } @@ -329,6 +381,9 @@ export class AgentLoopEngine extends EventEmitter { } } + // === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 === + await this.transitionTo(AgentLoopState.PARSING); + // 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接) if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { step.toolCalls = []; @@ -376,29 +431,89 @@ export class AgentLoopEngine extends EventEmitter { const executeAndForward = async (tc: MetonaToolCall): Promise => { const result = await this.executeToolSafely(tc); - // 立即转发工具结果到渲染进程(不等其他工具完成) + // H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流) + if (!this.aborted) { + // 立即转发工具结果到渲染进程(不等其他工具完成) + this.emit('streamEvent', { + type: MetonaStreamEventType.TOOL_RESULT, + requestId: request.meta.requestId, + sessionId, + iteration: this.currentIteration, + seq: this.nextSeq(), + timestamp: Date.now(), + toolResult: result, + runId: this.runId, + }); + } + + return result; + }; + + // H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成 + const toolsPromise = Promise.allSettled( + step.toolCalls.map((tc) => executeAndForward(tc)), + ); + const controller = this.abortController; + const abortPromise = new Promise((resolve) => { + if (this.aborted) return resolve(null); + if (controller) { + controller.signal.addEventListener('abort', () => resolve(null), { once: true }); + } + }); + const raceResult = await Promise.race([toolsPromise, abortPromise]) as + | PromiseSettledResult[] + | null; + + if (raceResult === null) { + // 被 abort 中断,标记步骤并退出 + step.completedAt = Date.now(); + step.state = AgentLoopState.TERMINATED; + return step; + } + + const settledResults = raceResult; + step.toolResults = settledResults.map((result, idx) => { + const tc = step.toolCalls![idx]; + if (result.status === 'fulfilled') return result.value; + // rejected:构造失败 result + const errorResult = { + toolCallId: tc.id, + toolName: tc.name, + result: null, + success: false, + error: `Tool execution failed: ${(result.reason as Error)?.message ?? String(result.reason)}`, + durationMs: 0, + timestamp: Date.now(), + }; + // 转发错误结果到 UI this.emit('streamEvent', { type: MetonaStreamEventType.TOOL_RESULT, requestId: request.meta.requestId, sessionId, iteration: this.currentIteration, - seq: 0, + seq: this.nextSeq(), timestamp: Date.now(), - toolResult: result, + toolResult: errorResult, + runId: this.runId, }); - - return result; - }; - - // Promise.all 保持结果顺序与 toolCalls 一致 - step.toolResults = await Promise.all( - step.toolCalls.map((tc) => executeAndForward(tc)), - ); + return errorResult; + }); } // === OBSERVING === await this.transitionTo(AgentLoopState.OBSERVING); + // === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 === + // 如果有工具调用且需要后续推理,进入 REFLECTING 状态 + if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) { + await this.transitionTo(AgentLoopState.REFLECTING); + // 检查工具执行是否有错误,如果有严重错误可以提前终止 + const hasErrors = step.toolResults?.some((r) => !r.success); + if (hasErrors) { + log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`); + } + } + // === 上下文压缩(基于 token 使用率触发) === // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow; @@ -416,6 +531,8 @@ export class AgentLoopEngine extends EventEmitter { compressedTokens: this.estimateMessagesTokens(compressed), }); } + // 压缩后回到 OBSERVING + await this.transitionTo(AgentLoopState.OBSERVING); } step.completedAt = Date.now(); @@ -455,13 +572,37 @@ export class AgentLoopEngine extends EventEmitter { } } - // 执行工具 - const toolResult = await this.toolRegistry.execute(toolCall, { - sessionId: this.currentSessionId ?? '', - workspacePath: this.workspacePath, - iteration: this.currentIteration, - requestId: this.currentRequestId, - }); + // 执行工具(带超时) + const toolTimeout = 120_000; // 单个工具最大 2 分钟 + let toolResult: MetonaToolResult; + try { + toolResult = await Promise.race([ + this.toolRegistry.execute(toolCall, { + sessionId: this.currentSessionId ?? '', + workspacePath: this.workspacePath, + iteration: this.currentIteration, + requestId: this.currentRequestId, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout), + ), + ]); + } catch (err) { + toolResult = { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + success: false, + error: (err as Error).message, + durationMs: 0, + timestamp: Date.now(), + }; + // 仍执行 post-hook + for (const hook of this.postToolHooks) { + await hook.afterExecute(toolCall, toolResult, this.currentSessionId); + } + return toolResult; + } // 后置 Hook 管道 for (const hook of this.postToolHooks) { @@ -472,26 +613,94 @@ export class AgentLoopEngine extends EventEmitter { } /** - * 带重试的流式调用 + * 带重试的流式调用(v0.2.0: 指数退避) * - * 如果 adapter 抛出错误,在 retryCount 次数内重试,每次等待 1 秒。 + * 如果 adapter 抛出错误,在 retryCount 次数内重试。 + * v0.2.0: 使用指数退避替代固定 1 秒等待 + * 等待时间 = baseDelay * 2^attempt(1s, 2s, 4s, 8s...) + * 上限 30 秒,加上 ±20% 随机抖动(jitter)避免惊群效应 */ private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable { let lastError: unknown; + const baseDelayMs = 1_000; + const maxDelayMs = 30_000; + for (let attempt = 0; attempt <= this.config.retryCount; attempt++) { try { + // 首次尝试直接 yield + if (attempt === 0) { + yield* this.adapter.chatStream(request); + return; + } + // 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta + yield { + type: MetonaStreamEventType.ERROR, + requestId: request.meta.requestId, + sessionId: request.meta.sessionId, + iteration: request.meta.iteration, + seq: 0, + timestamp: Date.now(), + error: { + code: 'RETRY' as never, + message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`, + retryable: true, + }, + } as MetonaStreamEvent; yield* this.adapter.chatStream(request); return; } catch (error) { lastError = error; + if (this.aborted) throw error; if (attempt < this.config.retryCount) { - await new Promise((resolve) => setTimeout(resolve, 1000)); + // 检查是否为可重试错误 + if (!this.isRetryableError(error)) throw error; + // 指数退避 + 抖动 + const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt)); + const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter + 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); + // 支持 abort 中断等待 + const signal = this.abortController?.signal; + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + reject(new Error('Aborted')); + return; + } + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }, { once: true }); + } + }); } } } throw lastError; } + /** 判断错误是否可重试 */ + private isRetryableError(error: unknown): boolean { + const err = error as { status?: number; code?: string; message?: string }; + // 429 Too Many Requests — 可重试 + if (err.status === 429) return true; + // 5xx 服务器错误 — 可重试 + if (err.status && err.status >= 500 && err.status < 600) return true; + // 网络超时/连接错误 — 可重试 + if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true; + // SSE 流中断 — 可重试 + if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true; + // 其他错误(400/401/403/4xx)不重试 + return false; + } + + /** 生成下一个事件序列号 */ + private nextSeq(): number { + return ++this.eventSeq; + } + private async transitionTo(state: AgentLoopState): Promise { const previous = this.currentState; this.currentState = state; @@ -501,6 +710,7 @@ export class AgentLoopEngine extends EventEmitter { state, sessionId: this.currentSessionId, iteration: this.currentIteration, + runId: this.runId, }); } @@ -610,11 +820,22 @@ export class AgentLoopEngine extends EventEmitter { requestId: this.currentRequestId, sessionId: this.currentSessionId, iteration: this.currentIteration, - seq: 0, + seq: this.nextSeq(), timestamp: Date.now(), + runId: this.runId, }); + // H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI) + const prevTerminated = this.currentState; this.currentState = AgentLoopState.TERMINATED; + this.emit('stateChange', { + previous: prevTerminated, + current: AgentLoopState.TERMINATED, + state: AgentLoopState.TERMINATED, + sessionId: this.currentSessionId, + iteration: this.currentIteration, + runId: this.runId, + }); // 发射 complete 事件(供托盘通知等外部监听器使用) this.emit('complete', { diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts new file mode 100644 index 0000000..7df4caf --- /dev/null +++ b/electron/harness/hooks/confirmation-hook.ts @@ -0,0 +1,222 @@ +/** + * Confirmation Hook — 工具执行前用户确认钩子(v0.2.0 新增) + * + * 对 requiresPermission=true 或 riskLevel >= HIGH 的工具, + * 通过 IPC 请求用户确认后再执行。 + * + * 支持两种自动执行机制: + * 1. 持久化自动执行(跨会话):存储在 ConfigService `tools.{toolName}.autoExecute` + * 2. 会话内记忆:同一会话内不再重复询问(rememberDecisions) + * + * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章 + */ + +import { BrowserWindow } from 'electron'; +import type { MetonaToolCall, MetonaToolDef } from '../types'; +import type { PreToolHook, HookResult } from './pre-tool'; +import type { ConfigService } from '../../services/config.service'; + +export interface ConfirmationRequest { + toolCallId: string; + toolName: string; + args: Record; + riskLevel: string; + reason: string; +} + +export class ConfirmationHook implements PreToolHook { + /** 需要确认的风险等级 */ + private static REQUIRES_CONFIRMATION = ['high', 'critical']; + + /** 工具定义缓存(由外部设置) */ + private toolDefs = new Map(); + + /** 用户选择记忆(同一会话内不再重复询问) */ + private rememberedDecisions = new Map(); + + /** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */ + private autoExecuteTools = new Set(); + + /** 等待确认的 Promise 解析器 */ + private pendingConfirmations = new Map void; timer: NodeJS.Timeout; toolName: string }>(); + + /** 确认超时时间(默认 120 秒) */ + private readonly confirmationTimeoutMs = 120_000; + + constructor( + private mainWindow: BrowserWindow | null = null, + private configService: ConfigService | null = null, + ) { + this.loadAutoExecuteList(); + } + + /** 设置主窗口(用于发送 IPC 消息) */ + setMainWindow(window: BrowserWindow): void { + this.mainWindow = window; + } + + /** + * 从 ConfigService 加载已设置为自动执行的工具列表 + * 配置键格式:tools.{toolName}.autoExecute = true + */ + private loadAutoExecuteList(): void { + if (!this.configService) return; + try { + const all = this.configService.getAll(); + this.autoExecuteTools.clear(); + for (const [key, value] of Object.entries(all)) { + // 匹配 tools.{toolName}.autoExecute 键 + const match = /^tools\.(.+)\.autoExecute$/.exec(key); + if (match && value === true) { + this.autoExecuteTools.add(match[1]); + } + } + } catch { + // 配置加载失败,忽略(不阻塞启动) + } + } + + /** + * 设置/取消工具的持久化自动执行 + * @param toolName 工具名 + * @param enabled true=自动执行(不再询问),false=每次询问 + */ + setAutoExecute(toolName: string, enabled: boolean): void { + const configKey = `tools.${toolName}.autoExecute`; + if (this.configService) { + this.configService.set(configKey, enabled); + } + if (enabled) { + this.autoExecuteTools.add(toolName); + // 自动执行时同步清除会话内"拒绝"记忆(避免冲突) + this.rememberedDecisions.delete(toolName); + } else { + this.autoExecuteTools.delete(toolName); + } + } + + /** + * 获取当前自动执行的工具列表 + */ + getAutoExecuteList(): string[] { + return Array.from(this.autoExecuteTools); + } + + /** 注入工具定义列表(用于查询风险等级) */ + setToolDefs(defs: MetonaToolDef[]): void { + this.toolDefs.clear(); + for (const def of defs) { + this.toolDefs.set(def.name, def); + } + } + + /** + * 处理用户确认响应(由 IPC handler 调用) + * @param remember 会话内记住决定 + * @param autoExecute 永久自动执行(持久化) + */ + resolveConfirmation(toolCallId: string, approved: boolean, remember: boolean, autoExecute: boolean = false): void { + const pending = this.pendingConfirmations.get(toolCallId); + if (pending) { + clearTimeout(pending.timer); + pending.resolve(approved); + // 永久自动执行(仅当用户批准时才设置) + if (autoExecute && approved) { + this.setAutoExecute(pending.toolName, true); + } + // 会话内记忆 + if (remember) { + this.rememberedDecisions.set(pending.toolName, approved); + } + this.pendingConfirmations.delete(toolCallId); + } + } + + async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise { + const def = this.toolDefs.get(toolCall.name); + if (!def) { + // 未知工具,放行(由 ToolRegistry 处理未知工具错误) + return { blocked: false }; + } + + // 检查是否需要确认 + const needsConfirmation = def.requiresPermission || + ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel); + + if (!needsConfirmation) { + return { blocked: false }; + } + + // 优先检查持久化自动执行(跨会话生效) + if (this.autoExecuteTools.has(toolCall.name)) { + return { blocked: false }; + } + + // 检查是否有记住的决策 + const remembered = this.rememberedDecisions.get(toolCall.name); + if (remembered !== undefined) { + if (remembered) return { blocked: false }; + return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` }; + } + + // 如果没有主窗口,安全起见阻止执行 + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return { blocked: true, reason: 'Cannot request confirmation: no main window available' }; + } + + // 发送确认请求到渲染进程 + const request: ConfirmationRequest = { + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.args, + riskLevel: def.riskLevel, + reason: def.requiresPermission + ? `Tool "${toolCall.name}" requires permission (risk: ${def.riskLevel})` + : `Tool "${toolCall.name}" has high risk level: ${def.riskLevel}`, + }; + + // 等待用户响应(带超时) + const approved = await this.waitForConfirmation(request); + + if (!approved) { + return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` }; + } + + return { blocked: false }; + } + + /** + * 等待用户确认(带超时) + */ + private waitForConfirmation(request: ConfirmationRequest): Promise { + return new Promise((resolve) => { + // 设置超时 + const timer = setTimeout(() => { + this.pendingConfirmations.delete(request.toolCallId); + resolve(false); // 超时视为拒绝 + }, this.confirmationTimeoutMs); + + this.pendingConfirmations.set(request.toolCallId, { + resolve, + timer, + toolName: request.toolName, + }); + + // 发送确认请求到渲染进程 + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('tool:confirmationRequest', request); + } + }); + } + + /** + * 清理所有等待中的确认(会话结束时调用) + */ + clearPending(): void { + for (const [, pending] of this.pendingConfirmations) { + clearTimeout(pending.timer); + pending.resolve(false); + } + this.pendingConfirmations.clear(); + } +} diff --git a/electron/harness/hooks/post-tool.ts b/electron/harness/hooks/post-tool.ts index 177927f..88782f2 100644 --- a/electron/harness/hooks/post-tool.ts +++ b/electron/harness/hooks/post-tool.ts @@ -9,6 +9,7 @@ import type { MetonaToolCall, MetonaToolResult } from '../types'; import type { AuditService } from '../../services/audit.service'; import type { MemoryManager } from '../memory/manager'; +import log from 'electron-log'; export interface PostToolHook { afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise; @@ -55,8 +56,7 @@ export class MemoryTriggerHook implements PostToolHook { }); } catch (error) { // 记忆存储失败不应影响工具执行结果 - // eslint-disable-next-line no-console - console.error('[MemoryTriggerHook] Failed to store episodic memory:', error); + log.error('[MemoryTriggerHook] Failed to store episodic memory:', error); } } } diff --git a/electron/harness/hooks/pre-tool.ts b/electron/harness/hooks/pre-tool.ts index 9a9eea5..a7a4f0d 100644 --- a/electron/harness/hooks/pre-tool.ts +++ b/electron/harness/hooks/pre-tool.ts @@ -36,8 +36,9 @@ export class RateLimitHook implements PreToolHook { constructor(private maxCallsPerMinute: number = 20) {} - async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise { - const key = toolCall.name; + async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise { + // 使用 sessionId:toolName 作为 key,实现会话隔离的 per-tool 速率限制 + const key = `${sessionId}:${toolCall.name}`; const now = Date.now(); const entry = this.callCounts.get(key); if (entry && entry.resetTime >= now) { diff --git a/electron/harness/memory/consolidator.ts b/electron/harness/memory/consolidator.ts new file mode 100644 index 0000000..aad11bb --- /dev/null +++ b/electron/harness/memory/consolidator.ts @@ -0,0 +1,277 @@ +/** + * Memory Consolidator — 会话结束记忆固化器 + * + * 每次 Agent 完成一轮对话后,调用 LLM 判断本次对话中哪些内容 + * 值得持久化到工作空间根目录的 MEMORY.md。 + * + * 工作流程: + * 1. 收集本次对话的 user message + assistant final answer + tool calls 摘要 + * 2. 读取当前 MEMORY.md 内容作为参考(避免重复) + * 3. 调用 LLM,要求其返回 JSON 数组 [{section, entry}] + * 4. 解析响应,调用 WorkspaceService.appendMemory 追加 + * + * 安全约束: + * - 仅追加新条目,不修改已有内容 + * - section 限定为预定义的 5 个分区 + * - 单次最多追加 5 条记忆,避免 LLM 过度提取 + * - LLM 调用失败时静默降级(不影响主流程) + * + * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章 + */ + +import { nanoid } from 'nanoid'; +import log from 'electron-log'; +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'; + +/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ +const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; +type AllowedSection = typeof ALLOWED_SECTIONS[number]; + +/** 单次固化最多追加的条目数 */ +const MAX_ENTRIES_PER_CONSOLIDATION = 5; + +/** 单条记忆最大长度 */ +const MAX_ENTRY_LENGTH = 500; + +export interface ConsolidationResult { + appended: number; + entries: Array<{ section: string; entry: string }>; + skipped: number; +} + +export class MemoryConsolidator { + constructor( + private adapter: IMetonaProviderAdapter, + private workspaceService: WorkspaceService, + ) {} + + /** + * 热切换 Adapter(配置变更时由 main.ts 调用) + */ + setAdapter(adapter: IMetonaProviderAdapter): void { + this.adapter = adapter; + } + + /** + * 固化本次对话的重要记忆到 MEMORY.md + * + * @param userMessage 用户原始消息 + * @param assistantAnswer Agent 最终回答 + * @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要) + * @returns 固化结果 + */ + async consolidate( + userMessage: string, + assistantAnswer: string, + iterations: IterationStep[], + ): Promise { + try { + // 1. 构建对话摘要 + const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations); + if (!conversationDigest) { + return { appended: 0, entries: [], skipped: 0 }; + } + + // 2. 读取当前 MEMORY.md 内容(供 LLM 去重) + const currentMemory = this.workspaceService.getFiles().memory; + const memoryDigest = this.truncateMemoryForPrompt(currentMemory); + + // 3. 调用 LLM 提取需要持久化的记忆 + const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest); + if (!llmResponse) { + return { appended: 0, entries: [], skipped: 0 }; + } + + // 4. 解析 JSON 响应 + const extracted = this.parseExtractionResponse(llmResponse); + if (extracted.length === 0) { + log.debug('[MemoryConsolidator] No memories worth persisting'); + return { appended: 0, entries: [], skipped: 0 }; + } + + // 5. 过滤 + 截断 + 追加到 MEMORY.md + const validEntries: Array<{ section: string; entry: string }> = []; + let skipped = 0; + + for (const item of extracted.slice(0, MAX_ENTRIES_PER_CONSOLIDATION)) { + if (!this.isValidEntry(item)) { + skipped++; + continue; + } + const truncatedEntry = item.entry.slice(0, MAX_ENTRY_LENGTH); + try { + this.workspaceService.appendMemory(item.section, truncatedEntry); + validEntries.push({ section: item.section, entry: truncatedEntry }); + } catch (err) { + log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err); + skipped++; + } + } + + if (validEntries.length > 0) { + log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`); + } + + return { appended: validEntries.length, entries: validEntries, skipped }; + } catch (error) { + log.error('[MemoryConsolidator] Consolidation failed:', error); + return { appended: 0, entries: [], skipped: 0 }; + } + } + + /** + * 构建对话摘要(供 LLM 分析) + */ + private buildConversationDigest( + userMessage: string, + assistantAnswer: string, + iterations: IterationStep[], + ): string { + const parts: string[] = []; + + // 用户消息 + parts.push(`[USER] ${this.truncate(userMessage, 2000)}`); + + // 工具调用摘要(如果有) + const toolSummaries: string[] = []; + for (const iter of iterations) { + if (!iter.toolCalls?.length) continue; + for (let i = 0; i < iter.toolCalls.length; i++) { + const tc = iter.toolCalls[i]; + const result = iter.toolResults?.find((r) => r.toolCallId === tc.id); + const status = result?.success ? 'ok' : 'error'; + const resultPreview = result?.result + ? this.truncate(JSON.stringify(result.result), 200) + : result?.error ?? ''; + toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`); + } + } + if (toolSummaries.length > 0) { + parts.push(`[TOOLS]\n${toolSummaries.join('\n')}`); + } + + // Agent 最终回答 + parts.push(`[ASSISTANT] ${this.truncate(assistantAnswer, 2000)}`); + + return parts.join('\n\n'); + } + + /** + * 截断 MEMORY.md 内容用于 prompt(避免过长) + */ + private truncateMemoryForPrompt(memory: string): string { + if (!memory) return '(empty)'; + // 截取前 3000 字符,保留分区结构概览 + if (memory.length <= 3000) return memory; + return memory.slice(0, 3000) + '\n... (truncated)'; + } + + /** + * 调用 LLM 提取需要持久化的记忆 + */ + private async callLLMForExtraction( + conversationDigest: string, + currentMemory: string, + ): Promise { + const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', '); + + const request: MetonaRequest = { + meta: { + sessionId: 'memory-consolidation', + iteration: 0, + requestId: `mc_${nanoid(12)}`, + timestamp: Date.now(), + agentVersion: '1.0.0', + }, + systemPrompt: { + roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.', + outputConstraints: [ + 'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:', + '1. Long-term value: will be useful in future conversations (not transient task state)', + '2. Not already present in the current MEMORY.md (avoid duplicates)', + '3. Concrete and actionable (not vague observations)', + '', + 'Good candidates: user preferences, project facts, important decisions, pending todos, known issues.', + 'Bad candidates: temporary tool results, trivial conversation, already-known facts.', + '', + `Output a JSON array. Each element: {"section": one of ${sectionsList}, "entry": concise description (max 200 chars, in the conversation language)}`, + 'If nothing is worth persisting, output an empty array: []', + 'Output ONLY the JSON array, no markdown fences, no explanation.', + ].join('\n'), + safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.', + }, + messages: [{ + role: 'user', + content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`, + timestamp: Date.now(), + }], + params: { + maxTokens: 1024, + temperature: 0.0, + stream: false, + thinkingEnabled: false, + thinkingEffort: 'low', + }, + }; + + try { + const response = await this.adapter.chat(request); + return response.content.trim(); + } catch (error) { + log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message); + return null; + } + } + + /** + * 解析 LLM 的提取响应 + */ + private parseExtractionResponse(raw: string): Array<{ section: string; entry: string }> { + if (!raw) return []; + + // 尝试直接解析 JSON + let cleaned = raw.trim(); + + // 移除可能的 markdown 代码围栏 + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); + } + + try { + const parsed = JSON.parse(cleaned); + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((item): item is { section: string; entry: string } => + typeof item === 'object' && item !== null && + typeof item.section === 'string' && typeof item.entry === 'string', + ) + .map((item) => ({ + section: item.section.trim(), + entry: item.entry.trim(), + })) + .filter((item) => item.section && item.entry); + } catch { + log.warn('[MemoryConsolidator] Failed to parse LLM response as JSON:', cleaned.slice(0, 200)); + return []; + } + } + + /** + * 校验条目是否合法(section 在允许列表内) + */ + private isValidEntry(item: { section: string; entry: string }): boolean { + return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0; + } + + /** + * 截断字符串 + */ + private truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + '...'; + } +} diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 76b0ab3..0398073 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -4,6 +4,11 @@ * 基于 SQLite(better-sqlite3)的三层记忆系统。 * 表结构由 DatabaseService 统一创建,此处不再重复。 * + * v0.2.0 增强: + * - TF-IDF 语义检索替代 LIKE 关键词搜索 + * - 时间衰减策略:老旧记忆权重降低 + * - IDF 缓存:避免每次搜索重新计算 + * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章 * @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层) */ @@ -38,17 +43,258 @@ interface MemorySearchOptions { minImportance?: number; } +/** 分词:将文本拆分为词项(支持中英文) */ +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) ?? []; + 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]); + } + return [...words, ...bigrams]; +} + +/** 计算词频(TF) */ +function computeTF(tokens: string[]): Map { + const tf = new Map(); + for (const token of tokens) { + tf.set(token, (tf.get(token) ?? 0) + 1); + } + // 归一化 + const total = tokens.length || 1; + for (const [key, val] of tf) { + tf.set(key, val / total); + } + return tf; +} + +/** 计算余弦相似度的点积部分 */ +function dotProduct(tf1: Map, tf2: Map, idf: Map): number { + let sum = 0; + for (const [term, freq1] of tf1) { + const freq2 = tf2.get(term); + if (freq2 !== undefined) { + const idfVal = idf.get(term) ?? 1; + sum += freq1 * freq2 * idfVal * idfVal; + } + } + return sum; +} + +/** 计算向量模长 */ +function vectorNorm(tf: Map, idf: Map): number { + let sum = 0; + for (const [term, freq] of tf) { + const idfVal = idf.get(term) ?? 1; + sum += (freq * idfVal) ** 2; + } + return Math.sqrt(sum); +} + +/** 时间衰减权重:30 天半衰期(age=30 时 weight=0.5) */ +function timeDecayWeight(createdAt: number, now: number = Date.now()): number { + const ageDays = Math.max(0, (now - createdAt) / (24 * 60 * 60 * 1000)); + const halfLifeDays = 30; + return Math.pow(0.5, ageDays / halfLifeDays); +} + /** * 记忆管理器 */ export class MemoryManager { + /** IDF 缓存:词项 -> 文档频率 */ + private idfCache = new Map(); + /** 缓存的记忆总数 */ + private cachedDocCount = 0; + /** 缓存最后更新时间 */ + private cacheUpdatedAt = 0; + /** 缓存有效期(5 分钟) */ + private readonly CACHE_TTL = 5 * 60 * 1000; + constructor(private getDB: () => Database.Database) {} /** * 初始化(表结构由 DatabaseService 创建) */ initialize(): void { - log.info('MemoryManager initialized'); + log.info('MemoryManager initialized (v0.2.0: TF-IDF enabled)'); + } + + /** + * 更新 IDF 缓存 + */ + private updateIdfCache(): void { + const now = Date.now(); + if (now - this.cacheUpdatedAt < this.CACHE_TTL && this.cachedDocCount > 0) { + return; // 缓存未过期 + } + + 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 }>; + + const allDocs = [ + ...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')), + ...semanticRows.map((r) => r.value), + ...workingRows.map((r) => r.value), + ]; + + this.cachedDocCount = 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); + } + + this.cacheUpdatedAt = now; + } + + /** + * TF-IDF 相似度搜索 + */ + private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] { + const db = this.getDB(); + this.updateIdfCache(); + + const queryTokens = tokenize(query); + if (queryTokens.length === 0) return []; + + const queryTF = computeTF(queryTokens); + const queryNorm = vectorNorm(queryTF, this.idfCache); + if (queryNorm === 0) return []; + + const { topK = 5, type, minImportance = 0 } = options; + const results: SearchResult[] = []; + const now = Date.now(); + + // 搜索 episodic 记忆 + if (!type || type === 'episodic') { + const rows = db.prepare(` + SELECT * FROM episodic_memories WHERE importance >= ? + ORDER BY importance DESC, created_at DESC LIMIT ? + `).all(minImportance, topK * 3) as Array<{ + id: string; session_id: string | null; content: string; summary: string | null; + source: string; importance: number; created_at: number; expires_at: number | null; + }>; + + for (const row of rows) { + const docText = row.content + ' ' + (row.summary ?? ''); + const docTokens = tokenize(docText); + const docTF = computeTF(docTokens); + const docNorm = vectorNorm(docTF, this.idfCache); + + if (docNorm === 0) continue; + + const dotProd = dotProduct(queryTF, docTF, this.idfCache); + const cosineSim = dotProd / (queryNorm * docNorm); + + // 时间衰减 + const decayWeight = timeDecayWeight(row.created_at, now); + // 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重 + const finalScore = cosineSim * decayWeight * (0.5 + row.importance * 0.5); + + if (finalScore > 0) { + results.push({ + id: row.id, type: 'episodic', content: row.content, + summary: row.summary ?? undefined, source: row.source as MemorySource, + importance: row.importance, sessionId: row.session_id ?? undefined, + createdAt: row.created_at, expiresAt: row.expires_at ?? undefined, + score: finalScore, + }); + } + } + } + + // 搜索 semantic 记忆 + if (!type || type === 'semantic') { + const rows = db.prepare(` + SELECT * FROM semantic_memories WHERE confidence >= ? + ORDER BY confidence DESC, access_count DESC LIMIT ? + `).all(minImportance, Math.ceil(topK * 1.5)) as Array<{ + id: string; key: string; value: string; category: string | null; + confidence: number; source_session: string | null; created_at: number; + }>; + + for (const row of rows) { + const docText = row.key + ' ' + row.value; + const docTokens = tokenize(docText); + const docTF = computeTF(docTokens); + const docNorm = vectorNorm(docTF, this.idfCache); + + if (docNorm === 0) continue; + + const dotProd = dotProduct(queryTF, docTF, this.idfCache); + const cosineSim = dotProd / (queryNorm * docNorm); + + const decayWeight = timeDecayWeight(row.created_at, now); + const finalScore = cosineSim * decayWeight * (0.5 + row.confidence * 0.5); + + if (finalScore > 0) { + results.push({ + id: row.id, type: 'semantic', content: row.value, + source: 'imported', importance: row.confidence, + sessionId: row.source_session ?? undefined, + createdAt: row.created_at, score: finalScore, + }); + } + } + } + + // 搜索 working 记忆 + if (!type || type === 'working') { + const rows = db.prepare(` + SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ? + `).all(topK * 3) as Array<{ + id: string; session_id: string; task_id: string; + key: string; value: string; updated_at: number; + }>; + + for (const row of rows) { + const docText = row.key + ' ' + row.value; + const docTokens = tokenize(docText); + const docTF = computeTF(docTokens); + const docNorm = vectorNorm(docTF, this.idfCache); + + if (docNorm === 0) continue; + + const dotProd = dotProduct(queryTF, docTF, this.idfCache); + const cosineSim = dotProd / (queryNorm * docNorm); + + const decayWeight = timeDecayWeight(row.updated_at, now); + const finalScore = cosineSim * decayWeight * 0.5; + + if (finalScore > 0) { + results.push({ + id: row.id, type: 'working', content: row.value, + source: 'agent_thought', importance: 0.5, + sessionId: row.session_id, createdAt: row.updated_at, + score: finalScore, + }); + } + } + } + + return results.sort((a, b) => b.score - a.score).slice(0, topK); } /** @@ -81,26 +327,44 @@ export class MemoryManager { break; } + // 使 IDF 缓存失效 + this.cacheUpdatedAt = 0; + log.debug(`Memory stored: ${id} (${item.type})`); return id; } /** - * 检索记忆(关键词搜索) + * 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减) + * + * v0.2.0 变更: + * - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索 + * - 支持中英文分词(英文按词,中文按 bigram) + * - 时间衰减:30 天半衰期,老旧记忆权重降低 + * - IDF 缓存:5 分钟有效期,避免重复计算 */ search(query: string, options: MemorySearchOptions = {}): SearchResult[] { const db = this.getDB(); const { topK = 5, type, minImportance = 0 } = options; if (!query) return []; - const pattern = `%${query}%`; + // v0.2.0: 优先使用 TF-IDF 语义搜索 + const tfidfResults = this.tfidfSearch(query, options); + if (tfidfResults.length > 0) { + return tfidfResults; + } + + // 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索 + // 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配 + const escapedQuery = query.replace(/[%_]/g, '\\$&'); + const pattern = `%${escapedQuery}%`; const results: SearchResult[] = []; // 搜索情节记忆 if (!type || type === 'episodic') { const rows = db.prepare(` SELECT * FROM episodic_memories - WHERE (content LIKE ? OR summary LIKE ?) AND importance >= ? + WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ? ORDER BY importance DESC, created_at DESC LIMIT ? `).all(pattern, pattern, minImportance, topK) as Array<{ id: string; session_id: string | null; content: string; summary: string | null; @@ -112,7 +376,7 @@ export class MemoryManager { summary: row.summary ?? undefined, source: row.source as MemorySource, importance: row.importance, sessionId: row.session_id ?? undefined, createdAt: row.created_at, expiresAt: row.expires_at ?? undefined, - score: row.importance, + score: row.importance * timeDecayWeight(row.created_at), }); } } @@ -121,7 +385,7 @@ export class MemoryManager { if (!type || type === 'semantic') { const rows = db.prepare(` SELECT * FROM semantic_memories - WHERE (key LIKE ? OR value LIKE ?) AND confidence >= ? + WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ? ORDER BY confidence DESC, access_count DESC LIMIT ? `).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{ id: string; key: string; value: string; category: string | null; @@ -132,7 +396,7 @@ export class MemoryManager { id: row.id, type: 'semantic', content: row.value, source: 'imported', importance: row.confidence, sessionId: row.source_session ?? undefined, - createdAt: row.created_at, score: row.confidence, + createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at), }); } } @@ -141,7 +405,7 @@ export class MemoryManager { if (type === 'working') { const rows = db.prepare(` SELECT * FROM working_memories - WHERE (key LIKE ? OR value LIKE ?) + WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') ORDER BY updated_at DESC LIMIT ? `).all(pattern, pattern, topK) as Array<{ id: string; session_id: string; task_id: string; @@ -152,7 +416,7 @@ export class MemoryManager { id: row.id, type: 'working', content: row.value, source: 'agent_thought', importance: 0.5, sessionId: row.session_id, createdAt: row.updated_at, - score: 0.3, // 工作记忆权重较低 + score: 0.3 * timeDecayWeight(row.updated_at), }); } } diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 2f187f7..200eaea 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -65,13 +65,33 @@ export class PolicyEngine { }; } + const argsStr = JSON.stringify(args); + + // v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一 + if (policy.allowedPatterns && policy.allowedPatterns.length > 0) { + let matchedAllowed = false; + for (const pattern of policy.allowedPatterns) { + if (pattern.test(argsStr)) { + matchedAllowed = true; + break; + } + } + if (!matchedAllowed) { + return { + authorized: false, + reason: 'Arguments do not match any allowed pattern', + level: policy.requiredLevel, + requiresConfirmation: policy.requireConfirmation ?? false, + }; + } + } + if (policy.deniedPatterns) { - const argsStr = JSON.stringify(args); for (const pattern of policy.deniedPatterns) { if (pattern.test(argsStr)) { return { authorized: false, - reason: `Arguments match denied pattern: ${pattern.source}`, + reason: 'Command blocked by security policy', level: policy.requiredLevel, requiresConfirmation: false, }; diff --git a/electron/harness/sandbox/sandbox.ts b/electron/harness/sandbox/sandbox.ts index 8793ccd..fd57426 100644 --- a/electron/harness/sandbox/sandbox.ts +++ b/electron/harness/sandbox/sandbox.ts @@ -7,6 +7,7 @@ */ import { resolve, sep } from 'path'; +import { existsSync, realpathSync } from 'fs'; export interface SandboxConfig { allowedPaths?: string[]; @@ -49,6 +50,7 @@ export class SandboxManager { * 校验文件路径是否在白名单内 * * 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。 + * 同时解析符号链接,防止通过 symlink 逃逸白名单。 */ validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } { const resolved = resolve(requestedPath); @@ -57,6 +59,7 @@ export class SandboxManager { return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' }; } + // 先做字符串级白名单校验 const isAllowed = Array.from(this.allowedPaths).some( (allowed) => resolved === allowed || resolved.startsWith(allowed + sep), ); @@ -64,28 +67,79 @@ export class SandboxManager { return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' }; } + // 解析符号链接(如果路径存在) + if (existsSync(resolved)) { + try { + const realPath = realpathSync(resolved); + const realAllowed = Array.from(this.allowedPaths).some( + (allowed) => realPath === allowed || realPath.startsWith(allowed + sep), + ); + if (!realAllowed) { + return { allowed: false, resolvedPath: realPath, reason: 'Symlink escape detected' }; + } + return { allowed: true, resolvedPath: realPath }; + } catch { + // realpath 解析失败(权限问题等),保守拒绝 + return { allowed: false, resolvedPath: resolved, reason: 'Path resolution failed' }; + } + } + return { allowed: true, resolvedPath: resolved }; } /** * 静态代码安全扫描 + * + * 检测危险模块导入、代码执行、路径遍历、危险命令、反向 shell、 + * fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。 */ scanCode(code: string): { safe: boolean; reason?: string } { const dangerousPatterns = [ - /require\s*\(\s*['"]child_process['"]\s*\)/, - /eval\s*\(/, - /process\.exit/, - /import\s+.*from\s+['"]fs['"]/, - /\.\.\//, - /rm\s+-rf/, - />\s*\/dev\/null/, - /curl.*\|\s*bash/, - /wget.*\|\s*sh/, + // 危险模块导入 + /require\s*\(\s*['"]child_process['"]\s*\)/i, + /import\s+.*from\s+['"]fs['"]/i, + /import\s+.*from\s+['"]child_process['"]/i, + // 代码执行 + /\beval\s*\(/i, + /process\.exit/i, + /Function\s*\(/i, + // 路径遍历 + /\.\.\//i, + /\\\.\.\\/i, // Windows ..\ + // 危险命令 + /\brm\s+-rf\b/i, + /\bkillall\s+-9\b/i, + /\bchown\s+-R\s+\//i, + // 重定向到系统目录 + />\s*\/dev\/null/i, + />\s*\/etc\//i, + // 管道执行 + /\bcurl\b.*\|\s*(bash|sh|zsh)\b/i, + /\bwget\b.*\|\s*(sh|bash|zsh)\b/i, + // 反向 shell + /\/bin\/(bash|sh)\s+-i/i, + /\bnc\s+-e\b/i, + /\bbash\s+-i\b/i, + // Fork bomb + /:\(\)\s*\{\s*:\|:\s*&\s*\};:/i, + // PowerShell 编码执行 + /powershell.*-enc(odedCommand)?\s+/i, + // 环境变量窃取 + /env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i, + // 编码绕过检测 + /\bbase64\b.*\|\s*(sh|bash|zsh)\b/i, + /\batob\s*\(/i, + /\bprintf\s+['"]\\x[0-9a-f]/i, + // 命令替换 + /\$\([^)]*(rm|kill|del|format|mkfs)\b/i, + // heredoc 执行 + /<<\s*(EOF|END)\s*[\s\S]*?\b(rm|kill|del|format|mkfs)\b/i, ]; for (const pattern of dangerousPatterns) { if (pattern.test(code)) { - return { safe: false, reason: `Dangerous pattern detected: ${pattern.source}` }; + // 不暴露 pattern.source,使用通用错误信息 + return { safe: false, reason: 'Command blocked by security policy' }; } } diff --git a/electron/harness/security/prompt-injection-defense.ts b/electron/harness/security/prompt-injection-defense.ts index 9f3a594..e33693a 100644 --- a/electron/harness/security/prompt-injection-defense.ts +++ b/electron/harness/security/prompt-injection-defense.ts @@ -6,6 +6,13 @@ * 2. 语义级检测(可选) * 3. 指令隔离标记 * + * v0.2.0 增强: + * - 扩展注入检测模式至 30+ 种 + * - 添加多语言检测(中英文) + * - 添加编码注入检测(hex、unicode 转义) + * - 添加角色扮演注入检测 + * - 添加间接注入检测(通过工具返回值注入) + * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章 */ @@ -22,39 +29,77 @@ export interface InjectionFinding { severity: 'low' | 'medium' | 'high'; } +interface InjectionPattern { + pattern: RegExp; + severity: 'low' | 'medium' | 'high'; +} + export class PromptInjectionDefender { - private static INJECTION_PATTERNS = [ - /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i, - /forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i, - /you\s+are\s+now/i, - /new\s+(instructions|directive|role|persona)/i, - /override\s+your\s+/i, - /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, - /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, - /-{3,}\s*(system|user|assistant|instruction)/i, - /<{3,}(system|instruction|prompt)/i, - /\[{3,}(system|instruction|prompt)/i, - /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, - /pretend\s+(you\s+are|to\s+be)/i, - /act\s+as\s+(if\s+you\s+were|you're)/i, - /DAN\s*[:\[]/i, - /JAILBREAK/i, + 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' }, + { 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: /(?: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: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' }, + { pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' }, + { pattern: /(?:黑客|越狱|破解)(?:模式|命令|规则)/i, severity: 'high' }, + { pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' }, + + // === MEDIUM 危险:角色扮演/权限提升/编码注入 === + { pattern: /you\s+are\s+now/i, severity: 'medium' }, + { pattern: /new\s+(instructions|directive|role|persona)/i, severity: 'medium' }, + { pattern: /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, severity: 'medium' }, + { pattern: /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, severity: 'medium' }, + { pattern: /pretend\s+(you\s+are|to\s+be)/i, severity: 'medium' }, + { pattern: /act\s+as\s+(if\s+you\s+were|you're)/i, severity: 'medium' }, + { pattern: /(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)/i, severity: 'medium' }, + { pattern: /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' }, + { pattern: /eval\s*\(\s*atob\s*\(/i, severity: 'medium' }, + { pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' }, + { pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' }, + // 中文中危 + { pattern: /你(?:现在|从现在开始)(?:是|扮演|作为一个)/i, severity: 'medium' }, + { pattern: /新(?:的)?(?:指令|角色|设定|规则)/i, severity: 'medium' }, + { pattern: /(?:显示|打印|输出|告诉我)(?:你的)?(?:系统|内部)(?:提示|指令|设定)/i, severity: 'medium' }, + { pattern: /假装(?:你是|自己是)/i, severity: 'medium' }, + { pattern: /扮演(?:一个|以下)/i, severity: 'medium' }, + { 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' }, + { pattern: /\[SYSTEM\]/i, severity: 'low' }, + { pattern: /\[INSTRUCTION\]/i, severity: 'low' }, + { pattern: /\[ADMIN\]/i, severity: 'low' }, + { pattern: /\[OVERRIDE\]/i, severity: 'low' }, + { pattern: //i, severity: 'low' }, + { pattern: //i, severity: 'low' }, + { pattern: //i, severity: 'low' }, + { pattern: //i, severity: 'low' }, ]; detect(input: string): InjectionDetectionResult { const findings: InjectionFinding[] = []; let riskScore = 0; - for (const pattern of PromptInjectionDefender.INJECTION_PATTERNS) { + for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) { const matches = input.match(pattern); if (matches) { findings.push({ pattern: pattern.source, matched: matches[0], - severity: this.classifySeverity(pattern.source), + severity, }); - riskScore += this.classifySeverity(pattern.source) === 'high' ? 3 : - this.classifySeverity(pattern.source) === 'medium' ? 2 : 1; + riskScore += severity === 'high' ? 5 : severity === 'medium' ? 3 : 1; } } @@ -66,21 +111,9 @@ export class PromptInjectionDefender { }; } - private classifySeverity(pattern: string): 'low' | 'medium' | 'high' { - const highRiskKeywords = ['ignore', 'forget', 'jailbreak', 'DAN', 'override', 'new\\s+instruction']; - const mediumRiskKeywords = ['reveal', 'dump', 'pretend', 'act\\s+as']; - - for (const kw of highRiskKeywords) { - if (new RegExp(kw, 'i').test(pattern)) return 'high'; - } - for (const kw of mediumRiskKeywords) { - if (new RegExp(kw, 'i').test(pattern)) return 'medium'; - } - return 'low'; - } - /** * 清理输入内容(移除明显的注入分隔符) + * v0.2.0: 增加中文注入标记清理 */ sanitize(input: string): string { let cleaned = input; @@ -90,6 +123,15 @@ export class PromptInjectionDefender { cleaned = cleaned.replace(/<{3,}(system|instruction|prompt).*$/gim, '[REMOVED]'); cleaned = cleaned.replace(/\[{3,}(system|instruction|prompt).*$/gim, '[REMOVED]'); + // v0.2.0: 移除间接注入标记 + cleaned = cleaned.replace(/\[(SYSTEM|INSTRUCTION|ADMIN|OVERRIDE)\]/gi, '[REMOVED]'); + cleaned = cleaned.replace(/<(system|instruction|admin|override)>/gi, '[REMOVED]'); + + // v0.2.0: 移除编码注入 + cleaned = cleaned.replace(/\\x[0-9a-f]{2}/gi, '[REMOVED]'); + cleaned = cleaned.replace(/\\u[0-9a-f]{4}/gi, '[REMOVED]'); + cleaned = cleaned.replace(/eval\s*\(\s*atob\s*\([^)]*\)\s*\)/gi, '[REMOVED]'); + return cleaned.trim(); } diff --git a/electron/harness/tools/built-in/code-search.ts b/electron/harness/tools/built-in/code-search.ts new file mode 100644 index 0000000..9ab944b --- /dev/null +++ b/electron/harness/tools/built-in/code-search.ts @@ -0,0 +1,228 @@ +/** + * 代码搜索工具(1 个) + * + * code_search — 基于 ripgrep 的高速代码搜索 + * + * 相比 search_files 的纯 JS 实现,code_search 调用 ripgrep 子进程, + * 性能提升 10-100 倍,支持正则、文件类型过滤、上下文行展示。 + * 适合大型代码库的精准搜索。 + * + * @see standard/开发规范.md — 优先使用第三方成熟库 + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { resolve } from 'path'; +import log from 'electron-log'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; +import { isPathWithinWorkspace } from './file-guard'; + +const execFileAsync = promisify(execFile); + +export class CodeSearchTool implements IMetonaTool { + /** ripgrep 可用性缓存(实例级,便于测试重置) */ + private rgAvailable: boolean | null = null; + + /** 检测系统是否安装了 ripgrep */ + private async checkRipgrep(): Promise { + if (this.rgAvailable !== null) return this.rgAvailable; + try { + await execFileAsync('rg', ['--version'], { timeout: 3_000 }); + this.rgAvailable = true; + } catch { + this.rgAvailable = false; + } + return this.rgAvailable; + } + + readonly definition: MetonaToolDef = { + name: 'code_search', + description: 'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Regex pattern to search for' }, + path: { type: 'string', description: 'Search directory (default: workspace root)' }, + file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' }, + case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' }, + context_before: { type: 'number', description: 'Lines of context before match (default 0, max 5)' }, + context_after: { type: 'number', description: 'Lines of context after match (default 0, max 5)' }, + max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' }, + }, + required: ['pattern'], + }, + category: MetonaToolCategory.SEARCH, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 30_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + const pattern = args.pattern as string; + if (typeof pattern !== 'string' || !pattern) { + return { results: [], count: 0, error: 'Pattern is required and must be a string' }; + } + if (pattern.length > 500) { + return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; + } + + const searchPath = args.path + ? this.resolveSearchPath(args.path as string, context.workspacePath) + : context.workspacePath; + const fileGlob = args.file_glob as string | undefined; + const caseSensitive = (args.case_sensitive as boolean) ?? false; + const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); + const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); + const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); + + const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; + + // 优先使用 ripgrep,回退到 JS 实现 + const hasRg = await this.checkRipgrep(); + if (hasRg) { + return this.searchWithRipgrep(pattern, searchPath, opts, context); + } + return this.searchWithJs(pattern, searchPath, opts, context); + } + + /** 使用 ripgrep 子进程搜索 */ + private async searchWithRipgrep( + pattern: string, + searchPath: string, + opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, + context: ToolExecutionContext, + ): Promise { + const rgArgs: string[] = ['--json']; + + if (!opts.caseSensitive) rgArgs.push('-i'); + if (opts.contextBefore > 0) rgArgs.push('-B', String(opts.contextBefore)); + if (opts.contextAfter > 0) rgArgs.push('-A', String(opts.contextAfter)); + rgArgs.push('-g', '!MEMORY.md'); + if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob); + + rgArgs.push(pattern, searchPath); + + try { + const { stdout } = await execFileAsync('rg', rgArgs, { + maxBuffer: 10 * 1024 * 1024, + timeout: 25_000, + }); + + const results = this.parseRipgrepJsonOutput(stdout); + return { results: results.slice(0, opts.maxResults), count: results.length, engine: 'ripgrep' }; + } catch (error) { + const err = error as { code?: number; signal?: string; stdout?: string; stderr?: string; killed?: boolean; message?: string }; + // rg 退出码 1 = 无匹配,不是错误 + if (err.code === 1) { + return { results: [], count: 0, engine: 'ripgrep' }; + } + // 超时被 kill + if (err.killed || err.signal === 'SIGTERM') { + return { results: [], count: 0, error: 'ripgrep search timed out', engine: 'ripgrep' }; + } + // 其他错误回退到 JS + log.warn('[CodeSearch] ripgrep failed, falling back to JS:', err.stderr || err.message); + return this.searchWithJs(pattern, searchPath, opts, context); + } + } + + /** 解析 ripgrep --json 输出 */ + private parseRipgrepJsonOutput(output: string): Array<{ + path: string; + line: number; + column: number; + match: string; + before?: string[]; + after?: string[]; + }> { + const results: Array<{ path: string; line: number; column: number; match: string; before?: string[]; after?: string[] }> = []; + const lines = output.split('\n').filter((l) => l.trim()); + + let currentMatch: { path: string; line: number; column: number; match: string; before?: string[]; after?: string[] } | null = null; + let beforeBuffer: string[] = []; + let afterBuffer: string[] = []; + + for (const line of lines) { + let entry: Record; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + const type = entry.type as string; + const data = entry.data as Record; + + if (type === 'context') { + const text = (data.lines as { text?: string } | undefined)?.text ?? ''; + + if (currentMatch) { + // 当前有 match,这是 after context + afterBuffer.push(text); + } else { + // 当前无 match,这是 before context + beforeBuffer.push(text); + } + } else if (type === 'match') { + // 新 match:先保存上一个 match 的 after context + if (currentMatch) { + if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer]; + results.push(currentMatch); + afterBuffer = []; + } + + const text = (data.lines as { text?: string } | undefined)?.text ?? ''; + const submatches = (data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ?? []; + const matchText = submatches[0]?.match?.text ?? text; + const column = (submatches[0]?.start ?? 0) + 1; + + currentMatch = { + path: (data.path as { text?: string } | undefined)?.text ?? '', + line: data.line_number as number, + column, + match: matchText, + before: beforeBuffer.length > 0 ? [...beforeBuffer] : undefined, + }; + beforeBuffer = []; + afterBuffer = []; + } + } + + // 保存最后一个 match + if (currentMatch) { + if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer]; + results.push(currentMatch); + } + + return results; + } + + /** JS 回退实现 */ + private async searchWithJs( + pattern: string, + searchPath: string, + opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, + context: ToolExecutionContext, + ): Promise { + // 动态导入以避免循环依赖 + const { SearchFilesTool } = await import('./filesystem'); + const searchTool = new SearchFilesTool(); + return searchTool.execute({ + pattern, + target: 'content', + path: searchPath, + file_glob: opts.fileGlob, + limit: opts.maxResults, + }, context); + } + + private resolveSearchPath(filePath: string, workspacePath: string): string { + const resolved = resolve(workspacePath, filePath); + if (!isPathWithinWorkspace(filePath, workspacePath)) { + throw new Error(`Path traversal detected: ${filePath}`); + } + return resolved; + } +} diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index b4c3a23..e91eecc 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -3,6 +3,11 @@ * * run_command — 在沙箱环境中执行 Shell 命令 * + * 安全增强(v0.2.0): + * 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描 + * 2. 通过 SandboxManager.validatePath 校验工作目录 + * 3. 命令注入模式检测扩展 + * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 * @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配) */ @@ -10,10 +15,12 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { resolve } from 'path'; +import log from 'electron-log'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard'; +import type { SandboxManager } from '../../sandbox/sandbox'; const execAsync = promisify(exec); @@ -22,7 +29,7 @@ const execAsync = promisify(exec); export class RunCommandTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'run_command', - description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation.', + description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.', parameters: { type: 'object', properties: { @@ -38,6 +45,14 @@ export class RunCommandTool implements IMetonaTool { timeoutMs: 120_000, }; + /** v0.2.0: 可选注入 SandboxManager 进行双重安全校验 */ + private sandboxManager: SandboxManager | null = null; + + /** 注入 SandboxManager(由 main.ts 在注册时调用) */ + setSandboxManager(manager: SandboxManager): void { + this.sandboxManager = manager; + } + async execute(args: Record, context: ToolExecutionContext): Promise { const command = args.command as string; const workdir = (args.workdir as string) ?? context.workspacePath; @@ -49,18 +64,56 @@ export class RunCommandTool implements IMetonaTool { return { success: false, error: `Working directory must be within workspace: ${workdir}`, command }; } - // 命令安全校验 + // v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计 + // 若 SandboxManager 未注入(配置错误),拒绝执行高风险命令 + if (!this.sandboxManager) { + log.error('[RunCommandTool] SandboxManager not injected — refusing to execute (fail-closed)'); + return { + success: false, + error: 'Security sandbox unavailable — command execution disabled', + command, + }; + } + + // SandboxManager 静态代码扫描 + const scanResult = this.sandboxManager.scanCode(command); + if (!scanResult.safe) { + return { success: false, error: 'Command blocked by security policy', command }; + } + // SandboxManager 路径校验 + const pathResult = this.sandboxManager.validatePath(resolvedWorkdir); + if (!pathResult.allowed) { + return { success: false, error: 'Command blocked by security policy', command }; + } + + // 命令安全校验(内置硬阻止列表) const validation = this.validateCommand(command); if (!validation.allowed) { return { success: false, error: validation.reason, command }; } try { - const { stdout, stderr } = await execAsync(command, { + // Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页 + // 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码 + const isWindows = process.platform === 'win32'; + const finalCommand = isWindows + ? `chcp 65001 >nul 2>&1 && ${command}` + : command; + + const { stdout, stderr } = await execAsync(finalCommand, { cwd: resolvedWorkdir, timeout, maxBuffer: 1024 * 1024, // 1MB - env: { ...process.env, NODE_ENV: 'production' }, + env: { + ...process.env, + NODE_ENV: 'production', + ...(isWindows ? { + // 强制常见程序使用 UTF-8 输出 + PYTHONIOENCODING: 'utf-8', + LANG: 'zh_CN.UTF-8', + LC_ALL: 'zh_CN.UTF-8', + } : {}), + }, }); return { @@ -97,23 +150,41 @@ export class RunCommandTool implements IMetonaTool { } // 硬阻止列表(绝对禁止执行) + // v0.2.0: 扩展危险命令检测模式 const hardBlocks = [ + // 文件系统破坏 { pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' }, + { pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' }, { pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' }, + // 系统控制 { pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' }, + { pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' }, + // 远程代码执行 { pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, { pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' }, + { pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' }, + // 设备文件 { pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' }, + // 磁盘格式化 { pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' }, + // 权限滥用 { pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' }, + { pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' }, + // 环境变量窃取 + { pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' }, + // 反向 shell + { pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' }, + { pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' }, // Windows 危险命令 { pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' }, { pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' }, { pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' }, { pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' }, + { pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' }, // 后台进程与管道炸弹 { pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' }, { pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' }, + { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, reason: 'Fork bomb is forbidden' }, ]; for (const block of hardBlocks) { diff --git a/electron/harness/tools/built-in/diff-viewer.ts b/electron/harness/tools/built-in/diff-viewer.ts new file mode 100644 index 0000000..de82175 --- /dev/null +++ b/electron/harness/tools/built-in/diff-viewer.ts @@ -0,0 +1,258 @@ +/** + * 文件差异对比工具(1 个) + * + * diff_viewer — 对比两个文件或两段文本的差异 + * + * 生成 unified diff 格式输出,支持行级差异检测。 + * 用于 Agent 在编辑文件前后对比变化,或对比两个配置文件。 + */ + +import { readFile } from 'fs/promises'; +import { resolve } from 'path'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; +import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard'; + +interface DiffLine { + type: 'context' | 'added' | 'removed'; + oldLineNo: number | null; + newLineNo: number | null; + content: string; +} + +/** 最长公共子序列(LCS)diff 算法 */ +function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] { + // 构建 LCS 表(限制内存:大文件截断到 5000 行) + const maxLines = 5000; + const oldSliced = oldLines.slice(0, maxLines); + const newSliced = newLines.slice(0, maxLines); + const sm = oldSliced.length; + const sn = newSliced.length; + + // D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存 + const lcs = new Uint32Array((sm + 1) * (sn + 1)); + const idx = (i: number, j: number): number => i * (sn + 1) + j; + + for (let i = 1; i <= sm; i++) { + for (let j = 1; j <= sn; j++) { + if (oldSliced[i - 1] === newSliced[j - 1]) { + lcs[idx(i, j)] = lcs[idx(i - 1, j - 1)] + 1; + } else { + lcs[idx(i, j)] = Math.max(lcs[idx(i - 1, j)], lcs[idx(i, j - 1)]); + } + } + } + + // 回溯生成 diff + const result: DiffLine[] = []; + let i = sm, j = sn; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) { + result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] }); + i--; j--; + } else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) { + result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] }); + j--; + } else { + result.unshift({ type: 'removed', oldLineNo: i, newLineNo: null, content: oldSliced[i - 1] }); + i--; + } + } + + return result; +} + +/** 生成 unified diff 格式字符串 */ +function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string { + const lines: string[] = []; + lines.push(`--- ${oldLabel}`); + lines.push(`+++ ${newLabel}`); + + let oldLine = 1; + let newLine = 1; + let hunkLines: string[] = []; + let hunkStartOld = 1; + let hunkStartNew = 1; + let inHunk = false; + let contextSinceChange = 0; + + const flushHunk = () => { + if (hunkLines.length > 0) { + // 移除尾部多余的 context 行 + const trimmed: string[] = [...hunkLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) { + trimmed.pop(); + contextSinceChange--; + } + if (trimmed.length > 0) { + const oldCount = trimmed.filter((l) => l.startsWith('-') || l.startsWith(' ')).length; + const newCount = trimmed.filter((l) => l.startsWith('+') || l.startsWith(' ')).length; + lines.push(`@@ -${hunkStartOld},${oldCount} +${hunkStartNew},${newCount} @@`); + lines.push(...trimmed); + } + } + hunkLines = []; + inHunk = false; + contextSinceChange = 0; + }; + + for (const dl of diffLines) { + if (dl.type === 'context') { + if (inHunk) { + hunkLines.push(` ${dl.content}`); + contextSinceChange++; + // 超过 contextLines 行连续 context,结束当前 hunk + if (contextSinceChange > contextLines) { + flushHunk(); + } + } + oldLine++; + newLine++; + } else if (dl.type === 'added') { + if (!inHunk) { + inHunk = true; + hunkStartOld = oldLine; + hunkStartNew = newLine; + } + hunkLines.push(`+${dl.content}`); + contextSinceChange = 0; + newLine++; + } else if (dl.type === 'removed') { + if (!inHunk) { + inHunk = true; + hunkStartOld = oldLine; + hunkStartNew = newLine; + } + hunkLines.push(`-${dl.content}`); + contextSinceChange = 0; + oldLine++; + } + } + flushHunk(); + + return lines.join('\n'); +} + +export class DiffViewerTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'diff_viewer', + description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.', + parameters: { + type: 'object', + properties: { + mode: { + type: 'string', + description: 'Comparison mode', + enum: ['files', 'text'], + }, + file_a: { type: 'string', description: 'First file path (for files mode)' }, + file_b: { type: 'string', description: 'Second file path (for files mode)' }, + text_a: { type: 'string', description: 'First text content (for text mode)' }, + text_b: { type: 'string', description: 'Second text content (for text mode)' }, + context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' }, + }, + required: ['mode'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.SAFE, + requiresPermission: false, + timeoutMs: 15_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const mode = args.mode as 'files' | 'text'; + const contextLines = Math.min(10, Math.max(0, (args.context_lines as number) ?? 3)); + + // D4.5: 校验 mode + if (mode !== 'files' && mode !== 'text') { + return { success: false, error: `Invalid mode: ${mode}. Must be 'files' or 'text'` }; + } + + let contentA: string; + let contentB: string; + let labelA: string; + let labelB: string; + + if (mode === 'files') { + const fileA = args.file_a as string; + const fileB = args.file_b as string; + if (!fileA || !fileB) { + return { success: false, error: 'file_a and file_b are required for files mode' }; + } + + // 安全校验 + const pathA = resolve(context.workspacePath, fileA); + const pathB = resolve(context.workspacePath, fileB); + if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) { + return { success: false, error: 'Path traversal detected' }; + } + if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) { + return { success: false, error: 'Access denied: MEMORY.md is protected' }; + } + + try { + contentA = await readFile(pathA, 'utf-8'); + contentB = await readFile(pathB, 'utf-8'); + } catch (err) { + return { success: false, error: `Failed to read files: ${(err as Error).message}` }; + } + labelA = fileA; + labelB = fileB; + } else { + contentA = (args.text_a as string) ?? ''; + contentB = (args.text_b as string) ?? ''; + labelA = 'text_a'; + labelB = 'text_b'; + } + + const linesA = contentA.split('\n'); + const linesB = contentB.split('\n'); + + const diff = computeDiff(linesA, linesB); + const unifiedDiff = formatUnifiedDiff(diff, labelA, labelB, contextLines); + + const addedCount = diff.filter((d) => d.type === 'added').length; + const removedCount = diff.filter((d) => d.type === 'removed').length; + const contextCount = diff.filter((d) => d.type === 'context').length; + + // D4.3: similarity 计算使用截断后的长度作为分母 + const maxLines = 5000; + const oldSlicedLen = Math.min(linesA.length, maxLines); + const newSlicedLen = Math.min(linesB.length, maxLines); + const oldTruncated = linesA.length > maxLines; + const newTruncated = linesB.length > maxLines; + const truncated = oldTruncated || newTruncated; + + // 生成摘要 + const summary = { + files_compared: mode === 'files' ? 2 : 0, + lines_added: addedCount, + lines_removed: removedCount, + lines_unchanged: contextCount, + total_changes: addedCount + removedCount, + similarity: (oldSlicedLen + newSlicedLen) > 0 + ? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100 + : 1, + truncated, + }; + + // D4.6: unifiedDiff 大小限制 + const MAX_DIFF_CHARS = 50_000; + const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS + ? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)' + : unifiedDiff; + + return { + success: true, + summary, + diff: truncatedDiff, + diff_lines: diff.slice(0, 500), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } +} diff --git a/electron/harness/tools/built-in/file-editor.ts b/electron/harness/tools/built-in/file-editor.ts new file mode 100644 index 0000000..b11b4d7 --- /dev/null +++ b/electron/harness/tools/built-in/file-editor.ts @@ -0,0 +1,212 @@ +/** + * 文件编辑工具(1 个) + * + * file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换 + * + * 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改, + * 避免大文件全量重写,减少 token 消耗和出错风险。 + * + * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + */ + +import { readFile, writeFile, rename, mkdir } from 'fs/promises'; +import { dirname } from 'path'; +import { existsSync } from 'fs'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; +import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard'; +import { resolve } from 'path'; + +/** 安全校验:路径遍历防护 + 受保护文件拦截 */ +function safeResolvePath(filePath: string, workspacePath: string): string { + const resolved = resolve(workspacePath, filePath); + if (!isPathWithinWorkspace(filePath, workspacePath)) { + throw new Error(`Path traversal detected: ${filePath}`); + } + if (isProtectedWorkspaceFile(filePath, workspacePath)) { + throw new Error('Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools'); + } + return resolved; +} + +export class FileEditorTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'file_editor', + description: 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits.', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file to edit' }, + operation: { + type: 'string', + description: 'Edit operation', + enum: ['replace', 'insert', 'delete', 'regex'], + }, + start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' }, + end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' }, + content: { type: 'string', description: 'New content for replace/insert operations' }, + pattern: { type: 'string', description: 'Regex pattern for regex operation' }, + replacement: { type: 'string', description: 'Replacement string for regex operation' }, + flags: { type: 'string', description: 'Regex flags (default "g")' }, + }, + required: ['file_path', 'operation'], + }, + category: MetonaToolCategory.FILESYSTEM, + riskLevel: MetonaRiskLevel.MEDIUM, + requiresPermission: true, + timeoutMs: 15_000, + }; + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + // F1.5: file_path 空值校验 + if (!args.file_path || typeof args.file_path !== 'string') { + return { success: false, error: 'file_path is required' }; + } + const filePath = safeResolvePath(args.file_path as string, context.workspacePath); + const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex'; + + // 文件必须存在(不支持创建新文件,请用 write_file) + if (!existsSync(filePath)) { + return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` }; + } + + const originalContent = await readFile(filePath, 'utf-8'); + const lines = originalContent.split('\n'); + + let newLines: string[]; + let affectedRange: { start: number; end: number }; + + switch (operation) { + case 'replace': { + const startLine = Math.max(1, (args.start_line as number) ?? 1); + const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine); + if (endLine < startLine) { + return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; + } + const content = (args.content as string) ?? ''; + const contentLines = content.split('\n'); + newLines = [ + ...lines.slice(0, startLine - 1), + ...contentLines, + ...lines.slice(endLine), + ]; + affectedRange = { start: startLine, end: endLine }; + break; + } + + case 'insert': { + let startLine = Math.max(1, (args.start_line as number) ?? 1); + startLine = Math.min(startLine, lines.length + 1); // 允许追加到末尾 + const content = (args.content as string) ?? ''; + const contentLines = content.split('\n'); + newLines = [ + ...lines.slice(0, startLine - 1), + ...contentLines, + ...lines.slice(startLine - 1), + ]; + affectedRange = { start: startLine, end: startLine + contentLines.length - 1 }; + break; + } + + case 'delete': { + const startLine = Math.max(1, (args.start_line as number) ?? 1); + const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine); + if (endLine < startLine) { + return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; + } + newLines = [ + ...lines.slice(0, startLine - 1), + ...lines.slice(endLine), + ]; + affectedRange = { start: startLine, end: endLine }; + break; + } + + case 'regex': { + const pattern = args.pattern as string; + const replacement = (args.replacement as string) ?? ''; + const flags = (args.flags as string) ?? 'g'; + if (!pattern) { + return { success: false, error: 'Regex operation requires "pattern" parameter' }; + } + // F1.3: regex pattern 长度限制 + if (pattern.length > 500) { + return { success: false, error: 'Regex pattern too long (max 500 chars)' }; + } + const startLine = Math.max(1, (args.start_line as number) ?? 1); + const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length); + if (endLine < startLine) { + return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; + } + + let regex: RegExp; + try { + regex = new RegExp(pattern, flags); + } catch (err) { + return { success: false, error: `Invalid regex: ${(err as Error).message}` }; + } + + // F1.4: 用带 g 标志的正则统计匹配数 + let replaceCount = 0; + const targetLines = lines.slice(startLine - 1, endLine); + const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g'); + const replacedLines = targetLines.map((line) => { + const matches = line.match(countRegex); + if (matches) replaceCount += matches.length; + return line.replace(regex, replacement); + }); + + newLines = [ + ...lines.slice(0, startLine - 1), + ...replacedLines, + ...lines.slice(endLine), + ]; + affectedRange = { start: startLine, end: endLine }; + + // 如果没有替换,返回提示 + if (replaceCount === 0) { + return { + success: true, + operation, + file_path: args.file_path, + message: 'No matches found for regex pattern', + replacements: 0, + affected_range: affectedRange, + }; + } + + break; + } + + default: + return { success: false, error: `Unknown operation: ${operation}` }; + } + + // 自动创建父目录 (F1.8: 异步 mkdir) + const parentDir = dirname(filePath); + if (!existsSync(parentDir)) { + await mkdir(parentDir, { recursive: true }); + } + + const newContent = newLines.join('\n'); + // F1.7: 原子写入 - 临时文件 + rename + const tmpPath = `${filePath}.tmp_${Date.now()}`; + await writeFile(tmpPath, newContent, 'utf-8'); + await rename(tmpPath, filePath); + + return { + success: true, + operation, + file_path: args.file_path, + affected_range: affectedRange, + lines_before: lines.length, + lines_after: newLines.length, + bytes_written: Buffer.byteLength(newContent, 'utf-8'), + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } +} diff --git a/electron/harness/tools/built-in/file-guard.ts b/electron/harness/tools/built-in/file-guard.ts index e629834..f473516 100644 --- a/electron/harness/tools/built-in/file-guard.ts +++ b/electron/harness/tools/built-in/file-guard.ts @@ -60,15 +60,24 @@ export function isPathWithinWorkspace( * 检查命令字符串是否尝试访问工作空间根目录的受保护文件 * * 用于 run_command 工具的命令校验。 - * 采用大小写不敏感匹配,覆盖 cat/type/Get-Content 等常见读取命令。 + * 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道), + * 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。 + * + * 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md + * 等价于工作空间根目录的 MEMORY.md。 * * @param command Shell 命令字符串 - * @returns true 如果命令包含受保护文件名 + * @returns true 如果命令直接引用了受保护文件名 */ export function commandTouchesProtectedFile(command: string): boolean { const lowerCmd = command.toLowerCase(); for (const protectedName of PROTECTED_FILES) { - if (lowerCmd.includes(protectedName.toLowerCase())) { + const lowerName = protectedName.toLowerCase(); + // 前面是起始/空白/引号/分号/管道/&/>;后面是结束/空白/引号/分号/管道/&/ + // 这样 subdir/MEMORY.md 和 subdir\MEMORY.md 不会被匹配(前面是 / 或 \) + const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(?:^|[\\s"'|;&>])${escaped}(?:$|[\\s"'|;&<])`, 'i'); + if (regex.test(lowerCmd)) { return true; } } diff --git a/electron/harness/tools/built-in/index.ts b/electron/harness/tools/built-in/index.ts index aa7bde1..c4d2575 100644 --- a/electron/harness/tools/built-in/index.ts +++ b/electron/harness/tools/built-in/index.ts @@ -1,12 +1,36 @@ /** * 内置工具导出 * + * v0.2.0: 从 11 个工具扩展到 15 个工具 + * 新增:file_editor, code_search, task_manager, diff_viewer + * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 */ +// 文件系统工具(4 个) export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem'; +// v0.2.0: 精准文件编辑工具 +export { FileEditorTool } from './file-editor'; +// v0.2.0: ripgrep 代码搜索工具 +export { CodeSearchTool } from './code-search'; +// v0.2.0: 文件差异对比工具 +export { DiffViewerTool } from './diff-viewer'; + +// 网络工具(2 个) export { WebSearchTool, WebFetchTool } from './network'; + +// 记忆工具(2 个) export { MemoryStoreTool, MemorySearchTool } from './memory'; + +// 命令工具(1 个) export { RunCommandTool } from './command'; + +// 浏览器工具(1 个) export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser'; + +// 委派工具(1 个) export { DelegateTaskTool } from './delegate-task'; + +// v0.2.0: 任务管理工具(1 个) +export { TaskManagerTool } from './task-manager'; + diff --git a/electron/harness/tools/built-in/task-manager.ts b/electron/harness/tools/built-in/task-manager.ts new file mode 100644 index 0000000..e46c3f3 --- /dev/null +++ b/electron/harness/tools/built-in/task-manager.ts @@ -0,0 +1,329 @@ +/** + * 任务管理工具(1 个) + * + * task_manager — 创建/更新/列出/完成任务 + * + * 支持 Agent 在执行复杂任务时将任务分解为子任务并跟踪状态。 + * 任务持久化到 SQLite tasks 表。 + * + * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html + */ + +import { nanoid } from 'nanoid'; +import type Database from 'better-sqlite3'; +import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; +import type { MetonaToolDef } from '../../../harness/types'; +import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; + +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled'; +export type TaskPriority = 'low' | 'medium' | 'high' | 'critical'; + +export interface Task { + id: string; + sessionId: string; + title: string; + description: string; + status: TaskStatus; + priority: TaskPriority; + parentId: string | null; + assignedTo: string | null; + order: number; + createdAt: number; + updatedAt: number; + completedAt: number | null; +} + +/** 从数据库行映射到 Task 对象 */ +function mapRow(row: Record): Task { + return { + id: row.id as string, + sessionId: row.session_id as string, + title: row.title as string, + description: row.description as string, + status: row.status as TaskStatus, + priority: row.priority as TaskPriority, + parentId: (row.parent_id as string) ?? null, + assignedTo: (row.assigned_to as string) ?? null, + order: row.order_idx as number, + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + completedAt: (row.completed_at as number) ?? null, + }; +} + +// T3.5: 合法枚举值 +const VALID_STATUSES: TaskStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled']; +const VALID_PRIORITIES: TaskPriority[] = ['low', 'medium', 'high', 'critical']; + +export class TaskManagerTool implements IMetonaTool { + readonly definition: MetonaToolDef = { + name: 'task_manager', + description: 'Manage tasks for complex multi-step work. Supports operations: create, update, list, complete, delete. Tasks are persisted per session and can have parent-child relationships.', + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'Task operation', + enum: ['create', 'update', 'list', 'complete', 'delete', 'get'], + }, + title: { type: 'string', description: 'Task title (for create)' }, + description: { type: 'string', description: 'Task description (for create/update)' }, + task_id: { type: 'string', description: 'Task ID (for update/complete/delete/get)' }, + status: { type: 'string', description: 'New status (for update)', enum: ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'] }, + priority: { type: 'string', description: 'Priority (for create/update)', enum: ['low', 'medium', 'high', 'critical'] }, + parent_id: { type: 'string', description: 'Parent task ID (for create, establishes subtask relationship)' }, + }, + required: ['operation'], + }, + category: MetonaToolCategory.DATABASE, + riskLevel: MetonaRiskLevel.LOW, + requiresPermission: false, + timeoutMs: 10_000, + }; + + constructor(private getDB: () => Database.Database) {} + + async execute(args: Record, context: ToolExecutionContext): Promise { + try { + const operation = args.operation as string; + + switch (operation) { + case 'create': + return this.createTask(args, context); + case 'update': + return this.updateTask(args, context); + case 'list': + return this.listTasks(args, context); + case 'complete': + return this.completeTask(args, context); + case 'delete': + return this.deleteTask(args, context); + case 'get': + return this.getTask(args, context); + default: + return { success: false, error: `Unknown operation: ${operation}` }; + } + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + private createTask(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const id = `task_${nanoid(12)}`; + const now = Date.now(); + const sessionId = context.sessionId; + const title = args.title as string; + const description = (args.description as string) ?? ''; + const priority = (args.priority as TaskPriority) ?? 'medium'; + const parentId = (args.parent_id as string) ?? null; + + if (!title) { + return { success: false, error: 'title is required for create operation' }; + } + + // T3.5: 校验 priority 枚举值 + if (!VALID_PRIORITIES.includes(priority)) { + return { success: false, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; + } + + // 获取同 session 同 parent 下的最大 order + const maxOrderRow = db.prepare( + 'SELECT MAX(order_idx) as max_order FROM tasks WHERE session_id = ? AND parent_id IS ?', + ).get(sessionId, parentId) as { max_order: number | null } | undefined; + const order = (maxOrderRow?.max_order ?? -1) + 1; + + db.prepare(` + INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, assigned_to, order_idx, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now); + + return { + success: true, + task: { + id, sessionId, title, description, + status: 'pending', priority, parentId, + assignedTo: null, order, + createdAt: now, updatedAt: now, completedAt: null, + }, + }; + } + + private updateTask(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const taskId = args.task_id as string; + if (!taskId) { + return { success: false, error: 'task_id is required for update operation' }; + } + + // T3.1: 校验任务属于当前会话 + const existing = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); + if (!existing) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + // T3.5: 校验枚举值 + if (args.status !== undefined && !VALID_STATUSES.includes(args.status as TaskStatus)) { + return { success: false, error: `Invalid status: ${args.status}. Must be one of: ${VALID_STATUSES.join(', ')}` }; + } + if (args.priority !== undefined && !VALID_PRIORITIES.includes(args.priority as TaskPriority)) { + return { success: false, error: `Invalid priority: ${args.priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` }; + } + + const updates: string[] = []; + const values: unknown[] = []; + + if (args.title !== undefined) { + updates.push('title = ?'); + values.push(args.title); + } + if (args.description !== undefined) { + updates.push('description = ?'); + values.push(args.description); + } + if (args.status !== undefined) { + updates.push('status = ?'); + values.push(args.status); + if (args.status === 'completed') { + updates.push('completed_at = ?'); + values.push(Date.now()); + } + } + if (args.priority !== undefined) { + updates.push('priority = ?'); + values.push(args.priority); + } + + if (updates.length === 0) { + return { success: false, error: 'No fields to update' }; + } + + updates.push('updated_at = ?'); + values.push(Date.now()); + values.push(taskId); + values.push(context.sessionId); + + // T3.1: UPDATE 语句加 AND session_id = ? 防止越权 + db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values); + + const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record; + return { success: true, task: mapRow(updated) }; + } + + private listTasks(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const sessionId = context.sessionId; + const status = args.status as string | undefined; + + let sql = 'SELECT * FROM tasks WHERE session_id = ?'; + const values: unknown[] = [sessionId]; + + if (status) { + sql += ' AND status = ?'; + values.push(status); + } + + sql += ' ORDER BY order_idx ASC, created_at ASC'; + + const rows = db.prepare(sql).all(...values) as Array>; + const tasks = rows.map(mapRow); + + return { + success: true, + tasks, + count: tasks.length, + by_status: { + pending: tasks.filter((t) => t.status === 'pending').length, + in_progress: tasks.filter((t) => t.status === 'in_progress').length, + completed: tasks.filter((t) => t.status === 'completed').length, + blocked: tasks.filter((t) => t.status === 'blocked').length, + cancelled: tasks.filter((t) => t.status === 'cancelled').length, + }, + }; + } + + private completeTask(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const taskId = args.task_id as string; + if (!taskId) { + return { success: false, error: 'task_id is required for complete operation' }; + } + + // T3.1: 校验任务属于当前会话 + const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); + if (!existing) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + const now = Date.now(); + // T3.1: UPDATE 语句加 AND session_id = ? 防止越权 + const result = db.prepare( + 'UPDATE tasks SET status = ?, completed_at = ?, updated_at = ? WHERE id = ? AND session_id = ?', + ).run('completed', now, now, taskId, context.sessionId); + + if (result.changes === 0) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + return { success: true, task_id: taskId, completed_at: now }; + } + + private deleteTask(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const taskId = args.task_id as string; + if (!taskId) { + return { success: false, error: 'task_id is required for delete operation' }; + } + + // T3.1: 校验任务属于当前会话 + const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId); + if (!existing) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + // T3.2/T3.3: 递归 CTE 查出所有后代任务 ID + const descendantIds = db.prepare(` + WITH RECURSIVE descendants AS ( + SELECT id FROM tasks WHERE id = ? + UNION ALL + SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id + ) + SELECT id FROM descendants + `).all(taskId) as Array<{ id: string }>; + + const allIds = descendantIds.map((r) => r.id); + + // 事务包裹批量删除 + const deleteMany = db.transaction(() => { + const placeholders = allIds.map(() => '?').join(','); + db.prepare(`DELETE FROM tasks WHERE id IN (${placeholders})`).run(...allIds); + }); + deleteMany(); + + return { success: true, task_id: taskId, deleted: allIds.length }; + } + + private getTask(args: Record, context: ToolExecutionContext): unknown { + const db = this.getDB(); + const taskId = args.task_id as string; + if (!taskId) { + return { success: false, error: 'task_id is required for get operation' }; + } + + // T3.1: 校验任务属于当前会话 + const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record | undefined; + if (!row) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + // 获取子任务(同样限制在当前会话内) + const subtasks = db.prepare('SELECT * FROM tasks WHERE parent_id = ? AND session_id = ? ORDER BY order_idx ASC').all(taskId, context.sessionId) as Array>; + + return { + success: true, + task: mapRow(row), + subtasks: subtasks.map(mapRow), + }; + } +} diff --git a/electron/harness/tools/registry.ts b/electron/harness/tools/registry.ts index 8e52090..1bcbee4 100644 --- a/electron/harness/tools/registry.ts +++ b/electron/harness/tools/registry.ts @@ -17,7 +17,6 @@ const MAX_RESULT_CHARS = 50_000; export class ToolRegistry { private tools = new Map(); - private disabledTools = new Set(); /** 注册内置工具 */ registerBuiltin(tool: IMetonaTool): void { @@ -51,14 +50,13 @@ export class ToolRegistry { get(name: string): IMetonaTool | undefined { const entry = this.tools.get(name); if (!entry?.enabled) return undefined; - if (this.disabledTools.has(name)) return undefined; return entry.tool; } /** 列出所有已启用工具的定义 */ listTools(): MetonaToolDef[] { return Array.from(this.tools.values()) - .filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)) + .filter((e) => e.enabled) .map((e) => e.tool.definition); } @@ -66,16 +64,15 @@ export class ToolRegistry { listAllTools(): Array { return Array.from(this.tools.values()).map((e) => ({ ...e.tool.definition, - enabled: e.enabled && !this.disabledTools.has(e.tool.definition.name), + enabled: e.enabled, })); } /** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */ setToolEnabled(name: string, enabled: boolean): void { - if (enabled) { - this.disabledTools.delete(name); - } else { - this.disabledTools.add(name); + const entry = this.tools.get(name); + if (entry) { + entry.enabled = enabled; } } @@ -151,6 +148,6 @@ export class ToolRegistry { /** 获取工具数量 */ get size(): number { - return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length; + return Array.from(this.tools.values()).filter((e) => e.enabled).length; } } diff --git a/electron/harness/types/metona-response.ts b/electron/harness/types/metona-response.ts index f077f72..ba929bb 100644 --- a/electron/harness/types/metona-response.ts +++ b/electron/harness/types/metona-response.ts @@ -84,8 +84,6 @@ export enum MetonaStreamEventType { TOOL_CALL_DELTA = 'tool_call_delta', TOOL_CALL_COMPLETE = 'tool_call_complete', TOOL_RESULT = 'tool_result', - THINKING_START = 'thinking_start', - THINKING_END = 'thinking_end', ERROR = 'error', DONE = 'done', USAGE = 'usage', @@ -99,6 +97,8 @@ export interface MetonaStreamEvent { /** 序列号 */ seq: number; timestamp: number; + /** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */ + runId?: string; /** 根据 type 使用不同字段 */ /** TEXT_DELTA / REASONING_DELTA */ diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index feb3a7d..74be754 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -22,6 +22,8 @@ import type { MemoryManager } from '../harness/memory/manager'; import type { MCPManager } from '../services/mcp-manager.service'; import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense'; import type { OutputValidator } from '../harness/verification/output-validator'; +import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; +import type { MemoryConsolidator } from '../harness/memory/consolidator'; import type { MetonaMessage, MetonaStreamEvent } from '../harness/types'; import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types'; import type { MetonaError } from '../harness/types'; @@ -45,6 +47,8 @@ export function registerAllIPCHandlers( reloadAdapter: () => void, promptInjectionDefender: PromptInjectionDefender, outputValidator: OutputValidator, + confirmationHook: ConfirmationHook, + memoryConsolidator: MemoryConsolidator, ): void { // ===== Agent 交互 ===== @@ -112,7 +116,7 @@ export function registerAllIPCHandlers( } }; - const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string }) => { + const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => { // AGENT 层:记录状态转换 if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`); // 转发到渲染进程(携带迭代号) @@ -121,8 +125,19 @@ export function registerAllIPCHandlers( } }; + // M-6: 转发上下文压缩事件为 toast 通知 + const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'info', + message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens`, + }); + } + }; + agentLoop.on('streamEvent', onStreamEvent); agentLoop.on('stateChange', onStateChange); + agentLoop.on('compressed', onCompressed); try { // 提示注入检测(安全模块) @@ -218,6 +233,26 @@ export function registerAllIPCHandlers( // 更新 MEMORY.md 时间戳 workspaceService.updateMemoryTimestamp(); + // 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md + // 异步执行,不阻塞主流程返回;失败仅记录日志 + memoryConsolidator + .consolidate(userMessage.content, output.finalAnswer, output.iterations) + .then((result) => { + if (result.appended > 0) { + log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`); + // 通知渲染进程记忆已更新 + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'info', + message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`, + }); + } + } + }) + .catch((err) => { + log.warn('[AGENT] Memory consolidation failed:', err); + }); + // TOOL 层:记录会话结束 auditService.logSessionEnd({ sessionId, @@ -282,6 +317,7 @@ export function registerAllIPCHandlers( } finally { agentLoop.off('streamEvent', onStreamEvent); agentLoop.off('stateChange', onStateChange); + agentLoop.off('compressed', onCompressed); } }); @@ -628,5 +664,141 @@ export function registerAllIPCHandlers( } }); + // ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)===== + // 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送) + ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => { + confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false); + log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`); + }); + + // ===== v0.2.0: 持久化自动执行设置 ===== + ipcMain.handle('tool:setAutoExecute', async (_event, toolName: string, enabled: boolean) => { + try { + confirmationHook.setAutoExecute(toolName, enabled); + log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('tool:getAutoExecuteList', async () => { + return { success: true, data: confirmationHook.getAutoExecuteList() }; + }); + + // ===== v0.2.0: 审计日志链式哈希验证 ===== + ipcMain.handle('audit:verifyChain', async () => { + try { + const result = auditService.verifyChain(); + log.info(`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`); + return { success: true, ...result }; + } catch (error) { + log.error('[AUDIT] Chain verification failed:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('audit:query', async (_event, filters?: { sessionId?: string; eventType?: string; limit?: number }) => { + try { + return { success: true, data: auditService.query(filters as Parameters[0]) }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + // ===== v0.2.0: 任务管理 IPC(TaskList UI)===== + ipcMain.handle('tasks:list', async (_event, sessionId?: string) => { + const db = sessionService.getDB(); + let sql = 'SELECT * FROM tasks'; + const params: unknown[] = []; + if (sessionId) { + sql += ' WHERE session_id = ?'; + params.push(sessionId); + } + sql += ' ORDER BY order_idx ASC, created_at ASC'; + return { success: true, data: db.prepare(sql).all(...params) }; + }); + + ipcMain.handle('tasks:create', async (_event, data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => { + const db = sessionService.getDB(); + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + try { + db.prepare(` + INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) + `).run(id, data.sessionId, data.title, data.description ?? '', data.priority ?? 'medium', data.parentId ?? null, Date.now(), Date.now()); + return { success: true, id }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('tasks:update', async (_event, id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => { + const db = sessionService.getDB(); + try { + const fields: string[] = []; + const values: unknown[] = []; + if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); } + if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); } + if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } + if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); } + if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); } + if (fields.length === 0) return { success: true }; + fields.push('updated_at = ?'); values.push(Date.now()); + if (updates.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); } + values.push(id); + db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('tasks:delete', async (_event, id: string) => { + const db = sessionService.getDB(); + try { + db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + // ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI)===== + ipcMain.handle('memory:listAll', async (_event, options?: { type?: string; limit?: number }) => { + const db = sessionService.getDB(); + const limit = options?.limit ?? 100; + const type = options?.type; + const results: Record = {}; + try { + if (!type || type === 'episodic') { + const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array>; + results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' })); + } + if (!type || type === 'semantic') { + const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array>; + results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at })); + } + if (!type || type === 'working') { + const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array>; + results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() })); + } + return { success: true, data: results }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('memory:delete', async (_event, type: string, id: string) => { + const db = sessionService.getDB(); + try { + const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories'; + db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + log.info('[SYS] All IPC handlers registered'); } diff --git a/electron/main.ts b/electron/main.ts index 379900f..f7b950d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,6 +30,7 @@ import { WindowManager } from './services/window-manager.service'; import { MCPManager } from './services/mcp-manager.service'; import { ContextBuilder } from './harness/prompts/context-builder'; import { MemoryManager } from './harness/memory/manager'; +import { MemoryConsolidator } from './harness/memory/consolidator'; import { registerAllIPCHandlers } from './ipc/handlers'; import { AgentLoopEngine } from './harness/agent-loop'; import { ToolRegistry } from './harness/tools/registry'; @@ -43,8 +44,14 @@ import { RunCommandTool, WebBrowserTool, cleanupBrowser, DelegateTaskTool, + // v0.2.0 新增工具 + FileEditorTool, + CodeSearchTool, + TaskManagerTool, + DiffViewerTool, } from './harness/tools/built-in'; import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks'; +import { ConfirmationHook } from './harness/hooks/confirmation-hook'; import { PolicyEngine } from './harness/sandbox/permissions'; import { SandboxManager } from './harness/sandbox/sandbox'; import { PromptInjectionDefender } from './harness/security/prompt-injection-defense'; @@ -150,6 +157,19 @@ async function initialize(): Promise { // ===== 步骤 5: 工作空间文件 + System Prompt ===== const contextBuilder = new ContextBuilder(); + // ===== v0.2.0: 安全模块(必须在工具注册之前,便于 RunCommandTool 注入 SandboxManager)===== + const policyEngine = new PolicyEngine(); + const sandboxManager = new SandboxManager({ + allowedPaths: [workspaceInfo.path], + networkPolicy: 'allowlist', + }); + const promptDefender = new PromptInjectionDefender(); + const outputValidator = new OutputValidator(); + + // v0.2.0: ConfirmationHook(提前创建,mainWindow 创建后再注入) + // 注入 ConfigService 以支持持久化自动执行设置 + const confirmationHook = new ConfirmationHook(null, configService); + // ===== 步骤 6: 注册内置工具 ===== const toolRegistry = new ToolRegistry(); toolRegistry.registerBuiltin(new ReadFileTool()); @@ -157,6 +177,11 @@ async function initialize(): Promise { toolRegistry.registerBuiltin(new ListDirectoryTool()); toolRegistry.registerBuiltin(new SearchFilesTool()); + // v0.2.0: 新增文件工具 + toolRegistry.registerBuiltin(new FileEditorTool()); + toolRegistry.registerBuiltin(new CodeSearchTool()); + toolRegistry.registerBuiltin(new DiffViewerTool()); + // WebFetchTool 先于 WebSearchTool 构造,注入为依赖 const webFetchTool = new WebFetchTool(); toolRegistry.registerBuiltin(webFetchTool); @@ -164,7 +189,14 @@ async function initialize(): Promise { toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager)); toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager)); - toolRegistry.registerBuiltin(new RunCommandTool()); + + // v0.2.0: RunCommandTool 注入 SandboxManager + const runCommandTool = new RunCommandTool(); + runCommandTool.setSandboxManager(sandboxManager); + toolRegistry.registerBuiltin(runCommandTool); + + // v0.2.0: 任务管理工具 + toolRegistry.registerBuiltin(new TaskManagerTool(() => db)); // 注册 Web Browser 统一浏览器工具 toolRegistry.registerBuiltin(new WebBrowserTool()); @@ -176,19 +208,13 @@ async function initialize(): Promise { log.warn('MCP Manager initialization error:', err); }); - // ===== 安全模块 ===== - const policyEngine = new PolicyEngine(); - const sandboxManager = new SandboxManager({ - allowedPaths: [workspaceInfo.path], - networkPolicy: 'allowlist', - }); - const promptDefender = new PromptInjectionDefender(); - const outputValidator = new OutputValidator(); - // ===== Hooks ===== + // v0.2.0: ConfirmationHook 注入到 preToolHooks 管道 + // 注意:setToolDefs 延迟到 DelegateTaskTool 注册后调用,确保包含所有工具的风险等级 const preToolHooks = [ new PermissionCheckHook(policyEngine), new RateLimitHook(20), + confirmationHook, ]; const postToolHooks = [ new AuditLogHook(auditService), @@ -215,6 +241,9 @@ async function initialize(): Promise { agentLoop.setTools(toolRegistry.listTools()); agentLoop.setWorkspacePath(workspaceInfo.path); + // ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)===== + const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService); + // ===== Task Orchestrator(子任务委派)===== const orchestrator = new TaskOrchestrator( agentLoop, toolRegistry, preToolHooks, postToolHooks, @@ -227,12 +256,16 @@ async function initialize(): Promise { toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator)); // 重新设置工具列表,包含新注册的 delegate_task agentLoop.setTools(toolRegistry.listTools()); + // v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存 + confirmationHook.setToolDefs(toolRegistry.listAllTools()); // ===== 热重载 Adapter 回调(设置变更时触发)===== + let lastProvider = configService.get('llm.provider') ?? ''; const reloadAdapter = () => { try { const newAdapter = createAdapter(); agentLoop.setAdapter(newAdapter); + memoryConsolidator.setAdapter(newAdapter); // Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口 const provider = configService.get('llm.provider') ?? ''; if (provider === 'ollama') { @@ -242,8 +275,30 @@ async function initialize(): Promise { agentLoop.updateConfig({ contextLength: undefined }); } log.info(`[CONFIG] Adapter reloaded: provider=${provider}`); + // 通知渲染进程 Provider 已切换(UI 显示 Toast + 系统消息) + if (mainWindow && !mainWindow.isDestroyed()) { + if (lastProvider && lastProvider !== provider) { + mainWindow.webContents.send('agent:providerSwitched', { + from: lastProvider, + to: provider, + reason: 'config_changed', + }); + } + mainWindow.webContents.send('toast:show', { + type: 'success', + message: `Provider 已切换: ${lastProvider || '未知'} → ${provider}`, + }); + } + lastProvider = provider; } catch (err) { log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`); + // 通知渲染进程 Provider 切换失败(UI 显示错误 Toast) + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('toast:show', { + type: 'error', + message: `Provider 切换失败: ${(err as Error).message}`, + }); + } } }; @@ -255,6 +310,9 @@ async function initialize(): Promise { title: 'MetonaAI Desktop', }); + // v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求) + confirmationHook.setMainWindow(mainWindow); + // ===== 系统托盘 ===== const resourcesPath = join(__dirname, '../../assets'); trayManager = new TrayManager(resourcesPath); @@ -267,7 +325,7 @@ async function initialize(): Promise { agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => { const statusMap: Record = { INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking', - EXECUTING: 'executing', OBSERVING: 'executing', REFLECTING: 'thinking', + EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking', COMPRESSING: 'thinking', TERMINATED: 'idle', }; const stateValue = (data.current || data.state) ?? ''; @@ -289,6 +347,7 @@ async function initialize(): Promise { contextBuilder, agentLoop, toolRegistry, auditService, sessionRecorder, memoryManager, mcpManager, createAdapter, promptDefender, outputValidator, + confirmationHook, memoryConsolidator, ); // TODO: Initialize UpdateService for auto-update functionality diff --git a/electron/preload.ts b/electron/preload.ts index 0ba2cd0..531c954 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,6 +64,41 @@ const metonaAPI = { // ===== 记忆系统 ===== memory: { search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options), + listAll: (options?: { type?: string; limit?: number }) => + ipcRenderer.invoke('memory:listAll', options), + delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id), + }, + + // ===== v0.2.0: 任务管理 ===== + tasks: { + list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId), + create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => + ipcRenderer.invoke('tasks:create', data), + update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => + ipcRenderer.invoke('tasks:update', id, updates), + delete: (id: string) => ipcRenderer.invoke('tasks:delete', id), + }, + + // ===== v0.2.0: 审计日志 ===== + audit: { + verifyChain: () => ipcRenderer.invoke('audit:verifyChain'), + query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) => + ipcRenderer.invoke('audit:query', filters), + }, + + // ===== v0.2.0: 工具确认请求 ===== + tool: { + onConfirmationRequest: (callback: (request: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); + ipcRenderer.on('tool:confirmationRequest', listener); + return () => ipcRenderer.removeListener('tool:confirmationRequest', listener); + }, + sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => + ipcRenderer.send('tool:confirmationResponse', response), + // v0.2.0: 持久化自动执行设置 + setAutoExecute: (toolName: string, enabled: boolean) => + ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), + getAutoExecuteList: () => ipcRenderer.invoke('tool:getAutoExecuteList'), }, // ===== 配置 ===== diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts index 4eeb322..42f2e99 100644 --- a/electron/services/audit.service.ts +++ b/electron/services/audit.service.ts @@ -6,12 +6,14 @@ * 1. 所有重要操作必须记录 * 2. 日志不可篡改(INSERT-ONLY) * 3. 支持按时间/类型/会话查询 + * 4. v0.2.0: 链式哈希防篡改 — 每条日志的 current_hash = SHA256(prev_hash + content) * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层 * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章 */ import type Database from 'better-sqlite3'; +import { createHash } from 'crypto'; import log from 'electron-log'; export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change'; @@ -33,24 +35,79 @@ export class AuditService { constructor(private getDB: () => Database.Database) {} /** - * 记录审计条目 + * 计算链式哈希 + * current_hash = SHA256(prev_hash + 所有内容字段) + * v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞 + */ + private computeHash( + prevHash: string, + entry: { + sessionId: string; iteration: number | null; eventType: string; + actor: string; target: string; details: string | null; + outcome: string | null; durationMs: number | null; createdAt: number; + }, + ): string { + // 使用 JSON.stringify 避免分隔符碰撞 + const content = JSON.stringify({ + prevHash, + sessionId: entry.sessionId, + iteration: entry.iteration, + eventType: entry.eventType, + actor: entry.actor, + target: entry.target, + details: entry.details, + outcome: entry.outcome, + durationMs: entry.durationMs, + createdAt: entry.createdAt, + }); + return createHash('sha256').update(content, 'utf-8').digest('hex'); + } + + /** + * 获取最后一条日志的 hash(用于链式哈希计算) + */ + private getLastHash(): string { + 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'; + } + + /** + * 记录审计条目(带链式哈希) */ log(entry: AuditEntry): void { try { const db = this.getDB(); + const now = Date.now(); + const detailsStr = entry.details ? JSON.stringify(entry.details) : null; + const prevHash = this.getLastHash(); + const currentHash = this.computeHash(prevHash, { + sessionId: entry.sessionId, + iteration: entry.iteration ?? null, + eventType: entry.eventType, + actor: entry.actor, + target: entry.target, + details: detailsStr, + outcome: entry.outcome ?? null, + durationMs: entry.durationMs ?? null, + createdAt: now, + }); + db.prepare(` - INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.sessionId, entry.iteration ?? null, entry.eventType, entry.actor, entry.target, - entry.details ? JSON.stringify(entry.details) : null, + detailsStr, entry.outcome ?? null, entry.durationMs ?? null, - Date.now(), + now, + prevHash, + currentHash, ); } catch (error) { // 审计日志写入失败不应影响主流程 @@ -185,6 +242,8 @@ export class AuditService { outcome: string | null; duration_ms: number | null; created_at: number; + prev_hash: string | null; + current_hash: string | null; }> { const db = this.getDB(); let sql = 'SELECT * FROM audit_logs WHERE 1=1'; @@ -217,6 +276,57 @@ export class AuditService { outcome: string | null; duration_ms: number | null; created_at: number; + prev_hash: string | null; + current_hash: string | null; }>; } + + /** + * v0.2.0: 验证链式哈希完整性 + * + * 遍历所有审计日志,重新计算每条记录的 hash,与存储的 current_hash 对比。 + * 如果任何一条记录的 hash 不匹配,说明日志已被篡改。 + * + * @returns 验证结果,包括是否通过、首个篡改位置的 ID + */ + verifyChain(): { valid: boolean; totalRecords: number; verifiedRecords: number; tamperedId: number | null } { + const db = this.getDB(); + const rows = db.prepare('SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC').all() as Array<{ + id: number; session_id: string; iteration: number | null; event_type: string; actor: string; + target: string; details: string | null; outcome: string | null; duration_ms: number | null; + created_at: number; prev_hash: string | null; current_hash: string | null; + }>; + + let prevHash = '0000000000000000000000000000000000000000000000000000000000000000'; + let verified = 0; + + for (const row of rows) { + // 跳过旧数据(current_hash 为 NULL,未回填哈希) + if (row.current_hash === null) { + prevHash = '0'.repeat(64); + continue; + } + + const expectedHash = this.computeHash(prevHash, { + sessionId: row.session_id, + iteration: row.iteration, + eventType: row.event_type, + actor: row.actor, + target: row.target, + details: row.details, + outcome: row.outcome, + durationMs: row.duration_ms, + createdAt: row.created_at, + }); + + if (row.current_hash !== expectedHash) { + return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id }; + } + + prevHash = row.current_hash; + verified++; + } + + return { valid: true, totalRecords: rows.length, verifiedRecords: verified, tamperedId: null }; + } } diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index a9d2243..b79047b 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -128,7 +128,9 @@ export class DatabaseService { details TEXT, outcome TEXT, duration_ms INTEGER, - created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + prev_hash TEXT, + current_hash TEXT ); -- ===== MCP 服务配置表 ===== @@ -183,6 +185,24 @@ export class DatabaseService { UNIQUE(session_id, task_id, key) ); + -- ===== v0.2.0: 任务表 ===== + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')), + priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')), + parent_id TEXT, + assigned_to TEXT, + order_idx INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + completed_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + -- ===== 索引 ===== CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); @@ -197,6 +217,9 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_episodic_session ON episodic_memories(session_id); CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memories(importance DESC); CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id); + CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx); + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status); + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); `); // 审计日志防篡改触发器(INSERT-ONLY) @@ -246,6 +269,28 @@ export class DatabaseService { throw error; } } + + // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) + try { + db.exec('ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT'); + log.info('[DB] Migration: added prev_hash column to audit_logs'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('duplicate column')) { + throw error; + } + } + + // v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希) + try { + db.exec('ALTER TABLE audit_logs ADD COLUMN current_hash TEXT'); + log.info('[DB] Migration: added current_hash column to audit_logs'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('duplicate column')) { + throw error; + } + } } /** diff --git a/package-lock.json b/package-lock.json index ea4c561..89f4f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.1.2", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.1.2", + "version": "0.2.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -417,9 +417,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -691,9 +691,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -1488,9 +1488,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1586,9 +1586,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4851,9 +4851,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5616,9 +5616,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5975,9 +5975,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -6321,9 +6321,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7633,9 +7633,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" diff --git a/package.json b/package.json index 82254fd..690f0e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.1.4", + "version": "0.2.1", "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 7bb7741..e6f5c10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { SettingsModal } from '@renderer/components/settings/SettingsModal'; import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'; import { CommandPalette } from '@renderer/components/CommandPalette'; import { ToastContainer } from '@renderer/components/ToastContainer'; +import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog'; import { useTheme } from '@renderer/hooks/useTheme'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useAgentStream } from '@renderer/hooks/useAgentStream'; @@ -64,6 +65,7 @@ export default function App(): React.JSX.Element { + ); diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx new file mode 100644 index 0000000..4f8f68b --- /dev/null +++ b/src/components/ConfirmationDialog.tsx @@ -0,0 +1,221 @@ +/** + * ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增) + * + * 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时, + * 通过此对话框请求用户确认。 + * + * 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情, + * 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, + Accordion, AccordionSummary, AccordionDetails, +} from '@mui/material'; +import { ShieldAlert, ChevronDown } from 'lucide-react'; + +interface ConfirmationRequest { + toolCallId: string; + toolName: string; + args: Record; + riskLevel: string; + reason: string; +} + +const RISK_COLORS: Record = { + safe: 'success', + low: 'info', + medium: 'warning', + high: 'error', + critical: 'error', +}; + +export function ConfirmationDialog(): React.JSX.Element | null { + const [request, setRequest] = useState(null); + const [remember, setRemember] = useState(false); + const [autoExecute, setAutoExecute] = useState(false); + + useEffect(() => { + // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API) + const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => { + setRequest(data as ConfirmationRequest); + setRemember(false); + setAutoExecute(false); + }); + return cleanup; + }, []); + + const handleRespond = useCallback((approved: boolean) => { + if (!request) return; + // 通过 preload 暴露的 metona.tool API 发送响应 + // autoExecute 仅在批准时生效(拒绝时无需持久化) + window.metona?.tool?.sendConfirmationResponse({ + toolCallId: request.toolCallId, + approved, + remember, + autoExecute: approved && autoExecute, + }); + setRequest(null); + }, [request, remember, autoExecute]); + + if (!request) return null; + + const riskColor = RISK_COLORS[request.riskLevel] ?? 'default'; + + // 格式化参数显示 + const formatArg = (key: string, value: unknown): string => { + if (typeof value === 'string') { + // 长字符串截断 + return value.length > 200 ? value.slice(0, 200) + '...' : value; + } + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + }; + + return ( + handleRespond(false)} + maxWidth="sm" + fullWidth + slotProps={{ + paper: { + sx: { bgcolor: 'background.paper' }, + }, + }} + > + + + 工具执行确认 + + + + + {request.reason} + + + + + + + + + Agent 正在请求执行以下工具,请确认参数: + + + + }> + + 参数详情 ({Object.keys(request.args).length} 个参数) + + + + + {Object.entries(request.args).map(([key, value]) => ( + + + {key}: + + + {formatArg(key, value)} + + + ))} + + + + + setRemember(e.target.checked)} + size="small" + /> + } + label={ + + 在本次会话中记住此决定(同一工具不再询问) + + } + sx={{ mt: 1 }} + /> + + { + setAutoExecute(e.target.checked); + // 勾选永久自动执行时,自动勾选会话内记忆(保持一致) + if (e.target.checked) setRemember(true); + }} + size="small" + /> + } + label={ + + 永久自动执行此工具(跨会话不再询问,可在设置中关闭) + + } + sx={{ mt: 0.5 }} + /> + + + + + + + + ); +} diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index 3fa77fb..4965425 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -191,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string { if (!children) return ''; if (Array.isArray(children)) return children.map(extractTextContent).join(''); if (typeof children === 'object' && 'props' in children) { - return extractTextContent((children as React.ReactElement).props as React.ReactNode); + return extractTextContent((children as React.ReactElement).props.children as React.ReactNode); } return ''; } diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index b264f91..5700a7e 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -13,7 +13,13 @@ export function MessageList(): React.JSX.Element { const isStreaming = useAgentStore((s) => s.isStreaming); const messagesEndRef = useRef(null); - useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + // 流式输出时高频触发,使用 throttle 避免滚动卡顿 + useEffect(() => { + const timer = setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 80); + return () => clearTimeout(timer); + }, [messages]); if (messages.length === 0 && !isStreaming) { return ( diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..890cf4f --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,53 @@ +/** + * ErrorBoundary — 渲染错误边界 + * + * 捕获子组件渲染时的同步异常,显示降级 UI 而非整个应用白屏。 + */ + +import { Component, type ReactNode } from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import { AlertTriangle } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallbackTitle?: string; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: { componentStack: string }): void { + console.error('[ErrorBoundary]', error, info.componentStack); + } + + handleReset = (): void => { + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (!this.state.hasError) return this.props.children; + return ( + + + + {this.props.fallbackTitle ?? '组件渲染失败'} + + + {this.state.error?.message ?? '未知错误'} + + + + ); + } +} diff --git a/src/components/layout/DetailPanel.tsx b/src/components/layout/DetailPanel.tsx index dd8fb8a..2b3d463 100644 --- a/src/components/layout/DetailPanel.tsx +++ b/src/components/layout/DetailPanel.tsx @@ -1,16 +1,25 @@ /** * DetailPanel — 右侧详情面板 * - * 布局策略:TraceViewer flex:1 撑满,TokenUsage/AgentMonitor flexShrink:0 固定。 + * 通过 Tab 切换显示 Trace / Memory / Tasks。 */ -import { Box } from '@mui/material'; +import { useState } from 'react'; +import { Box, Tabs, Tab } from '@mui/material'; +import { Activity, Brain, ListChecks } from 'lucide-react'; import { TraceViewer } from '@renderer/components/trace/TraceViewer'; import { TokenUsage } from '@renderer/components/trace/TokenUsage'; import { AgentMonitor } from '@renderer/components/layout/AgentMonitor'; +import { MemoryViewer } from '@renderer/components/memory/MemoryViewer'; +import { TaskList } from '@renderer/components/tasks/TaskList'; +import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; import { LAYOUT } from '@renderer/lib/constants'; +type DetailTab = 'trace' | 'memory' | 'tasks'; + export function DetailPanel(): React.JSX.Element { + const [tab, setTab] = useState('trace'); + return ( + setTab(v as DetailTab)} + variant="fullWidth" + sx={{ + minHeight: 32, height: 32, flexShrink: 0, + borderBottom: 1, borderColor: 'divider', + '& .MuiTab-root': { minHeight: 32, height: 32, fontSize: 11, textTransform: 'none', py: 0, px: 1 }, + '& .MuiTabs-indicator': { height: 2 }, + }} + > + } iconPosition="start" label="Trace" value="trace" /> + } iconPosition="start" label="Memory" value="memory" /> + } iconPosition="start" label="Tasks" value="tasks" /> + + - - - + {tab === 'trace' && ( + + <> + + + + + + )} + {tab === 'memory' && ( + + + + )} + {tab === 'tasks' && ( + + + + )} ); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index cfd8165..e302e0a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { Box, Typography, Button, IconButton, TextField, Stack, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; import Fuse from 'fuse.js'; -import { Plus, Search, MessageSquare, Pin, Wrench, Database, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; +import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import { useSessionStore, type Session } from '@renderer/stores/session-store'; import { useAgentStore } from '@renderer/stores/agent-store'; import { formatRelativeTime } from '@renderer/lib/formatters'; @@ -78,7 +78,6 @@ export function Sidebar(): React.JSX.Element { - ); @@ -169,40 +168,3 @@ function ToolManagerPanel() { ); } - -function MemorySearchPanel() { - const [expanded, setExpanded] = useState(false); - const [query, setQuery] = useState(''); - const [results, setResults] = useState>([]); - const [searching, setSearching] = useState(false); - const handleSearch = async () => { if (!query.trim() || !window.metona?.memory?.search) return; setSearching(true); try { setResults((await window.metona.memory.search(query, { topK: 5 })) as any); } catch { setResults([]); } setSearching(false); }; - return ( - - setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}> - {expanded ? : } - 记忆搜索} /> - - - - - setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} /> - - - {results.length > 0 && ( - - {results.map((r) => ( - - - {r.type} - 重要度: {r.importance.toFixed(1)} - - {r.content.slice(0, 100)} - - ))} - - )} - - - - ); -} diff --git a/src/components/memory/MemoryViewer.tsx b/src/components/memory/MemoryViewer.tsx new file mode 100644 index 0000000..5323c34 --- /dev/null +++ b/src/components/memory/MemoryViewer.tsx @@ -0,0 +1,440 @@ +/** + * MemoryViewer — 记忆浏览器 + * + * 列出所有记忆(episodic/semantic/working),支持搜索、删除。 + * Agent 完成任务后自动刷新(监听 agentStatus: thinking|executing -> idle)。 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails, + IconButton, TextField, Chip, Alert, InputAdornment, +} from '@mui/material'; +import { Brain, Search, Trash2, ChevronDown } from 'lucide-react'; +import { useAgentStore } from '@renderer/stores/agent-store'; +import { formatTime, truncate } from '@renderer/lib/formatters'; + +// ===== 类型 ===== + +type MemoryType = 'episodic' | 'semantic' | 'working'; + +interface MemoryItem { + id: string; + type: MemoryType; + content: string; + summary?: string; + importance?: number; + createdAt?: number; + created_at?: number; +} + +interface SearchResult { + id: string; + type: MemoryType; + content: string; + summary?: string; + importance: number; + relevanceScore: number; + createdAt: number; +} + +// ===== 常量 ===== + +const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working']; + +const MEMORY_TYPE_LABELS: Record = { + episodic: '情景记忆', + semantic: '语义记忆', + working: '工作记忆', +}; + +const MEMORY_TYPE_COLORS: Record = { + episodic: '#22d3ee', + semantic: '#a855f7', + working: '#fbbf24', +}; + +function importanceColor(score: number): string { + if (score >= 0.8) return '#ef4444'; + if (score >= 0.5) return '#f59e0b'; + return '#64748b'; +} + +function getCreatedAt(item: MemoryItem): number { + return item.createdAt ?? item.created_at ?? 0; +} + +function getId(item: MemoryItem): string { + return item.id; +} + +// ===== 主组件 ===== + +export function MemoryViewer(): React.JSX.Element { + const [memories, setMemories] = useState>({ + episodic: [], semantic: [], working: [], + }); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [error, setError] = useState(null); + const [searching, setSearching] = useState(false); + const [expanded, setExpanded] = useState('all'); + + // Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle + const agentStatus = useAgentStore((s) => s.agentStatus); + const prevStatus = useRef(agentStatus); + + const loadMemories = useCallback(async () => { + if (!window.metona?.memory?.listAll) return; + try { + setError(null); + const res = await window.metona.memory.listAll(); + if (!res.success) { + setError(res.error ?? '加载记忆失败'); + return; + } + const data = res.data ?? {}; + setMemories({ + episodic: (data.episodic as MemoryItem[] | undefined) ?? [], + semantic: (data.semantic as MemoryItem[] | undefined) ?? [], + working: (data.working as MemoryItem[] | undefined) ?? [], + }); + } catch (err) { + setError((err as Error).message ?? '加载记忆失败'); + } + }, []); + + // 初次挂载加载 + useEffect(() => { + loadMemories(); + }, [loadMemories]); + + // Agent 完成自动刷新 + useEffect(() => { + const prev = prevStatus.current; + prevStatus.current = agentStatus; + if ((prev === 'thinking' || prev === 'executing') && agentStatus === 'idle') { + loadMemories(); + } + }, [agentStatus, loadMemories]); + + const handleSearch = async () => { + const q = searchQuery.trim(); + if (!q || !window.metona?.memory?.search) return; + setSearching(true); + setError(null); + try { + const results = await window.metona.memory.search(q, { topK: 20 }); + setSearchResults(results); + } catch (err) { + setError((err as Error).message ?? '搜索失败'); + setSearchResults([]); + } finally { + setSearching(false); + } + }; + + const handleClearSearch = () => { + setSearchQuery(''); + setSearchResults(null); + }; + + const handleDelete = async (type: MemoryType, id: string) => { + if (!window.metona?.memory?.delete) return; + try { + setError(null); + const res = await window.metona.memory.delete(type, id); + if (!res.success) { + setError(res.error ?? '删除失败'); + return; + } + await loadMemories(); + } catch (err) { + setError((err as Error).message ?? '删除失败'); + } + }; + + const totalCount = + memories.episodic.length + memories.semantic.length + memories.working.length; + + // 搜索结果视图 + if (searchResults !== null) { + return ( + + + + + 记忆搜索 + + + {searchResults.length} 条结果 + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} + placeholder="搜索记忆..." + sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: searchQuery ? ( + + 清除 + + ) : null, + }, + }} + /> + + {error && {error}} + + + {searching ? ( + + 搜索中... + + ) : searchResults.length === 0 ? ( + + 无匹配结果 + + ) : ( + searchResults.map((r) => ( + + )) + )} + + + ); + } + + // 全量列表视图 + return ( + + + + + 记忆库 + + + {totalCount} 条 + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} + placeholder="搜索记忆..." + sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + {error && {error}} + + + {totalCount === 0 ? ( + + 暂无记忆数据 + + ) : ( + MEMORY_TYPES.map((type) => { + const items = memories[type]; + if (items.length === 0) return null; + return ( + setExpanded(isExpanded ? type : 'all')} + elevation={0} + sx={{ + '&:before': { display: 'none' }, + '& .MuiAccordionSummary-root': { minHeight: 32, px: 0 }, + '& .MuiAccordionSummary-content': { my: 0, mx: 0 }, + '& .MuiAccordionDetails-root': { px: 0, py: 0 }, + }} + > + }> + + + + {MEMORY_TYPE_LABELS[type]} + + + + + + + {items.map((item) => ( + + ))} + + + + ); + }) + )} + + + ); +} + +// ===== 子组件 ===== + +function MemoryItemRow({ + item, + onDelete, +}: { + item: MemoryItem; + onDelete: (type: MemoryType, id: string) => void; +}): React.JSX.Element { + const type = item.type; + const createdAt = getCreatedAt(item); + const importance = item.importance ?? 0; + + return ( + + + + {typeof item.importance === 'number' && ( + + )} + {createdAt > 0 && ( + + {formatTime(createdAt)} + + )} + onDelete(type, getId(item))} + sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} + > + + + + + {truncate(item.content, 100)} + + + ); +} + +function MemorySearchItem({ + item, + onDelete, +}: { + item: SearchResult; + onDelete: (type: MemoryType, id: string) => void; +}): React.JSX.Element { + return ( + + + + + + {item.createdAt > 0 && ( + + {formatTime(item.createdAt)} + + )} + onDelete(item.type, item.id)} + sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }} + > + + + + + {truncate(item.content, 100)} + + + ); +} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 81d1b9a..748eafb 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -211,11 +211,53 @@ function AgentSettings() { } function ToolsSettings() { - const [tools, setTools] = useState>([]); - useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []); - const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); }; + const [tools, setTools] = useState>([]); + const [autoExecList, setAutoExecList] = useState([]); + + const loadTools = () => { + if (window.metona?.tools?.list) { + window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ + name: t.name, description: t.description, riskLevel: t.riskLevel, + requiresPermission: t.requiresPermission, enabled: t.enabled, + })))).catch((err) => { console.error('[SettingsModal]', err); }); + } + }; + const loadAutoExec = () => { + if (window.metona?.tool?.getAutoExecuteList) { + window.metona.tool.getAutoExecuteList().then((r) => { + if (r.success) setAutoExecList(r.data); + }).catch((err) => { console.error('[SettingsModal]', err); }); + } + }; + useEffect(() => { loadTools(); loadAutoExec(); }, []); + + const handleToggle = (name: string, enabled: boolean) => { + setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); + window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); + }; + + // 设置/取消自动执行 + const handleSetAutoExec = async (name: string, enabled: boolean) => { + if (!window.metona?.tool?.setAutoExecute) return; + const r = await window.metona.tool.setAutoExecute(name, enabled); + if (r.success) { + setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name)); + } + }; + const riskColors: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error' }; + // 需要确认的工具(high/critical 或 requiresPermission) + const needsConfirmTools = tools.filter((t) => + t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission, + ); + // 需要确认但未设为自动执行的工具 + const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name)); + // 已设为自动执行的工具详情 + const autoExecToolDetails = autoExecList + .map((name) => tools.find((t) => t.name === name)) + .filter((t): t is NonNullable => t !== undefined); + return ( 工具管理 @@ -231,6 +273,56 @@ function ToolsSettings() { ))} + + + + {/* ===== 自动执行工具管理 ===== */} + + 自动执行工具 + 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} /> + + + 已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。 + + + {/* 已自动执行的工具列表 */} + {autoExecToolDetails.length === 0 ? ( + + 暂无自动执行工具 + + ) : ( + autoExecToolDetails.map((t) => ( + + + {t.name} + + + + + )) + )} + + {/* 可设为自动执行的工具(需要确认但未设置) */} + {pendingConfirmTools.length > 0 && ( + <> + + 可设为自动执行(当前需要确认) + + {pendingConfirmTools.map((t) => ( + + + {t.name} + + + + + ))} + + )} ); } diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx new file mode 100644 index 0000000..2a044c5 --- /dev/null +++ b/src/components/tasks/TaskList.tsx @@ -0,0 +1,378 @@ +/** + * TaskList — 任务列表 + * + * 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Box, Typography, Stack, List, ListItem, ListItemIcon, + Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse, +} from '@mui/material'; +import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react'; +import { useAgentStore } from '@renderer/stores/agent-store'; +import { formatTime } from '@renderer/lib/formatters'; + +// ===== 类型 ===== + +type TaskStatus = MetonaTask['status']; +type TaskPriority = MetonaTask['priority']; + +// ===== 常量 ===== + +const STATUS_LABELS: Record = { + pending: '待处理', + in_progress: '进行中', + completed: '已完成', + blocked: '阻塞', + cancelled: '已取消', +}; + +const STATUS_COLORS: Record = { + pending: '#f59e0b', + in_progress: '#a855f7', + completed: '#22c55e', + blocked: '#ef4444', + cancelled: '#64748b', +}; + +const PRIORITY_LABELS: Record = { + low: '低', + medium: '中', + high: '高', + critical: '紧急', +}; + +const PRIORITY_COLORS: Record = { + low: '#64748b', + medium: '#3b82f6', + high: '#f97316', + critical: '#ef4444', +}; + +const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'critical']; + +// ===== 主组件 ===== + +export function TaskList(): React.JSX.Element { + const sessionId = useAgentStore((s) => s.currentSessionId); + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newPriority, setNewPriority] = useState('medium'); + const [expandedId, setExpandedId] = useState(null); + const [creating, setCreating] = useState(false); + + const loadTasks = useCallback(async (sid?: string) => { + if (!window.metona?.tasks?.list) return; + try { + setError(null); + const res = await window.metona.tasks.list(sid); + if (!res.success) { + setError('加载任务失败'); + return; + } + const list = (res.data ?? []).slice().sort((a, b) => { + if (a.order_idx !== b.order_idx) return a.order_idx - b.order_idx; + return a.created_at - b.created_at; + }); + setTasks(list); + } catch (err) { + setError((err as Error).message ?? '加载任务失败'); + } + }, []); + + useEffect(() => { + loadTasks(sessionId ?? undefined); + }, [sessionId, loadTasks]); + + const handleCreate = async () => { + if (!sessionId) { + setError('请先选择或创建会话'); + return; + } + if (!newTitle.trim()) { + setError('任务标题不能为空'); + return; + } + if (!window.metona?.tasks?.create) return; + setCreating(true); + setError(null); + try { + const res = await window.metona.tasks.create({ + sessionId, + title: newTitle.trim(), + priority: newPriority, + }); + if (!res.success) { + setError(res.error ?? '创建任务失败'); + return; + } + setNewTitle(''); + setNewPriority('medium'); + setShowAddForm(false); + await loadTasks(sessionId); + } catch (err) { + setError((err as Error).message ?? '创建任务失败'); + } finally { + setCreating(false); + } + }; + + const handleToggleComplete = async (task: MetonaTask) => { + if (!window.metona?.tasks?.update) return; + const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed'; + setError(null); + try { + const res = await window.metona.tasks.update(task.id, { status: nextStatus }); + if (!res.success) { + setError(res.error ?? '更新任务失败'); + return; + } + await loadTasks(sessionId ?? undefined); + } catch (err) { + setError((err as Error).message ?? '更新任务失败'); + } + }; + + const handleDelete = async (id: string) => { + if (!window.metona?.tasks?.delete) return; + setError(null); + try { + const res = await window.metona.tasks.delete(id); + if (!res.success) { + setError(res.error ?? '删除任务失败'); + return; + } + if (expandedId === id) setExpandedId(null); + await loadTasks(sessionId ?? undefined); + } catch (err) { + setError((err as Error).message ?? '删除任务失败'); + } + }; + + const toggleExpand = (id: string) => { + setExpandedId(expandedId === id ? null : id); + }; + + return ( + + + + + 任务列表 + + + {tasks.length} 项 + + setShowAddForm(!showAddForm)} + sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }} + title="新增任务" + > + + + + + {error && ( + + {error} + + )} + + + + setNewTitle(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} + placeholder="任务标题..." + sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }} + /> + + + + { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }} + sx={{ p: 0.5, color: 'text.secondary' }} + > + + + + + + + + {!sessionId ? ( + + 请先选择会话 + + ) : tasks.length === 0 ? ( + + 暂无任务,点击 + 创建 + + ) : ( + + {tasks.map((task) => ( + handleToggleComplete(task)} + onToggleExpand={() => toggleExpand(task.id)} + onDelete={() => handleDelete(task.id)} + /> + ))} + + )} + + + ); +} + +// ===== 子组件 ===== + +function TaskItemRow({ + task, + expanded, + onToggleComplete, + onToggleExpand, + onDelete, +}: { + task: MetonaTask; + expanded: boolean; + onToggleComplete: () => void; + onToggleExpand: () => void; + onDelete: () => void; +}): React.JSX.Element { + const isCompleted = task.status === 'completed'; + const statusColor = STATUS_COLORS[task.status]; + const priorityColor = PRIORITY_COLORS[task.priority]; + + return ( + + + + + + + + {task.title} + + + + + + {formatTime(task.created_at)} + + + + + + + + + + + {task.description ? task.description : ( + + 无描述 + + )} + + + + ); +} diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 9f3b8f5..92e8847 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -11,7 +11,7 @@ */ import { useEffect, useRef } from 'react'; -import { useAgentStore, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store'; +import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store'; /** * Agent 流式事件监听 Hook @@ -40,6 +40,26 @@ export function useAgentStream(): void { state?: string; }; + // 会话过滤:忽略非当前会话的事件(防止切换会话后旧事件污染) + if (data.sessionId && data.sessionId !== useAgentStore.getState().currentSessionId) return; + + // H-6/C-4: runId 过滤 — 忽略旧 run 的事件(abort 后重发场景) + // 修复 abort 后旧 DONE 污染新 run:currentRunId=null 时忽略终止事件 + const currentState = useAgentStore.getState(); + if (data.runId) { + if (currentState.currentRunId) { + // currentRunId 已设置:只接受匹配的事件 + if (data.runId !== currentState.currentRunId) return; + } else { + // currentRunId 未设置: + // done/error 是终止事件 — currentRunId 为 null 说明已被 abort 或尚未开始, + // 忽略旧 run 的延迟终止事件,避免错误地 setStreaming(false) + if (data.type === 'done' || data.type === 'error') return; + // 首个活跃事件,设置 currentRunId + currentState.setCurrentRunId(data.runId); + } + } + // 每次都从 store 读取最新状态(避免闭包捕获过期快照) const getStore = () => useAgentStore.getState(); @@ -47,6 +67,33 @@ export function useAgentStream(): void { // 推理内容增量 case 'reasoning_delta': if (data.delta) { + // C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序) + if (data.iteration != null) { + const msgs = getStore().messages; + const last = msgs[msgs.length - 1]; + const needsNewCard = !last || last.role !== 'assistant' || + (last.iteration != null && last.iteration !== data.iteration); + if (needsNewCard) { + getStore().addMessage({ + id: genMsgId('assistant'), + role: 'assistant', + content: '', + reasoningContent: data.delta, + timestamp: Date.now(), + iteration: data.iteration, + }); + if (data.iteration !== getStore().currentIteration) { + getStore().setCurrentIteration(data.iteration); + } + // 同步更新当前 Trace 步骤的 thought 字段 + const ts = getStore().traceSteps; + const step = ts[ts.length - 1]; + if (step) { + getStore().updateLastTraceStep({ thought: (step.thought ?? '') + data.delta }); + } + break; + } + } const messages = getStore().messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === 'assistant') { @@ -57,7 +104,7 @@ export function useAgentStream(): void { } else { // 还没有 assistant 消息,先创建一条(仅含思考内容) getStore().addMessage({ - id: `msg_${Date.now()}_assistant`, + id: genMsgId('assistant'), role: 'assistant', content: '', reasoningContent: data.delta, @@ -80,7 +127,27 @@ export function useAgentStream(): void { // 文本增量 case 'text_delta': if (data.delta) { - getStore().setStreaming(true); + // C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序) + if (data.iteration != null) { + const msgs = getStore().messages; + const last = msgs[msgs.length - 1]; + const needsNewCard = !last || last.role !== 'assistant' || + (last.iteration != null && last.iteration !== data.iteration); + if (needsNewCard) { + getStore().addMessage({ + id: genMsgId('assistant'), + role: 'assistant', + content: data.delta, + timestamp: Date.now(), + iteration: data.iteration, + }); + if (data.iteration !== getStore().currentIteration) { + getStore().setCurrentIteration(data.iteration); + } + break; + } + } + // isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置 getStore().updateLastAssistantMessage(data.delta); // 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪) @@ -97,6 +164,30 @@ export function useAgentStream(): void { } break; + // M-1: 工具调用增量(流式参数拼接)— 仅更新 UI 占位,完整调用由 tool_call_complete 处理 + case 'tool_call_delta': + if (data.toolCallDelta) { + const msgs = getStore().messages; + const lastAssistant = msgs[msgs.length - 1]; + const { index, name } = data.toolCallDelta; + // 如果最后一条 assistant 消息还没有该 index 的占位工具调用,添加一个 pending 占位 + if (lastAssistant?.role === 'assistant' && name) { + const existing = (lastAssistant.toolCalls ?? []).find((_, i) => i === index); + if (!existing) { + const placeholder: ToolCallInfo = { + id: `tc_pending_${index}`, + name, + args: {}, + status: 'pending', + }; + getStore().updateMessage(lastAssistant.id, { + toolCalls: [...(lastAssistant.toolCalls ?? []), placeholder], + }); + } + } + } + break; + // 工具调用完成 case 'tool_call_complete': if (data.toolCall) { @@ -107,13 +198,22 @@ export function useAgentStream(): void { status: 'executing', }; - // 追加到当前 assistant 消息 + // 追加到当前 assistant 消息(替换同 index 的 pending 占位) const msgs = getStore().messages; const lastAssistant = msgs[msgs.length - 1]; if (lastAssistant?.role === 'assistant') { - getStore().updateMessage(lastAssistant.id, { - toolCalls: [...(lastAssistant.toolCalls ?? []), tc], - }); + const existingTcs = lastAssistant.toolCalls ?? []; + // 检查是否有同 index 的 pending 占位需要替换 + const pendingIdx = existingTcs.findIndex((t) => t.id.startsWith('tc_pending_')); + if (pendingIdx >= 0) { + const updated = [...existingTcs]; + updated[pendingIdx] = tc; + getStore().updateMessage(lastAssistant.id, { toolCalls: updated }); + } else { + getStore().updateMessage(lastAssistant.id, { + toolCalls: [...existingTcs, tc], + }); + } } // 更新当前 Trace 步骤(添加工具调用信息) @@ -165,7 +265,8 @@ export function useAgentStream(): void { } : tc, ); - getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls }); + // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) + getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls }); break; } } @@ -197,7 +298,11 @@ export function useAgentStream(): void { // 流结束 case 'done': getStore().setStreaming(false); - getStore().setAgentStatus('idle'); + getStore().setCurrentRunId(null); + // 不覆盖 error 状态 — error handler 已设置 agentStatus='error' + if (getStore().agentStatus !== 'error') { + getStore().setAgentStatus('idle'); + } // 标记最后一个 Trace 步骤为已完成 getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().saveTraceData(); @@ -206,10 +311,11 @@ export function useAgentStream(): void { // 错误 case 'error': getStore().setStreaming(false); + getStore().setCurrentRunId(null); getStore().setAgentStatus('error'); getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().addMessage({ - id: `msg_${Date.now()}_error`, + id: genMsgId('error'), role: 'system', content: `错误: ${data.error?.message ?? '未知错误'}`, timestamp: Date.now(), @@ -236,31 +342,60 @@ export function useAgentStream(): void { state?: string; previous?: string; current?: string; + runId?: string; }; const store = useAgentStore.getState(); + // 会话过滤:忽略非当前会话的状态变化 + if (data.sessionId && data.sessionId !== store.currentSessionId) return; + + // H-6/C-4: runId 过滤 — 忽略旧 run 的状态变化 + // 修复 abort 后旧 TERMINATED 污染:currentRunId=null 时只有 INIT 设置 currentRunId,其他忽略 + if (data.runId) { + if (store.currentRunId) { + // currentRunId 已设置:只接受匹配的状态变化 + if (data.runId !== store.currentRunId) return; + } else { + // currentRunId 未设置: + // 只有 INIT 是新 run 的第一个状态,设置 currentRunId; + // 其他状态(如旧 run 的 TERMINATED)忽略,避免污染 + if (data.state === 'INIT') { + store.setCurrentRunId(data.runId); + } else { + return; + } + } + } + // --- 迭代号更新 + 新消息卡片创建 --- if (data.iteration != null && data.iteration !== store.currentIteration) { const prevIteration = store.currentIteration; store.setCurrentIteration(data.iteration); + // M-7: 统一所有迭代的卡片创建路径(包括首轮) // 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片 - // 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时 - if (data.iteration > prevIteration && prevIteration > 0) { + // 如果最后一条已经是当前迭代的 assistant 卡片(delta handler 提前创建),不重复 + if (data.iteration > prevIteration) { const messages = store.messages; const lastMsg = messages[messages.length - 1]; - if ( - lastMsg?.role === 'assistant' && - (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent) - ) { - store.addMessage({ - id: `msg_${Date.now()}_assistant`, - role: 'assistant', - content: '', - timestamp: Date.now(), - iteration: data.iteration, - }); + const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration; + + if (!isAlreadyCurrentIteration) { + // 首轮不要求上一轮有内容(上一轮是用户消息) + const isFirstIteration = prevIteration === 0; + const prevHasContent = lastMsg?.role === 'assistant' && + (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent); + + if (isFirstIteration || prevHasContent) { + store.addMessage({ + id: genMsgId('assistant'), + role: 'assistant', + content: '', + timestamp: Date.now(), + iteration: data.iteration, + }); + } } } } @@ -303,13 +438,14 @@ export function useAgentStream(): void { } // --- Agent 状态映射 --- + // INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态 const stateToStatus: Record = { THINKING: 'thinking', EXECUTING: 'executing', PARSING: 'thinking', - OBSERVING: 'thinking', + OBSERVING: 'thinking', // L-4: 观察工具结果更接近"思考"而非"执行" + REFLECTING: 'thinking', COMPRESSING: 'thinking', - INIT: 'thinking', TERMINATED: 'idle', }; const newStatus = stateToStatus[data.state]; @@ -327,9 +463,12 @@ export function useAgentStream(): void { if (!window.metona?.agent?.onProviderSwitched) return; const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => { - const { from, to, reason } = data as { from?: string; to?: string; reason?: string }; - useAgentStore.getState().addMessage({ - id: `msg_${Date.now()}_system`, + const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string }; + // L-3: 仅在当前会话中显示 Provider 切换消息 + const store = useAgentStore.getState(); + if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return; + store.addMessage({ + id: genMsgId('system'), role: 'system', content: `Provider 已切换: ${from ?? '未知'} → ${to ?? '未知'}${reason ? ` (${reason})` : ''}`, timestamp: Date.now(), diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 0babb53..5693a46 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -53,6 +53,7 @@ export function formatDuration(ms: number): string { // ===== 截断文本(< 20 行纯函数,允许自写)===== export function truncate(text: string, maxLength: number): string { + if (typeof text !== 'string' || text.length === 0) return ''; if (text.length <= maxLength) return text; return text.slice(0, maxLength - 1) + '…'; } diff --git a/src/lib/ipc-client.ts b/src/lib/ipc-client.ts deleted file mode 100644 index a18f93a..0000000 --- a/src/lib/ipc-client.ts +++ /dev/null @@ -1,181 +0,0 @@ -// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring. - -/** - * IPC Client — 渲染进程 IPC 封装 - * - * 为渲染进程提供类型安全的 IPC 调用封装。 - * 所有调用通过 window.metona 桥接层(由 preload.ts contextBridge 暴露)。 - * - * @see electron/preload.ts — contextBridge 安全暴露 - * @see src/types/global.d.ts — 类型声明 - */ - -// DEPRECATED: Components use window.metona directly. This file is kept for future type-safe refactoring. -// Types are now globally available via src/types/global.d.ts (script file, no import needed). - - -// ===== 桥接层访问 ===== - -function getBridge(): MetonaBridge { - if (!window.metona) { - throw new Error('Metona bridge not available. Ensure preload script is loaded.'); - } - return window.metona; -} - -// ===== Agent API ===== - -export const agentAPI = { - /** - * 发送用户消息到 Agent - */ - sendMessage(message: { role: 'user'; content: string; timestamp: number }, sessionId: string): Promise<{ success: boolean }> { - return getBridge().agent.sendMessage(message, sessionId); - }, - - /** - * 监听流式事件 - * @returns 取消监听函数 - */ - onStreamEvent(callback: (event: MetonaStreamEventData) => void): () => void { - return getBridge().agent.onStreamEvent(callback); - }, - - /** - * 监听状态变化 - * @returns 取消监听函数 - */ - onStateChange(callback: (state: { sessionId: string; state: string }) => void): () => void { - return getBridge().agent.onStateChange(callback); - }, - - /** - * 中断当前会话 - */ - abortSession(sessionId: string): Promise<{ success: boolean }> { - return getBridge().agent.abortSession(sessionId); - }, - - /** - * 监听 Provider 切换通知 - * @returns 取消监听函数 - */ - onProviderSwitched(callback: (data: { from?: string; to?: string; reason?: string }) => void): () => void { - return getBridge().agent.onProviderSwitched(callback); - }, -}; - -// ===== Sessions API ===== - -export const sessionsAPI = { - list(): Promise { - return getBridge().sessions.list(); - }, - - create(title?: string): Promise { - return getBridge().sessions.create(title); - }, - - rename(sessionId: string, title: string): Promise<{ success: boolean }> { - return getBridge().sessions.rename(sessionId, title); - }, - - delete(sessionId: string): Promise<{ success: boolean }> { - return getBridge().sessions.delete(sessionId); - }, - - getMessages(sessionId: string): Promise> { - return getBridge().sessions.getMessages(sessionId); - }, - - pin(sessionId: string, pinned: boolean): Promise<{ success: boolean }> { - return getBridge().sessions.pin(sessionId, pinned); - }, -}; - -// ===== MCP API ===== - -export const mcpAPI = { - listServers(): Promise { - return getBridge().mcp.listServers(); - }, - - addServer(config: { name: string; transport: 'stdio' | 'sse'; command?: string; args?: string[]; url?: string; enabled: boolean }): Promise<{ success: boolean }> { - return getBridge().mcp.addServer(config); - }, - - removeServer(name: string): Promise<{ success: boolean }> { - return getBridge().mcp.removeServer(name); - }, - - toggleServer(name: string, enabled: boolean): Promise<{ success: boolean }> { - return getBridge().mcp.toggleServer(name, enabled); - }, -}; - -// ===== Memory API ===== - -export const memoryAPI = { - search(query: string, options?: { - topK?: number; - type?: 'episodic' | 'semantic' | 'working'; - minImportance?: number; - }): Promise { - return getBridge().memory.search(query, options); - }, -}; - -// ===== Config API ===== - -export const configAPI = { - get(key: string): Promise { - return getBridge().config.get(key) as Promise; - }, - - set(key: string, value: unknown): Promise<{ success: boolean }> { - return getBridge().config.set(key, value); - }, -}; - -// ===== App API ===== - -export const appAPI = { - getVersion(): Promise { - return getBridge().app.getVersion(); - }, - - getAppDataPath(): Promise { - return getBridge().app.getAppDataPath(); - }, - - openExternal(url: string): Promise { - return getBridge().app.openExternal(url); - }, - - showItemInFolder(path: string): Promise { - return getBridge().app.showItemInFolder(path); - }, -}; - -// ===== Toast API ===== - -export const toastAPI = { - /** - * 监听主进程 Toast 通知桥接 - * @returns 取消监听函数 - */ - onShow(callback: (data: { - type: 'success' | 'error' | 'warning' | 'info'; - message: string; - options?: Record; - }) => void): () => void { - return getBridge().toast.onShow(callback); - }, -}; diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index cb2ac91..86f953d 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -7,6 +7,10 @@ import { create } from 'zustand'; import { useSessionStore } from './session-store'; +// L-1: 消息 ID 防碰撞计数器 +let _msgIdCounter = 0; +export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`; + // ===== 消息类型 ===== export type MessageRole = 'user' | 'assistant' | 'system' | 'tool'; @@ -85,7 +89,9 @@ interface AgentState { // 流式 isStreaming: boolean; - streamingContent: string; + + // Run 标识(用于过滤旧流事件) + currentRunId: string | null; // Provider provider: string; @@ -101,10 +107,10 @@ interface AgentState { updateLastAssistantMessage: (delta: string) => void; setAgentStatus: (status: AgentStatus) => void; setStreaming: (streaming: boolean) => void; - appendStreamingContent: (delta: string) => void; - clearStreamingContent: () => 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; setProvider: (provider: string, model: string) => void; @@ -125,7 +131,7 @@ export const useAgentStore = create((set, get) => ({ tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, traceSteps: [], isStreaming: false, - streamingContent: '', + currentRunId: null, provider: '', model: '', contextWindow: 0, @@ -133,7 +139,7 @@ export const useAgentStore = create((set, get) => ({ // ===== Actions ===== setCurrentSession: (id) => { - set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } }); + set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null }); // 从数据库加载该会话的消息 if (id && window.metona?.sessions?.getMessages) { @@ -208,7 +214,7 @@ export const useAgentStore = create((set, get) => ({ console.error('[AgentStore]', 'Failed to create session:', err); set({ agentStatus: 'error', isStreaming: false }); get().addMessage({ - id: `msg_${Date.now()}_error`, + id: genMsgId('error'), role: 'system', content: `错误: 无法创建会话 — ${(err as Error).message}`, timestamp: Date.now(), @@ -218,7 +224,7 @@ export const useAgentStore = create((set, get) => ({ } const userMessage: ChatMessage = { - id: `msg_${Date.now()}_user`, + id: genMsgId('user'), role: 'user', content, // 用户可见内容(纯文本) timestamp: Date.now(), @@ -229,7 +235,7 @@ export const useAgentStore = create((set, get) => ({ messages: [...s.messages, userMessage], agentStatus: 'thinking', isStreaming: true, - streamingContent: '', + currentRunId: null, // 将在首个 streamEvent 中由 runId 设置 currentIteration: 0, })); @@ -283,7 +289,7 @@ export const useAgentStore = create((set, get) => ({ console.error('[AgentStore]', 'IPC sendMessage failed:', err); set({ agentStatus: 'error', isStreaming: false }); get().addMessage({ - id: `msg_${Date.now()}_error`, + id: genMsgId('error'), role: 'system', content: `错误: 消息发送失败 — ${(err as Error).message}`, timestamp: Date.now(), @@ -302,7 +308,7 @@ export const useAgentStore = create((set, get) => ({ messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta }; } else { messages.push({ - id: `msg_${Date.now()}_assistant`, + id: genMsgId('assistant'), role: 'assistant', content: delta, timestamp: Date.now(), @@ -317,10 +323,7 @@ export const useAgentStore = create((set, get) => ({ setStreaming: (streaming) => set({ isStreaming: streaming }), - appendStreamingContent: (delta) => - set((s) => ({ streamingContent: s.streamingContent + delta })), - - clearStreamingContent: () => set({ streamingContent: '' }), + setCurrentRunId: (runId) => set({ currentRunId: runId }), addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })), @@ -332,6 +335,14 @@ export const useAgentStore = create((set, get) => ({ ), })), + // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) + updateTraceStepById: (id, updates) => + set((s) => ({ + traceSteps: s.traceSteps.map((t) => + t.id === id ? { ...t, ...updates } : t, + ), + })), + updateLastTraceStep: (updates) => set((s) => { if (s.traceSteps.length === 0) return s; @@ -376,11 +387,13 @@ export const useAgentStore = create((set, get) => ({ tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, traceSteps: [], isStreaming: false, - streamingContent: '', + currentRunId: null, }), abort: () => { const sessionId = get().currentSessionId; + // 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED) + // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 set({ agentStatus: 'idle', isStreaming: false }); if (sessionId && window.metona?.agent?.abortSession) { window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 936df55..d86d4d7 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -59,7 +59,13 @@ interface MetonaAgentAPI { /** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */ sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>; onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void; - onStateChange: (callback: (state: { sessionId: string; state: string }) => void) => () => void; + onStateChange: (callback: (state: { + sessionId: string | null; + iteration: number; + state: string; + previous: string; + current: string; + }) => void) => () => void; abortSession: (sessionId: string) => Promise<{ success: boolean }>; onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void; } @@ -93,6 +99,7 @@ interface MetonaSessionsAPI { timestamp: number; }>>; pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>; + archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean }>; deleteMessage: (messageId: string) => Promise<{ success: boolean }>; clearMessages: (sessionId: string) => Promise<{ success: boolean }>; saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>; @@ -142,6 +149,12 @@ interface MetonaMemoryAPI { type?: 'episodic' | 'semantic' | 'working'; minImportance?: number; }) => Promise; + listAll: (options?: { type?: string; limit?: number }) => Promise<{ + success: boolean; + data?: { episodic?: unknown[]; semantic?: unknown[]; working?: unknown[] }; + error?: string; + }>; + delete: (type: string, id: string) => Promise<{ success: boolean; error?: string }>; } // ===== Config API ===== @@ -210,6 +223,86 @@ interface MetonaDataAPI { clearAuditLogs: () => Promise<{ success: boolean; error?: string }>; } +// ===== v0.2.0: Tasks API ===== + +interface MetonaTask { + id: string; + session_id: string; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled'; + priority: 'low' | 'medium' | 'high' | 'critical'; + parent_id: string | null; + assigned_to: string | null; + order_idx: number; + created_at: number; + updated_at: number; + completed_at: number | null; +} + +interface MetonaTasksAPI { + list: (sessionId?: string) => Promise<{ success: boolean; data: MetonaTask[] }>; + create: (data: { + sessionId: string; + title: string; + description?: string; + priority?: string; + parentId?: string; + }) => Promise<{ success: boolean; id?: string; error?: string }>; + update: ( + id: string, + updates: { + title?: string; + description?: string; + status?: string; + priority?: string; + assignedTo?: string; + }, + ) => Promise<{ success: boolean; error?: string }>; + delete: (id: string) => Promise<{ success: boolean; error?: string }>; +} + +// ===== v0.2.0: Audit API ===== + +interface MetonaAuditVerifyResult { + success: boolean; + valid: boolean; + totalRecords: number; + verifiedRecords: number; + tamperedId: number | null; + error?: string; +} + +interface MetonaAuditAPI { + verifyChain: () => Promise; + query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) => + Promise<{ success: boolean; data?: unknown[]; error?: string }>; +} + +// ===== v0.2.0: Tool Confirmation API ===== + +interface MetonaConfirmationRequest { + toolCallId: string; + toolName: string; + args: Record; + riskLevel: string; + reason: string; +} + +interface MetonaToolAPI { + onConfirmationRequest: (callback: (request: MetonaConfirmationRequest) => void) => () => void; + sendConfirmationResponse: (response: { + toolCallId: string; + approved: boolean; + remember: boolean; + autoExecute?: boolean; + }) => void; + /** 设置/取消工具的持久化自动执行(跨会话不再询问) */ + setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; + /** 获取已设置为自动执行的工具列表 */ + getAutoExecuteList: () => Promise<{ success: boolean; data: string[] }>; +} + // ===== Bridge 接口 ===== interface MetonaBridge { @@ -223,6 +316,9 @@ interface MetonaBridge { tools: MetonaToolsAPI; data: MetonaDataAPI; searxng: MetonaSearXNGAPI; + tasks: MetonaTasksAPI; + audit: MetonaAuditAPI; + tool: MetonaToolAPI; } // ===== 全局 Window 增强 =====