feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - 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
This commit is contained in:
@@ -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["🔄 训练循环<br/>数据采集 → 梯度更新 → 效果评估 → 版本发布<br/>离线流程 · 周期以天/周计"]
|
||||||
|
|
||||||
|
AL["🔄 Agentic Loop(智能体循环)<br/>推理 → 行动 → 观察 → 再推理<br/>在线实时流程 · 以秒计"]
|
||||||
|
|
||||||
|
FL["🔄 反馈循环<br/>工具返回 → 用户修正 → 量化指标 → 评估监控"]
|
||||||
|
|
||||||
|
HL["🔄 人工介入循环<br/>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
|
||||||
|
> 📖 **本文档基于公开技术资料整理,涵盖概念解析、分层体系、代码实现、工程实践与行业趋势
|
||||||
@@ -65,6 +65,12 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
private currentSessionId: string = '';
|
private currentSessionId: string = '';
|
||||||
private currentRequestId: string = '';
|
private currentRequestId: string = '';
|
||||||
private workspacePath: string = '';
|
private workspacePath: string = '';
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private eventSeq = 0;
|
||||||
|
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
|
||||||
|
private runId: string = '';
|
||||||
|
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
|
||||||
|
private currentRunPromise: Promise<AgentLoopOutput> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: Partial<AgentLoopConfig> = {},
|
config: Partial<AgentLoopConfig> = {},
|
||||||
@@ -127,13 +133,34 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
history: MetonaMessage[],
|
history: MetonaMessage[],
|
||||||
systemPrompt: MetonaSystemPrompt,
|
systemPrompt: MetonaSystemPrompt,
|
||||||
|
): Promise<AgentLoopOutput> {
|
||||||
|
// 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<AgentLoopOutput> {
|
): Promise<AgentLoopOutput> {
|
||||||
this.startTime = Date.now();
|
this.startTime = Date.now();
|
||||||
this.aborted = false;
|
this.aborted = false;
|
||||||
|
this.runId = `run_${nanoid(8)}`;
|
||||||
this.iterations = [];
|
this.iterations = [];
|
||||||
this.currentIteration = 0;
|
this.currentIteration = 0;
|
||||||
this.currentSessionId = sessionId;
|
this.currentSessionId = sessionId;
|
||||||
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
this.eventSeq = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transitionTo(AgentLoopState.INIT);
|
await this.transitionTo(AgentLoopState.INIT);
|
||||||
@@ -220,7 +247,11 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
return this.finish(TerminationReason.MAX_ITERATIONS);
|
return this.finish(TerminationReason.MAX_ITERATIONS);
|
||||||
return this.finish(TerminationReason.TIMEOUT);
|
return this.finish(TerminationReason.TIMEOUT);
|
||||||
} catch (error) {
|
} 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);
|
return this.finish(TerminationReason.ERROR, undefined, error as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,9 +269,19 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
/** 中断循环 */
|
/** 中断循环 */
|
||||||
abort(): void {
|
abort(): void {
|
||||||
this.aborted = true;
|
this.aborted = true;
|
||||||
|
this.abortController?.abort();
|
||||||
|
this.abortController = null;
|
||||||
this.emit('aborted');
|
this.emit('aborted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁引擎,清理所有监听器
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.abort();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取当前状态 */
|
/** 获取当前状态 */
|
||||||
getState(): AgentLoopState {
|
getState(): AgentLoopState {
|
||||||
return this.currentState;
|
return this.currentState;
|
||||||
@@ -279,8 +320,18 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
||||||
if (event.type === MetonaStreamEventType.DONE) continue;
|
if (event.type === MetonaStreamEventType.DONE) continue;
|
||||||
|
|
||||||
// 转发流式事件到渲染进程
|
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||||||
this.emit('streamEvent', event);
|
// 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) {
|
switch (event.type) {
|
||||||
case MetonaStreamEventType.TEXT_DELTA:
|
case MetonaStreamEventType.TEXT_DELTA:
|
||||||
@@ -322,6 +373,7 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MetonaStreamEventType.ERROR:
|
case MetonaStreamEventType.ERROR:
|
||||||
|
// RETRY 已在循环入口过滤,此处只处理真正的错误
|
||||||
if (event.error) {
|
if (event.error) {
|
||||||
throw new Error(event.error.message);
|
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 拼接)
|
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
|
||||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||||
step.toolCalls = [];
|
step.toolCalls = [];
|
||||||
@@ -376,29 +431,89 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||||
const result = await this.executeToolSafely(tc);
|
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<null>((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<MetonaToolResult>[]
|
||||||
|
| 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', {
|
this.emit('streamEvent', {
|
||||||
type: MetonaStreamEventType.TOOL_RESULT,
|
type: MetonaStreamEventType.TOOL_RESULT,
|
||||||
requestId: request.meta.requestId,
|
requestId: request.meta.requestId,
|
||||||
sessionId,
|
sessionId,
|
||||||
iteration: this.currentIteration,
|
iteration: this.currentIteration,
|
||||||
seq: 0,
|
seq: this.nextSeq(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
toolResult: result,
|
toolResult: errorResult,
|
||||||
|
runId: this.runId,
|
||||||
});
|
});
|
||||||
|
return errorResult;
|
||||||
return result;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// Promise.all 保持结果顺序与 toolCalls 一致
|
|
||||||
step.toolResults = await Promise.all(
|
|
||||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === OBSERVING ===
|
// === OBSERVING ===
|
||||||
await this.transitionTo(AgentLoopState.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 使用率触发) ===
|
// === 上下文压缩(基于 token 使用率触发) ===
|
||||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
|
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
|
||||||
@@ -416,6 +531,8 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 压缩后回到 OBSERVING
|
||||||
|
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||||
}
|
}
|
||||||
|
|
||||||
step.completedAt = Date.now();
|
step.completedAt = Date.now();
|
||||||
@@ -455,13 +572,37 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行工具
|
// 执行工具(带超时)
|
||||||
const toolResult = await this.toolRegistry.execute(toolCall, {
|
const toolTimeout = 120_000; // 单个工具最大 2 分钟
|
||||||
sessionId: this.currentSessionId ?? '',
|
let toolResult: MetonaToolResult;
|
||||||
workspacePath: this.workspacePath,
|
try {
|
||||||
iteration: this.currentIteration,
|
toolResult = await Promise.race([
|
||||||
requestId: this.currentRequestId,
|
this.toolRegistry.execute(toolCall, {
|
||||||
});
|
sessionId: this.currentSessionId ?? '',
|
||||||
|
workspacePath: this.workspacePath,
|
||||||
|
iteration: this.currentIteration,
|
||||||
|
requestId: this.currentRequestId,
|
||||||
|
}),
|
||||||
|
new Promise<MetonaToolResult>((_, 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 管道
|
// 后置 Hook 管道
|
||||||
for (const hook of this.postToolHooks) {
|
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<MetonaStreamEvent> {
|
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
const baseDelayMs = 1_000;
|
||||||
|
const maxDelayMs = 30_000;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
|
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
|
||||||
try {
|
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);
|
yield* this.adapter.chatStream(request);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
if (this.aborted) throw error;
|
||||||
if (attempt < this.config.retryCount) {
|
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;
|
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<void> {
|
private async transitionTo(state: AgentLoopState): Promise<void> {
|
||||||
const previous = this.currentState;
|
const previous = this.currentState;
|
||||||
this.currentState = state;
|
this.currentState = state;
|
||||||
@@ -501,6 +710,7 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
state,
|
state,
|
||||||
sessionId: this.currentSessionId,
|
sessionId: this.currentSessionId,
|
||||||
iteration: this.currentIteration,
|
iteration: this.currentIteration,
|
||||||
|
runId: this.runId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,11 +820,22 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
requestId: this.currentRequestId,
|
requestId: this.currentRequestId,
|
||||||
sessionId: this.currentSessionId,
|
sessionId: this.currentSessionId,
|
||||||
iteration: this.currentIteration,
|
iteration: this.currentIteration,
|
||||||
seq: 0,
|
seq: this.nextSeq(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
runId: this.runId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||||||
|
const prevTerminated = this.currentState;
|
||||||
this.currentState = AgentLoopState.TERMINATED;
|
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 事件(供托盘通知等外部监听器使用)
|
// 发射 complete 事件(供托盘通知等外部监听器使用)
|
||||||
this.emit('complete', {
|
this.emit('complete', {
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
riskLevel: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConfirmationHook implements PreToolHook {
|
||||||
|
/** 需要确认的风险等级 */
|
||||||
|
private static REQUIRES_CONFIRMATION = ['high', 'critical'];
|
||||||
|
|
||||||
|
/** 工具定义缓存(由外部设置) */
|
||||||
|
private toolDefs = new Map<string, MetonaToolDef>();
|
||||||
|
|
||||||
|
/** 用户选择记忆(同一会话内不再重复询问) */
|
||||||
|
private rememberedDecisions = new Map<string, boolean>();
|
||||||
|
|
||||||
|
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
|
||||||
|
private autoExecuteTools = new Set<string>();
|
||||||
|
|
||||||
|
/** 等待确认的 Promise 解析器 */
|
||||||
|
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => 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<HookResult> {
|
||||||
|
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<boolean> {
|
||||||
|
return new Promise<boolean>((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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import type { MetonaToolCall, MetonaToolResult } from '../types';
|
import type { MetonaToolCall, MetonaToolResult } from '../types';
|
||||||
import type { AuditService } from '../../services/audit.service';
|
import type { AuditService } from '../../services/audit.service';
|
||||||
import type { MemoryManager } from '../memory/manager';
|
import type { MemoryManager } from '../memory/manager';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
export interface PostToolHook {
|
export interface PostToolHook {
|
||||||
afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void>;
|
afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void>;
|
||||||
@@ -55,8 +56,7 @@ export class MemoryTriggerHook implements PostToolHook {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 记忆存储失败不应影响工具执行结果
|
// 记忆存储失败不应影响工具执行结果
|
||||||
// eslint-disable-next-line no-console
|
log.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
|
||||||
console.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export class RateLimitHook implements PreToolHook {
|
|||||||
|
|
||||||
constructor(private maxCallsPerMinute: number = 20) {}
|
constructor(private maxCallsPerMinute: number = 20) {}
|
||||||
|
|
||||||
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
|
||||||
const key = toolCall.name;
|
// 使用 sessionId:toolName 作为 key,实现会话隔离的 per-tool 速率限制
|
||||||
|
const key = `${sessionId}:${toolCall.name}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = this.callCounts.get(key);
|
const entry = this.callCounts.get(key);
|
||||||
if (entry && entry.resetTime >= now) {
|
if (entry && entry.resetTime >= now) {
|
||||||
|
|||||||
@@ -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<ConsolidationResult> {
|
||||||
|
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<string | null> {
|
||||||
|
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) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
* 基于 SQLite(better-sqlite3)的三层记忆系统。
|
* 基于 SQLite(better-sqlite3)的三层记忆系统。
|
||||||
* 表结构由 DatabaseService 统一创建,此处不再重复。
|
* 表结构由 DatabaseService 统一创建,此处不再重复。
|
||||||
*
|
*
|
||||||
|
* v0.2.0 增强:
|
||||||
|
* - TF-IDF 语义检索替代 LIKE 关键词搜索
|
||||||
|
* - 时间衰减策略:老旧记忆权重降低
|
||||||
|
* - IDF 缓存:避免每次搜索重新计算
|
||||||
|
*
|
||||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||||
* @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层)
|
* @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层)
|
||||||
*/
|
*/
|
||||||
@@ -38,17 +43,258 @@ interface MemorySearchOptions {
|
|||||||
minImportance?: number;
|
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<string, number> {
|
||||||
|
const tf = new Map<string, number>();
|
||||||
|
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<string, number>, tf2: Map<string, number>, idf: Map<string, number>): 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<string, number>, idf: Map<string, number>): 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 {
|
export class MemoryManager {
|
||||||
|
/** IDF 缓存:词项 -> 文档频率 */
|
||||||
|
private idfCache = new Map<string, number>();
|
||||||
|
/** 缓存的记忆总数 */
|
||||||
|
private cachedDocCount = 0;
|
||||||
|
/** 缓存最后更新时间 */
|
||||||
|
private cacheUpdatedAt = 0;
|
||||||
|
/** 缓存有效期(5 分钟) */
|
||||||
|
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
constructor(private getDB: () => Database.Database) {}
|
constructor(private getDB: () => Database.Database) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化(表结构由 DatabaseService 创建)
|
* 初始化(表结构由 DatabaseService 创建)
|
||||||
*/
|
*/
|
||||||
initialize(): void {
|
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<string, number>();
|
||||||
|
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使 IDF 缓存失效
|
||||||
|
this.cacheUpdatedAt = 0;
|
||||||
|
|
||||||
log.debug(`Memory stored: ${id} (${item.type})`);
|
log.debug(`Memory stored: ${id} (${item.type})`);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检索记忆(关键词搜索)
|
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减)
|
||||||
|
*
|
||||||
|
* v0.2.0 变更:
|
||||||
|
* - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索
|
||||||
|
* - 支持中英文分词(英文按词,中文按 bigram)
|
||||||
|
* - 时间衰减:30 天半衰期,老旧记忆权重降低
|
||||||
|
* - IDF 缓存:5 分钟有效期,避免重复计算
|
||||||
*/
|
*/
|
||||||
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
|
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
const { topK = 5, type, minImportance = 0 } = options;
|
const { topK = 5, type, minImportance = 0 } = options;
|
||||||
if (!query) return [];
|
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[] = [];
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
// 搜索情节记忆
|
// 搜索情节记忆
|
||||||
if (!type || type === 'episodic') {
|
if (!type || type === 'episodic') {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM episodic_memories
|
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 ?
|
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||||
`).all(pattern, pattern, minImportance, topK) as Array<{
|
`).all(pattern, pattern, minImportance, topK) as Array<{
|
||||||
id: string; session_id: string | null; content: string; summary: string | null;
|
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,
|
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||||
createdAt: row.created_at, expiresAt: row.expires_at ?? 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') {
|
if (!type || type === 'semantic') {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM semantic_memories
|
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 ?
|
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||||
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
||||||
id: string; key: string; value: string; category: string | null;
|
id: string; key: string; value: string; category: string | null;
|
||||||
@@ -132,7 +396,7 @@ export class MemoryManager {
|
|||||||
id: row.id, type: 'semantic', content: row.value,
|
id: row.id, type: 'semantic', content: row.value,
|
||||||
source: 'imported', importance: row.confidence,
|
source: 'imported', importance: row.confidence,
|
||||||
sessionId: row.source_session ?? undefined,
|
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') {
|
if (type === 'working') {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM working_memories
|
SELECT * FROM working_memories
|
||||||
WHERE (key LIKE ? OR value LIKE ?)
|
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
|
||||||
ORDER BY updated_at DESC LIMIT ?
|
ORDER BY updated_at DESC LIMIT ?
|
||||||
`).all(pattern, pattern, topK) as Array<{
|
`).all(pattern, pattern, topK) as Array<{
|
||||||
id: string; session_id: string; task_id: string;
|
id: string; session_id: string; task_id: string;
|
||||||
@@ -152,7 +416,7 @@ export class MemoryManager {
|
|||||||
id: row.id, type: 'working', content: row.value,
|
id: row.id, type: 'working', content: row.value,
|
||||||
source: 'agent_thought', importance: 0.5,
|
source: 'agent_thought', importance: 0.5,
|
||||||
sessionId: row.session_id, createdAt: row.updated_at,
|
sessionId: row.session_id, createdAt: row.updated_at,
|
||||||
score: 0.3, // 工作记忆权重较低
|
score: 0.3 * timeDecayWeight(row.updated_at),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
if (policy.deniedPatterns) {
|
||||||
const argsStr = JSON.stringify(args);
|
|
||||||
for (const pattern of policy.deniedPatterns) {
|
for (const pattern of policy.deniedPatterns) {
|
||||||
if (pattern.test(argsStr)) {
|
if (pattern.test(argsStr)) {
|
||||||
return {
|
return {
|
||||||
authorized: false,
|
authorized: false,
|
||||||
reason: `Arguments match denied pattern: ${pattern.source}`,
|
reason: 'Command blocked by security policy',
|
||||||
level: policy.requiredLevel,
|
level: policy.requiredLevel,
|
||||||
requiresConfirmation: false,
|
requiresConfirmation: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { resolve, sep } from 'path';
|
import { resolve, sep } from 'path';
|
||||||
|
import { existsSync, realpathSync } from 'fs';
|
||||||
|
|
||||||
export interface SandboxConfig {
|
export interface SandboxConfig {
|
||||||
allowedPaths?: string[];
|
allowedPaths?: string[];
|
||||||
@@ -49,6 +50,7 @@ export class SandboxManager {
|
|||||||
* 校验文件路径是否在白名单内
|
* 校验文件路径是否在白名单内
|
||||||
*
|
*
|
||||||
* 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。
|
* 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。
|
||||||
|
* 同时解析符号链接,防止通过 symlink 逃逸白名单。
|
||||||
*/
|
*/
|
||||||
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
|
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
|
||||||
const resolved = resolve(requestedPath);
|
const resolved = resolve(requestedPath);
|
||||||
@@ -57,6 +59,7 @@ export class SandboxManager {
|
|||||||
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
|
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先做字符串级白名单校验
|
||||||
const isAllowed = Array.from(this.allowedPaths).some(
|
const isAllowed = Array.from(this.allowedPaths).some(
|
||||||
(allowed) => resolved === allowed || resolved.startsWith(allowed + sep),
|
(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' };
|
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 };
|
return { allowed: true, resolvedPath: resolved };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 静态代码安全扫描
|
* 静态代码安全扫描
|
||||||
|
*
|
||||||
|
* 检测危险模块导入、代码执行、路径遍历、危险命令、反向 shell、
|
||||||
|
* fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。
|
||||||
*/
|
*/
|
||||||
scanCode(code: string): { safe: boolean; reason?: string } {
|
scanCode(code: string): { safe: boolean; reason?: string } {
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [
|
||||||
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
// 危险模块导入
|
||||||
/eval\s*\(/,
|
/require\s*\(\s*['"]child_process['"]\s*\)/i,
|
||||||
/process\.exit/,
|
/import\s+.*from\s+['"]fs['"]/i,
|
||||||
/import\s+.*from\s+['"]fs['"]/,
|
/import\s+.*from\s+['"]child_process['"]/i,
|
||||||
/\.\.\//,
|
// 代码执行
|
||||||
/rm\s+-rf/,
|
/\beval\s*\(/i,
|
||||||
/>\s*\/dev\/null/,
|
/process\.exit/i,
|
||||||
/curl.*\|\s*bash/,
|
/Function\s*\(/i,
|
||||||
/wget.*\|\s*sh/,
|
// 路径遍历
|
||||||
|
/\.\.\//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) {
|
for (const pattern of dangerousPatterns) {
|
||||||
if (pattern.test(code)) {
|
if (pattern.test(code)) {
|
||||||
return { safe: false, reason: `Dangerous pattern detected: ${pattern.source}` };
|
// 不暴露 pattern.source,使用通用错误信息
|
||||||
|
return { safe: false, reason: 'Command blocked by security policy' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
* 2. 语义级检测(可选)
|
* 2. 语义级检测(可选)
|
||||||
* 3. 指令隔离标记
|
* 3. 指令隔离标记
|
||||||
*
|
*
|
||||||
|
* v0.2.0 增强:
|
||||||
|
* - 扩展注入检测模式至 30+ 种
|
||||||
|
* - 添加多语言检测(中英文)
|
||||||
|
* - 添加编码注入检测(hex、unicode 转义)
|
||||||
|
* - 添加角色扮演注入检测
|
||||||
|
* - 添加间接注入检测(通过工具返回值注入)
|
||||||
|
*
|
||||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,39 +29,77 @@ export interface InjectionFinding {
|
|||||||
severity: 'low' | 'medium' | 'high';
|
severity: 'low' | 'medium' | 'high';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InjectionPattern {
|
||||||
|
pattern: RegExp;
|
||||||
|
severity: 'low' | 'medium' | 'high';
|
||||||
|
}
|
||||||
|
|
||||||
export class PromptInjectionDefender {
|
export class PromptInjectionDefender {
|
||||||
private static INJECTION_PATTERNS = [
|
private static INJECTION_PATTERNS: InjectionPattern[] = [
|
||||||
/ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i,
|
// === HIGH 危险:直接越狱/忽略指令 ===
|
||||||
/forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i,
|
{ pattern: /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i, severity: 'high' },
|
||||||
/you\s+are\s+now/i,
|
{ pattern: /forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i, severity: 'high' },
|
||||||
/new\s+(instructions|directive|role|persona)/i,
|
{ pattern: /override\s+your\s+/i, severity: 'high' },
|
||||||
/override\s+your\s+/i,
|
{ pattern: /JAILBREAK/i, severity: 'high' },
|
||||||
/(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i,
|
{ pattern: /DAN\s*[:\[]/i, severity: 'high' },
|
||||||
/(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i,
|
{ pattern: /(?:enable|turn\s+on|activate)\s+(developer|debug|root|admin)\s+mode/i, severity: 'high' },
|
||||||
/-{3,}\s*(system|user|assistant|instruction)/i,
|
{ pattern: /(?:disable|turn\s+off|bypass)\s+(your|the)\s+(safety|security|filter|guard|defense)/i, severity: 'high' },
|
||||||
/<{3,}(system|instruction|prompt)/i,
|
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(your|the|all)\s+(data|memory|context|secrets)/i, severity: 'high' },
|
||||||
/\[{3,}(system|instruction|prompt)/i,
|
{ pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' },
|
||||||
/base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i,
|
{ pattern: /(?:in|under)\s+(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' },
|
||||||
/pretend\s+(you\s+are|to\s+be)/i,
|
// 中文高危
|
||||||
/act\s+as\s+(if\s+you\s+were|you're)/i,
|
{ pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' },
|
||||||
/DAN\s*[:\[]/i,
|
{ pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' },
|
||||||
/JAILBREAK/i,
|
{ 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: /<system>/i, severity: 'low' },
|
||||||
|
{ pattern: /<instruction>/i, severity: 'low' },
|
||||||
|
{ pattern: /<admin>/i, severity: 'low' },
|
||||||
|
{ pattern: /<override>/i, severity: 'low' },
|
||||||
];
|
];
|
||||||
|
|
||||||
detect(input: string): InjectionDetectionResult {
|
detect(input: string): InjectionDetectionResult {
|
||||||
const findings: InjectionFinding[] = [];
|
const findings: InjectionFinding[] = [];
|
||||||
let riskScore = 0;
|
let riskScore = 0;
|
||||||
|
|
||||||
for (const pattern of PromptInjectionDefender.INJECTION_PATTERNS) {
|
for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) {
|
||||||
const matches = input.match(pattern);
|
const matches = input.match(pattern);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
findings.push({
|
findings.push({
|
||||||
pattern: pattern.source,
|
pattern: pattern.source,
|
||||||
matched: matches[0],
|
matched: matches[0],
|
||||||
severity: this.classifySeverity(pattern.source),
|
severity,
|
||||||
});
|
});
|
||||||
riskScore += this.classifySeverity(pattern.source) === 'high' ? 3 :
|
riskScore += severity === 'high' ? 5 : severity === 'medium' ? 3 : 1;
|
||||||
this.classifySeverity(pattern.source) === 'medium' ? 2 : 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 {
|
sanitize(input: string): string {
|
||||||
let cleaned = input;
|
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]');
|
||||||
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();
|
return cleaned.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = entry.type as string;
|
||||||
|
const data = entry.data as Record<string, unknown>;
|
||||||
|
|
||||||
|
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<unknown> {
|
||||||
|
// 动态导入以避免循环依赖
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
*
|
*
|
||||||
* run_command — 在沙箱环境中执行 Shell 命令
|
* run_command — 在沙箱环境中执行 Shell 命令
|
||||||
*
|
*
|
||||||
|
* 安全增强(v0.2.0):
|
||||||
|
* 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描
|
||||||
|
* 2. 通过 SandboxManager.validatePath 校验工作目录
|
||||||
|
* 3. 命令注入模式检测扩展
|
||||||
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||||||
*/
|
*/
|
||||||
@@ -10,10 +15,12 @@
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import log from 'electron-log';
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
||||||
|
import type { SandboxManager } from '../../sandbox/sandbox';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -22,7 +29,7 @@ const execAsync = promisify(exec);
|
|||||||
export class RunCommandTool implements IMetonaTool {
|
export class RunCommandTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'run_command',
|
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: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -38,6 +45,14 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
timeoutMs: 120_000,
|
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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
const command = args.command as string;
|
const command = args.command as string;
|
||||||
const workdir = (args.workdir as string) ?? context.workspacePath;
|
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 };
|
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);
|
const validation = this.validateCommand(command);
|
||||||
if (!validation.allowed) {
|
if (!validation.allowed) {
|
||||||
return { success: false, error: validation.reason, command };
|
return { success: false, error: validation.reason, command };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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,
|
cwd: resolvedWorkdir,
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer: 1024 * 1024, // 1MB
|
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 {
|
return {
|
||||||
@@ -97,23 +150,41 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 硬阻止列表(绝对禁止执行)
|
// 硬阻止列表(绝对禁止执行)
|
||||||
|
// v0.2.0: 扩展危险命令检测模式
|
||||||
const hardBlocks = [
|
const hardBlocks = [
|
||||||
|
// 文件系统破坏
|
||||||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
{ 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(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(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: /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: /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: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
||||||
|
// 磁盘格式化
|
||||||
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
||||||
|
// 权限滥用
|
||||||
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is 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 危险命令
|
// Windows 危险命令
|
||||||
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
||||||
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown 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: /\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: /\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: 'Background subshell execution is forbidden' },
|
||||||
{ pattern: /\|\s*&/, reason: 'Pipe to background process 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) {
|
for (const block of hardBlocks) {
|
||||||
|
|||||||
@@ -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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,15 +60,24 @@ export function isPathWithinWorkspace(
|
|||||||
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
|
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
|
||||||
*
|
*
|
||||||
* 用于 run_command 工具的命令校验。
|
* 用于 run_command 工具的命令校验。
|
||||||
* 采用大小写不敏感匹配,覆盖 cat/type/Get-Content 等常见读取命令。
|
* 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道),
|
||||||
|
* 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。
|
||||||
|
*
|
||||||
|
* 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md
|
||||||
|
* 等价于工作空间根目录的 MEMORY.md。
|
||||||
*
|
*
|
||||||
* @param command Shell 命令字符串
|
* @param command Shell 命令字符串
|
||||||
* @returns true 如果命令包含受保护文件名
|
* @returns true 如果命令直接引用了受保护文件名
|
||||||
*/
|
*/
|
||||||
export function commandTouchesProtectedFile(command: string): boolean {
|
export function commandTouchesProtectedFile(command: string): boolean {
|
||||||
const lowerCmd = command.toLowerCase();
|
const lowerCmd = command.toLowerCase();
|
||||||
for (const protectedName of PROTECTED_FILES) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* 内置工具导出
|
* 内置工具导出
|
||||||
*
|
*
|
||||||
|
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
||||||
|
* 新增:file_editor, code_search, task_manager, diff_viewer
|
||||||
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 文件系统工具(4 个)
|
||||||
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
|
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';
|
export { WebSearchTool, WebFetchTool } from './network';
|
||||||
|
|
||||||
|
// 记忆工具(2 个)
|
||||||
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
||||||
|
|
||||||
|
// 命令工具(1 个)
|
||||||
export { RunCommandTool } from './command';
|
export { RunCommandTool } from './command';
|
||||||
|
|
||||||
|
// 浏览器工具(1 个)
|
||||||
export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser';
|
export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser';
|
||||||
|
|
||||||
|
// 委派工具(1 个)
|
||||||
export { DelegateTaskTool } from './delegate-task';
|
export { DelegateTaskTool } from './delegate-task';
|
||||||
|
|
||||||
|
// v0.2.0: 任务管理工具(1 个)
|
||||||
|
export { TaskManagerTool } from './task-manager';
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>): 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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
|
||||||
|
return { success: true, task: mapRow(updated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private listTasks(args: Record<string, unknown>, 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<Record<string, unknown>>;
|
||||||
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> | 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<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
task: mapRow(row),
|
||||||
|
subtasks: subtasks.map(mapRow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ const MAX_RESULT_CHARS = 50_000;
|
|||||||
|
|
||||||
export class ToolRegistry {
|
export class ToolRegistry {
|
||||||
private tools = new Map<string, ToolRegistryEntry>();
|
private tools = new Map<string, ToolRegistryEntry>();
|
||||||
private disabledTools = new Set<string>();
|
|
||||||
|
|
||||||
/** 注册内置工具 */
|
/** 注册内置工具 */
|
||||||
registerBuiltin(tool: IMetonaTool): void {
|
registerBuiltin(tool: IMetonaTool): void {
|
||||||
@@ -51,14 +50,13 @@ export class ToolRegistry {
|
|||||||
get(name: string): IMetonaTool | undefined {
|
get(name: string): IMetonaTool | undefined {
|
||||||
const entry = this.tools.get(name);
|
const entry = this.tools.get(name);
|
||||||
if (!entry?.enabled) return undefined;
|
if (!entry?.enabled) return undefined;
|
||||||
if (this.disabledTools.has(name)) return undefined;
|
|
||||||
return entry.tool;
|
return entry.tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列出所有已启用工具的定义 */
|
/** 列出所有已启用工具的定义 */
|
||||||
listTools(): MetonaToolDef[] {
|
listTools(): MetonaToolDef[] {
|
||||||
return Array.from(this.tools.values())
|
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);
|
.map((e) => e.tool.definition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +64,15 @@ export class ToolRegistry {
|
|||||||
listAllTools(): Array<MetonaToolDef & { enabled: boolean }> {
|
listAllTools(): Array<MetonaToolDef & { enabled: boolean }> {
|
||||||
return Array.from(this.tools.values()).map((e) => ({
|
return Array.from(this.tools.values()).map((e) => ({
|
||||||
...e.tool.definition,
|
...e.tool.definition,
|
||||||
enabled: e.enabled && !this.disabledTools.has(e.tool.definition.name),
|
enabled: e.enabled,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
|
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
|
||||||
setToolEnabled(name: string, enabled: boolean): void {
|
setToolEnabled(name: string, enabled: boolean): void {
|
||||||
if (enabled) {
|
const entry = this.tools.get(name);
|
||||||
this.disabledTools.delete(name);
|
if (entry) {
|
||||||
} else {
|
entry.enabled = enabled;
|
||||||
this.disabledTools.add(name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +148,6 @@ export class ToolRegistry {
|
|||||||
|
|
||||||
/** 获取工具数量 */
|
/** 获取工具数量 */
|
||||||
get size(): number {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ export enum MetonaStreamEventType {
|
|||||||
TOOL_CALL_DELTA = 'tool_call_delta',
|
TOOL_CALL_DELTA = 'tool_call_delta',
|
||||||
TOOL_CALL_COMPLETE = 'tool_call_complete',
|
TOOL_CALL_COMPLETE = 'tool_call_complete',
|
||||||
TOOL_RESULT = 'tool_result',
|
TOOL_RESULT = 'tool_result',
|
||||||
THINKING_START = 'thinking_start',
|
|
||||||
THINKING_END = 'thinking_end',
|
|
||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
DONE = 'done',
|
DONE = 'done',
|
||||||
USAGE = 'usage',
|
USAGE = 'usage',
|
||||||
@@ -99,6 +97,8 @@ export interface MetonaStreamEvent {
|
|||||||
/** 序列号 */
|
/** 序列号 */
|
||||||
seq: number;
|
seq: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
/** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */
|
||||||
|
runId?: string;
|
||||||
|
|
||||||
/** 根据 type 使用不同字段 */
|
/** 根据 type 使用不同字段 */
|
||||||
/** TEXT_DELTA / REASONING_DELTA */
|
/** TEXT_DELTA / REASONING_DELTA */
|
||||||
|
|||||||
+173
-1
@@ -22,6 +22,8 @@ import type { MemoryManager } from '../harness/memory/manager';
|
|||||||
import type { MCPManager } from '../services/mcp-manager.service';
|
import type { MCPManager } from '../services/mcp-manager.service';
|
||||||
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
|
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
|
||||||
import type { OutputValidator } from '../harness/verification/output-validator';
|
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 type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
|
||||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||||
import type { MetonaError } from '../harness/types';
|
import type { MetonaError } from '../harness/types';
|
||||||
@@ -45,6 +47,8 @@ export function registerAllIPCHandlers(
|
|||||||
reloadAdapter: () => void,
|
reloadAdapter: () => void,
|
||||||
promptInjectionDefender: PromptInjectionDefender,
|
promptInjectionDefender: PromptInjectionDefender,
|
||||||
outputValidator: OutputValidator,
|
outputValidator: OutputValidator,
|
||||||
|
confirmationHook: ConfirmationHook,
|
||||||
|
memoryConsolidator: MemoryConsolidator,
|
||||||
): void {
|
): void {
|
||||||
|
|
||||||
// ===== Agent 交互 =====
|
// ===== 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 层:记录状态转换
|
// AGENT 层:记录状态转换
|
||||||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
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('streamEvent', onStreamEvent);
|
||||||
agentLoop.on('stateChange', onStateChange);
|
agentLoop.on('stateChange', onStateChange);
|
||||||
|
agentLoop.on('compressed', onCompressed);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提示注入检测(安全模块)
|
// 提示注入检测(安全模块)
|
||||||
@@ -218,6 +233,26 @@ export function registerAllIPCHandlers(
|
|||||||
// 更新 MEMORY.md 时间戳
|
// 更新 MEMORY.md 时间戳
|
||||||
workspaceService.updateMemoryTimestamp();
|
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 层:记录会话结束
|
// TOOL 层:记录会话结束
|
||||||
auditService.logSessionEnd({
|
auditService.logSessionEnd({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -282,6 +317,7 @@ export function registerAllIPCHandlers(
|
|||||||
} finally {
|
} finally {
|
||||||
agentLoop.off('streamEvent', onStreamEvent);
|
agentLoop.off('streamEvent', onStreamEvent);
|
||||||
agentLoop.off('stateChange', onStateChange);
|
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<typeof auditService.query>[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<string, unknown[]> = {};
|
||||||
|
try {
|
||||||
|
if (!type || type === 'episodic') {
|
||||||
|
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
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');
|
log.info('[SYS] All IPC handlers registered');
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-11
@@ -30,6 +30,7 @@ import { WindowManager } from './services/window-manager.service';
|
|||||||
import { MCPManager } from './services/mcp-manager.service';
|
import { MCPManager } from './services/mcp-manager.service';
|
||||||
import { ContextBuilder } from './harness/prompts/context-builder';
|
import { ContextBuilder } from './harness/prompts/context-builder';
|
||||||
import { MemoryManager } from './harness/memory/manager';
|
import { MemoryManager } from './harness/memory/manager';
|
||||||
|
import { MemoryConsolidator } from './harness/memory/consolidator';
|
||||||
import { registerAllIPCHandlers } from './ipc/handlers';
|
import { registerAllIPCHandlers } from './ipc/handlers';
|
||||||
import { AgentLoopEngine } from './harness/agent-loop';
|
import { AgentLoopEngine } from './harness/agent-loop';
|
||||||
import { ToolRegistry } from './harness/tools/registry';
|
import { ToolRegistry } from './harness/tools/registry';
|
||||||
@@ -43,8 +44,14 @@ import {
|
|||||||
RunCommandTool,
|
RunCommandTool,
|
||||||
WebBrowserTool, cleanupBrowser,
|
WebBrowserTool, cleanupBrowser,
|
||||||
DelegateTaskTool,
|
DelegateTaskTool,
|
||||||
|
// v0.2.0 新增工具
|
||||||
|
FileEditorTool,
|
||||||
|
CodeSearchTool,
|
||||||
|
TaskManagerTool,
|
||||||
|
DiffViewerTool,
|
||||||
} from './harness/tools/built-in';
|
} from './harness/tools/built-in';
|
||||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||||
|
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||||||
import { PolicyEngine } from './harness/sandbox/permissions';
|
import { PolicyEngine } from './harness/sandbox/permissions';
|
||||||
import { SandboxManager } from './harness/sandbox/sandbox';
|
import { SandboxManager } from './harness/sandbox/sandbox';
|
||||||
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
||||||
@@ -150,6 +157,19 @@ async function initialize(): Promise<void> {
|
|||||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||||
const contextBuilder = new ContextBuilder();
|
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: 注册内置工具 =====
|
// ===== 步骤 6: 注册内置工具 =====
|
||||||
const toolRegistry = new ToolRegistry();
|
const toolRegistry = new ToolRegistry();
|
||||||
toolRegistry.registerBuiltin(new ReadFileTool());
|
toolRegistry.registerBuiltin(new ReadFileTool());
|
||||||
@@ -157,6 +177,11 @@ async function initialize(): Promise<void> {
|
|||||||
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
||||||
toolRegistry.registerBuiltin(new SearchFilesTool());
|
toolRegistry.registerBuiltin(new SearchFilesTool());
|
||||||
|
|
||||||
|
// v0.2.0: 新增文件工具
|
||||||
|
toolRegistry.registerBuiltin(new FileEditorTool());
|
||||||
|
toolRegistry.registerBuiltin(new CodeSearchTool());
|
||||||
|
toolRegistry.registerBuiltin(new DiffViewerTool());
|
||||||
|
|
||||||
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
|
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
|
||||||
const webFetchTool = new WebFetchTool();
|
const webFetchTool = new WebFetchTool();
|
||||||
toolRegistry.registerBuiltin(webFetchTool);
|
toolRegistry.registerBuiltin(webFetchTool);
|
||||||
@@ -164,7 +189,14 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
||||||
toolRegistry.registerBuiltin(new MemorySearchTool(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 统一浏览器工具
|
// 注册 Web Browser 统一浏览器工具
|
||||||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||||
@@ -176,19 +208,13 @@ async function initialize(): Promise<void> {
|
|||||||
log.warn('MCP Manager initialization error:', err);
|
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 =====
|
// ===== Hooks =====
|
||||||
|
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
||||||
|
// 注意:setToolDefs 延迟到 DelegateTaskTool 注册后调用,确保包含所有工具的风险等级
|
||||||
const preToolHooks = [
|
const preToolHooks = [
|
||||||
new PermissionCheckHook(policyEngine),
|
new PermissionCheckHook(policyEngine),
|
||||||
new RateLimitHook(20),
|
new RateLimitHook(20),
|
||||||
|
confirmationHook,
|
||||||
];
|
];
|
||||||
const postToolHooks = [
|
const postToolHooks = [
|
||||||
new AuditLogHook(auditService),
|
new AuditLogHook(auditService),
|
||||||
@@ -215,6 +241,9 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.setTools(toolRegistry.listTools());
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||||
|
|
||||||
|
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||||||
|
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||||||
|
|
||||||
// ===== Task Orchestrator(子任务委派)=====
|
// ===== Task Orchestrator(子任务委派)=====
|
||||||
const orchestrator = new TaskOrchestrator(
|
const orchestrator = new TaskOrchestrator(
|
||||||
agentLoop, toolRegistry, preToolHooks, postToolHooks,
|
agentLoop, toolRegistry, preToolHooks, postToolHooks,
|
||||||
@@ -227,12 +256,16 @@ async function initialize(): Promise<void> {
|
|||||||
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||||||
// 重新设置工具列表,包含新注册的 delegate_task
|
// 重新设置工具列表,包含新注册的 delegate_task
|
||||||
agentLoop.setTools(toolRegistry.listTools());
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
|
// v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
|
||||||
|
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
||||||
|
|
||||||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||||||
|
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
||||||
const reloadAdapter = () => {
|
const reloadAdapter = () => {
|
||||||
try {
|
try {
|
||||||
const newAdapter = createAdapter();
|
const newAdapter = createAdapter();
|
||||||
agentLoop.setAdapter(newAdapter);
|
agentLoop.setAdapter(newAdapter);
|
||||||
|
memoryConsolidator.setAdapter(newAdapter);
|
||||||
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
||||||
const provider = configService.get<string>('llm.provider') ?? '';
|
const provider = configService.get<string>('llm.provider') ?? '';
|
||||||
if (provider === 'ollama') {
|
if (provider === 'ollama') {
|
||||||
@@ -242,8 +275,30 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.updateConfig({ contextLength: undefined });
|
agentLoop.updateConfig({ contextLength: undefined });
|
||||||
}
|
}
|
||||||
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
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) {
|
} catch (err) {
|
||||||
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
|
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<void> {
|
|||||||
title: 'MetonaAI Desktop',
|
title: 'MetonaAI Desktop',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求)
|
||||||
|
confirmationHook.setMainWindow(mainWindow);
|
||||||
|
|
||||||
// ===== 系统托盘 =====
|
// ===== 系统托盘 =====
|
||||||
const resourcesPath = join(__dirname, '../../assets');
|
const resourcesPath = join(__dirname, '../../assets');
|
||||||
trayManager = new TrayManager(resourcesPath);
|
trayManager = new TrayManager(resourcesPath);
|
||||||
@@ -267,7 +325,7 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
|
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
|
||||||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||||||
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
||||||
EXECUTING: 'executing', OBSERVING: 'executing', REFLECTING: 'thinking',
|
EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
|
||||||
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
||||||
};
|
};
|
||||||
const stateValue = (data.current || data.state) ?? '';
|
const stateValue = (data.current || data.state) ?? '';
|
||||||
@@ -289,6 +347,7 @@ async function initialize(): Promise<void> {
|
|||||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||||
sessionRecorder, memoryManager, mcpManager, createAdapter,
|
sessionRecorder, memoryManager, mcpManager, createAdapter,
|
||||||
promptDefender, outputValidator,
|
promptDefender, outputValidator,
|
||||||
|
confirmationHook, memoryConsolidator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Initialize UpdateService for auto-update functionality
|
// TODO: Initialize UpdateService for auto-update functionality
|
||||||
|
|||||||
@@ -64,6 +64,41 @@ const metonaAPI = {
|
|||||||
// ===== 记忆系统 =====
|
// ===== 记忆系统 =====
|
||||||
memory: {
|
memory: {
|
||||||
search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options),
|
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'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 配置 =====
|
// ===== 配置 =====
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
* 1. 所有重要操作必须记录
|
* 1. 所有重要操作必须记录
|
||||||
* 2. 日志不可篡改(INSERT-ONLY)
|
* 2. 日志不可篡改(INSERT-ONLY)
|
||||||
* 3. 支持按时间/类型/会话查询
|
* 3. 支持按时间/类型/会话查询
|
||||||
|
* 4. v0.2.0: 链式哈希防篡改 — 每条日志的 current_hash = SHA256(prev_hash + content)
|
||||||
*
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
|
||||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
|
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) {}
|
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 {
|
log(entry: AuditEntry): void {
|
||||||
try {
|
try {
|
||||||
const db = this.getDB();
|
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(`
|
db.prepare(`
|
||||||
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at)
|
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
entry.sessionId,
|
entry.sessionId,
|
||||||
entry.iteration ?? null,
|
entry.iteration ?? null,
|
||||||
entry.eventType,
|
entry.eventType,
|
||||||
entry.actor,
|
entry.actor,
|
||||||
entry.target,
|
entry.target,
|
||||||
entry.details ? JSON.stringify(entry.details) : null,
|
detailsStr,
|
||||||
entry.outcome ?? null,
|
entry.outcome ?? null,
|
||||||
entry.durationMs ?? null,
|
entry.durationMs ?? null,
|
||||||
Date.now(),
|
now,
|
||||||
|
prevHash,
|
||||||
|
currentHash,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 审计日志写入失败不应影响主流程
|
// 审计日志写入失败不应影响主流程
|
||||||
@@ -185,6 +242,8 @@ export class AuditService {
|
|||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
prev_hash: string | null;
|
||||||
|
current_hash: string | null;
|
||||||
}> {
|
}> {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
|
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
|
||||||
@@ -217,6 +276,57 @@ export class AuditService {
|
|||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
created_at: number;
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export class DatabaseService {
|
|||||||
details TEXT,
|
details TEXT,
|
||||||
outcome TEXT,
|
outcome TEXT,
|
||||||
duration_ms INTEGER,
|
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 服务配置表 =====
|
-- ===== MCP 服务配置表 =====
|
||||||
@@ -183,6 +185,24 @@ export class DatabaseService {
|
|||||||
UNIQUE(session_id, task_id, key)
|
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_session ON messages(session_id, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
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_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_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_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)
|
// 审计日志防篡改触发器(INSERT-ONLY)
|
||||||
@@ -246,6 +269,28 @@ export class DatabaseService {
|
|||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+29
-29
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.2",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.2",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -417,9 +417,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -691,9 +691,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1488,9 +1488,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1586,9 +1586,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4851,9 +4851,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dir-compare/node_modules/brace-expansion": {
|
"node_modules/dir-compare/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5616,9 +5616,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5975,9 +5975,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6321,9 +6321,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7633,9 +7633,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.5.1",
|
"version": "11.5.2",
|
||||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.4",
|
"version": "0.2.1",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SettingsModal } from '@renderer/components/settings/SettingsModal';
|
|||||||
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard';
|
||||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||||
import { ToastContainer } from '@renderer/components/ToastContainer';
|
import { ToastContainer } from '@renderer/components/ToastContainer';
|
||||||
|
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
||||||
import { useAgentStream } from '@renderer/hooks/useAgentStream';
|
import { useAgentStream } from '@renderer/hooks/useAgentStream';
|
||||||
@@ -64,6 +65,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<ConfirmationDialog />
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
riskLevel: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
|
safe: 'success',
|
||||||
|
low: 'info',
|
||||||
|
medium: 'warning',
|
||||||
|
high: 'error',
|
||||||
|
critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmationDialog(): React.JSX.Element | null {
|
||||||
|
const [request, setRequest] = useState<ConfirmationRequest | null>(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 (
|
||||||
|
<Dialog
|
||||||
|
open={!!request}
|
||||||
|
onClose={() => handleRespond(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: { bgcolor: 'background.paper' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
|
||||||
|
<Typography variant="h6" component="span">工具执行确认</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
|
||||||
|
{request.reason}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
label={`工具: ${request.toolName}`}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`风险: ${request.riskLevel}`}
|
||||||
|
color={riskColor}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Agent 正在请求执行以下工具,请确认参数:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
参数详情 ({Object.keys(request.args).length} 个参数)
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'text.primary',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 300,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(request.args).map(([key, value]) => (
|
||||||
|
<Box key={key} component="div" sx={{ mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'info.main', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{key}:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'text.primary', ml: 1 }}
|
||||||
|
>
|
||||||
|
{formatArg(key, value)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
在本次会话中记住此决定(同一工具不再询问)
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={autoExecute}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAutoExecute(e.target.checked);
|
||||||
|
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
|
||||||
|
if (e.target.checked) setRemember(true);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
永久自动执行此工具(跨会话不再询问,可在设置中关闭)
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRespond(false)}
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
拒绝执行
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRespond(true)}
|
||||||
|
color="success"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
确认执行
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -191,7 +191,7 @@ function extractTextContent(children: React.ReactNode): string {
|
|||||||
if (!children) return '';
|
if (!children) return '';
|
||||||
if (Array.isArray(children)) return children.map(extractTextContent).join('');
|
if (Array.isArray(children)) return children.map(extractTextContent).join('');
|
||||||
if (typeof children === 'object' && 'props' in children) {
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ export function MessageList(): React.JSX.Element {
|
|||||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(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) {
|
if (messages.length === 0 && !isStreaming) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<AlertTriangle size={24} color="var(--mui-palette-error-main)" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
|
{this.props.fallbackTitle ?? '组件渲染失败'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10, textAlign: 'center', wordBreak: 'break-word', maxWidth: '80%' }}>
|
||||||
|
{this.state.error?.message ?? '未知错误'}
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={this.handleReset} sx={{ mt: 1, textTransform: 'none', fontSize: 11 }}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* DetailPanel — 右侧详情面板
|
* 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 { TraceViewer } from '@renderer/components/trace/TraceViewer';
|
||||||
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
|
import { TokenUsage } from '@renderer/components/trace/TokenUsage';
|
||||||
import { AgentMonitor } from '@renderer/components/layout/AgentMonitor';
|
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';
|
import { LAYOUT } from '@renderer/lib/constants';
|
||||||
|
|
||||||
|
type DetailTab = 'trace' | 'memory' | 'tasks';
|
||||||
|
|
||||||
export function DetailPanel(): React.JSX.Element {
|
export function DetailPanel(): React.JSX.Element {
|
||||||
|
const [tab, setTab] = useState<DetailTab>('trace');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="aside"
|
component="aside"
|
||||||
@@ -19,10 +28,42 @@ export function DetailPanel(): React.JSX.Element {
|
|||||||
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH,
|
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider', width: LAYOUT.DETAIL_WIDTH,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v) => 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 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab icon={<Activity size={12} />} iconPosition="start" label="Trace" value="trace" />
|
||||||
|
<Tab icon={<Brain size={12} />} iconPosition="start" label="Memory" value="memory" />
|
||||||
|
<Tab icon={<ListChecks size={12} />} iconPosition="start" label="Tasks" value="tasks" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
<TraceViewer />
|
{tab === 'trace' && (
|
||||||
<TokenUsage />
|
<ErrorBoundary fallbackTitle="Trace 渲染失败">
|
||||||
<AgentMonitor />
|
<>
|
||||||
|
<TraceViewer />
|
||||||
|
<TokenUsage />
|
||||||
|
<AgentMonitor />
|
||||||
|
</>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
|
{tab === 'memory' && (
|
||||||
|
<ErrorBoundary fallbackTitle="Memory 渲染失败">
|
||||||
|
<MemoryViewer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
|
{tab === 'tasks' && (
|
||||||
|
<ErrorBoundary fallbackTitle="Tasks 渲染失败">
|
||||||
|
<TaskList />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { 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 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 { useSessionStore, type Session } from '@renderer/stores/session-store';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { formatRelativeTime } from '@renderer/lib/formatters';
|
import { formatRelativeTime } from '@renderer/lib/formatters';
|
||||||
@@ -78,7 +78,6 @@ export function Sidebar(): React.JSX.Element {
|
|||||||
|
|
||||||
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
<Divider sx={{ mt: 'auto', mb: 1 }} />
|
||||||
<ToolManagerPanel />
|
<ToolManagerPanel />
|
||||||
<MemorySearchPanel />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -169,40 +168,3 @@ function ToolManagerPanel() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MemorySearchPanel() {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [results, setResults] = useState<Array<{ id: string; type: string; content: string; importance: number }>>([]);
|
|
||||||
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 (
|
|
||||||
<Box>
|
|
||||||
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
|
||||||
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Database size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
|
|
||||||
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}>记忆搜索</Typography>} />
|
|
||||||
</ListItemButton>
|
|
||||||
<Collapse in={expanded}>
|
|
||||||
<Stack spacing={1} sx={{ pl: 3, pr: 1, pb: 1 }}>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<TextField size="small" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder="搜索记忆..." sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: 11, height: 28 } }} />
|
|
||||||
<Button variant="outlined" size="small" onClick={handleSearch} disabled={searching} sx={{ minWidth: 40, height: 28, fontSize: 10 }}>{searching ? '...' : '搜索'}</Button>
|
|
||||||
</Stack>
|
|
||||||
{results.length > 0 && (
|
|
||||||
<Box sx={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
{results.map((r) => (
|
|
||||||
<Box key={r.id} sx={{ px: 1, py: 0.75, borderRadius: 1, bgcolor: 'background.default', fontSize: 10, color: 'text.secondary' }}>
|
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
|
||||||
<Box sx={{ px: 0.5, borderRadius: 0.5, bgcolor: 'action.hover', color: 'primary.main', fontSize: 9 }}>{r.type}</Box>
|
|
||||||
<Typography variant="caption" sx={{ fontSize: 9 }}>重要度: {r.importance.toFixed(1)}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Typography variant="caption" sx={{ fontSize: 10, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.content.slice(0, 100)}</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<MemoryType, string> = {
|
||||||
|
episodic: '情景记忆',
|
||||||
|
semantic: '语义记忆',
|
||||||
|
working: '工作记忆',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
|
||||||
|
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<Record<MemoryType, MemoryItem[]>>({
|
||||||
|
episodic: [], semantic: [], working: [],
|
||||||
|
});
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<MemoryType | 'all'>('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 (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||||
|
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||||
|
记忆搜索
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||||
|
{searchResults.length} 条结果
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search size={12} style={{ color: '#8b8fa7' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: searchQuery ? (
|
||||||
|
<InputAdornment position="end" sx={{ cursor: 'pointer' }} onClick={handleClearSearch}>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}>清除</Typography>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
|
||||||
|
{searching ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
||||||
|
搜索中...
|
||||||
|
</Typography>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
||||||
|
无匹配结果
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
searchResults.map((r) => (
|
||||||
|
<MemorySearchItem key={`${r.type}-${r.id}`} item={r} onDelete={handleDelete} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全量列表视图
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||||
|
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||||
|
记忆库
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||||
|
{totalCount} 条
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Search size={12} style={{ color: '#8b8fa7' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
{totalCount === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||||
|
暂无记忆数据
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
MEMORY_TYPES.map((type) => {
|
||||||
|
const items = memories[type];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
key={type}
|
||||||
|
expanded={expanded === type || expanded === 'all'}
|
||||||
|
onChange={(_, isExpanded) => 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 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
|
||||||
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: MEMORY_TYPE_COLORS[type], flexShrink: 0 }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}>
|
||||||
|
{MEMORY_TYPE_LABELS[type]}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={items.length}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
ml: 'auto', mr: 0.5, height: 16, fontSize: 10,
|
||||||
|
bgcolor: 'action.hover', color: 'text.secondary',
|
||||||
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<MemoryItemRow key={getId(item)} item={item} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 子组件 =====
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1, py: 0.75, borderRadius: 1,
|
||||||
|
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
||||||
|
'&:hover .delete-btn': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||||
|
<Chip
|
||||||
|
label={MEMORY_TYPE_LABELS[type]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
|
||||||
|
color: MEMORY_TYPE_COLORS[type],
|
||||||
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{typeof item.importance === 'number' && (
|
||||||
|
<Chip
|
||||||
|
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: importanceColor(item.importance) + '22',
|
||||||
|
color: importanceColor(item.importance),
|
||||||
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{createdAt > 0 && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{formatTime(createdAt)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
className="delete-btn"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDelete(type, getId(item))}
|
||||||
|
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||||
|
{truncate(item.content, 100)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemorySearchItem({
|
||||||
|
item,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: SearchResult;
|
||||||
|
onDelete: (type: MemoryType, id: string) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1, py: 0.75, borderRadius: 1,
|
||||||
|
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
||||||
|
'&:hover .delete-btn': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||||
|
<Chip
|
||||||
|
label={MEMORY_TYPE_LABELS[item.type]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
|
||||||
|
color: MEMORY_TYPE_COLORS[item.type],
|
||||||
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: '#22d3ee22', color: '#22d3ee',
|
||||||
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: importanceColor(item.importance) + '22',
|
||||||
|
color: importanceColor(item.importance),
|
||||||
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.createdAt > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}>
|
||||||
|
{formatTime(item.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
className="delete-btn"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDelete(item.type, item.id)}
|
||||||
|
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||||
|
{truncate(item.content, 100)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -211,11 +211,53 @@ function AgentSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolsSettings() {
|
function ToolsSettings() {
|
||||||
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
|
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
|
||||||
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 [autoExecList, setAutoExecList] = useState<string[]>([]);
|
||||||
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 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<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
|
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { 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<typeof t> => t !== undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>工具管理</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>工具管理</Typography>
|
||||||
@@ -231,6 +273,56 @@ function ToolsSettings() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
{/* ===== 自动执行工具管理 ===== */}
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>自动执行工具</Typography>
|
||||||
|
<Chip label={`${autoExecList.length} 个`} size="small" color={autoExecList.length > 0 ? 'success' : 'default'} variant="outlined" sx={{ height: 18, fontSize: 10 }} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
|
||||||
|
已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 已自动执行的工具列表 */}
|
||||||
|
{autoExecToolDetails.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}>
|
||||||
|
暂无自动执行工具
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
autoExecToolDetails.map((t) => (
|
||||||
|
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'success.main', opacity: 0.8, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
|
||||||
|
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
|
||||||
|
</Stack>
|
||||||
|
<Button size="small" color="warning" variant="outlined" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, false)}>
|
||||||
|
取消自动
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 可设为自动执行的工具(需要确认但未设置) */}
|
||||||
|
{pendingConfirmTools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
|
||||||
|
可设为自动执行(当前需要确认)
|
||||||
|
</Typography>
|
||||||
|
{pendingConfirmTools.map((t) => (
|
||||||
|
<Stack key={t.name} direction="row" sx={{ py: 1, px: 1.5, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px dashed', borderColor: 'divider', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{t.name}</Typography>
|
||||||
|
<Chip label={t.riskLevel.toUpperCase()} size="small" color={riskColors[t.riskLevel]} variant="outlined" sx={{ height: 16, fontSize: 9 }} />
|
||||||
|
</Stack>
|
||||||
|
<Button size="small" color="success" variant="contained" sx={{ fontSize: 10, minWidth: 70 }} onClick={() => handleSetAutoExec(t.name, true)}>
|
||||||
|
设为自动
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TaskStatus, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
in_progress: '进行中',
|
||||||
|
completed: '已完成',
|
||||||
|
blocked: '阻塞',
|
||||||
|
cancelled: '已取消',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||||
|
pending: '#f59e0b',
|
||||||
|
in_progress: '#a855f7',
|
||||||
|
completed: '#22c55e',
|
||||||
|
blocked: '#ef4444',
|
||||||
|
cancelled: '#64748b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||||||
|
low: '低',
|
||||||
|
medium: '中',
|
||||||
|
high: '高',
|
||||||
|
critical: '紧急',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||||
|
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<MetonaTask[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(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 (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||||
|
<ListChecks size={14} style={{ color: '#22d3ee' }} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||||||
|
任务列表
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||||
|
{tasks.length} 项
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
|
||||||
|
title="新增任务"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
mb: 1, px: 1, py: 0.5, fontSize: 10,
|
||||||
|
color: 'error.main', bgcolor: 'error.main' + '14',
|
||||||
|
borderRadius: 1, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
|
||||||
|
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||||
|
placeholder="任务标题..."
|
||||||
|
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={newPriority}
|
||||||
|
onChange={(e) => setNewPriority(e.target.value as TaskPriority)}
|
||||||
|
sx={{ flex: 1, height: 28, fontSize: 11, '& .MuiSelect-select': { py: 0.5, px: 1 } }}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((p) => (
|
||||||
|
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
|
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newTitle.trim()}
|
||||||
|
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{creating ? '...' : '创建'}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
|
||||||
|
sx={{ p: 0.5, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
{!sessionId ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||||
|
请先选择会话
|
||||||
|
</Typography>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||||||
|
暂无任务,点击 + 创建
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding dense>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskItemRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
expanded={expandedId === task.id}
|
||||||
|
onToggleComplete={() => handleToggleComplete(task)}
|
||||||
|
onToggleExpand={() => toggleExpand(task.id)}
|
||||||
|
onDelete={() => handleDelete(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 子组件 =====
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ListItem
|
||||||
|
disableGutters
|
||||||
|
disablePadding
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
px: 0.5, py: 0.25,
|
||||||
|
borderRadius: 1,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
'&:hover .delete-btn': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'flex-start' }}>
|
||||||
|
<ListItemIcon sx={{ minWidth: 20, mt: 0.25 }}>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={isCompleted}
|
||||||
|
onChange={onToggleComplete}
|
||||||
|
sx={{ p: 0.25, '& .MuiSvgIcon-root': { fontSize: 14 } }}
|
||||||
|
/>
|
||||||
|
</ListItemIcon>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
sx={{
|
||||||
|
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
|
||||||
|
color: isCompleted ? 'text.disabled' : 'text.primary',
|
||||||
|
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
label={STATUS_LABELS[task.status]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: statusColor + '22', color: statusColor,
|
||||||
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={PRIORITY_LABELS[task.priority]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 14, fontSize: 9,
|
||||||
|
bgcolor: priorityColor + '22', color: priorityColor,
|
||||||
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="span"
|
||||||
|
sx={{ fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{formatTime(task.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
style={{
|
||||||
|
color: 'text.disabled',
|
||||||
|
transition: 'transform 150ms',
|
||||||
|
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
className="delete-btn"
|
||||||
|
size="small"
|
||||||
|
onClick={onDelete}
|
||||||
|
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||||||
|
{task.description ? task.description : (
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
|
||||||
|
无描述
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
+167
-28
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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
|
* Agent 流式事件监听 Hook
|
||||||
@@ -40,6 +40,26 @@ export function useAgentStream(): void {
|
|||||||
state?: string;
|
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 读取最新状态(避免闭包捕获过期快照)
|
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
|
||||||
const getStore = () => useAgentStore.getState();
|
const getStore = () => useAgentStore.getState();
|
||||||
|
|
||||||
@@ -47,6 +67,33 @@ export function useAgentStream(): void {
|
|||||||
// 推理内容增量
|
// 推理内容增量
|
||||||
case 'reasoning_delta':
|
case 'reasoning_delta':
|
||||||
if (data.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 messages = getStore().messages;
|
||||||
const lastMsg = messages[messages.length - 1];
|
const lastMsg = messages[messages.length - 1];
|
||||||
if (lastMsg?.role === 'assistant') {
|
if (lastMsg?.role === 'assistant') {
|
||||||
@@ -57,7 +104,7 @@ export function useAgentStream(): void {
|
|||||||
} else {
|
} else {
|
||||||
// 还没有 assistant 消息,先创建一条(仅含思考内容)
|
// 还没有 assistant 消息,先创建一条(仅含思考内容)
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: `msg_${Date.now()}_assistant`,
|
id: genMsgId('assistant'),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '',
|
content: '',
|
||||||
reasoningContent: data.delta,
|
reasoningContent: data.delta,
|
||||||
@@ -80,7 +127,27 @@ export function useAgentStream(): void {
|
|||||||
// 文本增量
|
// 文本增量
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
if (data.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);
|
getStore().updateLastAssistantMessage(data.delta);
|
||||||
|
|
||||||
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
|
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
|
||||||
@@ -97,6 +164,30 @@ export function useAgentStream(): void {
|
|||||||
}
|
}
|
||||||
break;
|
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':
|
case 'tool_call_complete':
|
||||||
if (data.toolCall) {
|
if (data.toolCall) {
|
||||||
@@ -107,13 +198,22 @@ export function useAgentStream(): void {
|
|||||||
status: 'executing',
|
status: 'executing',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 追加到当前 assistant 消息
|
// 追加到当前 assistant 消息(替换同 index 的 pending 占位)
|
||||||
const msgs = getStore().messages;
|
const msgs = getStore().messages;
|
||||||
const lastAssistant = msgs[msgs.length - 1];
|
const lastAssistant = msgs[msgs.length - 1];
|
||||||
if (lastAssistant?.role === 'assistant') {
|
if (lastAssistant?.role === 'assistant') {
|
||||||
getStore().updateMessage(lastAssistant.id, {
|
const existingTcs = lastAssistant.toolCalls ?? [];
|
||||||
toolCalls: [...(lastAssistant.toolCalls ?? []), tc],
|
// 检查是否有同 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 步骤(添加工具调用信息)
|
// 更新当前 Trace 步骤(添加工具调用信息)
|
||||||
@@ -165,7 +265,8 @@ export function useAgentStream(): void {
|
|||||||
}
|
}
|
||||||
: tc,
|
: tc,
|
||||||
);
|
);
|
||||||
getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls });
|
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
|
||||||
|
getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +298,11 @@ export function useAgentStream(): void {
|
|||||||
// 流结束
|
// 流结束
|
||||||
case 'done':
|
case 'done':
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
getStore().setAgentStatus('idle');
|
getStore().setCurrentRunId(null);
|
||||||
|
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
|
||||||
|
if (getStore().agentStatus !== 'error') {
|
||||||
|
getStore().setAgentStatus('idle');
|
||||||
|
}
|
||||||
// 标记最后一个 Trace 步骤为已完成
|
// 标记最后一个 Trace 步骤为已完成
|
||||||
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
||||||
getStore().saveTraceData();
|
getStore().saveTraceData();
|
||||||
@@ -206,10 +311,11 @@ export function useAgentStream(): void {
|
|||||||
// 错误
|
// 错误
|
||||||
case 'error':
|
case 'error':
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
|
getStore().setCurrentRunId(null);
|
||||||
getStore().setAgentStatus('error');
|
getStore().setAgentStatus('error');
|
||||||
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: `msg_${Date.now()}_error`,
|
id: genMsgId('error'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `错误: ${data.error?.message ?? '未知错误'}`,
|
content: `错误: ${data.error?.message ?? '未知错误'}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -236,31 +342,60 @@ export function useAgentStream(): void {
|
|||||||
state?: string;
|
state?: string;
|
||||||
previous?: string;
|
previous?: string;
|
||||||
current?: string;
|
current?: string;
|
||||||
|
runId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = useAgentStore.getState();
|
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) {
|
if (data.iteration != null && data.iteration !== store.currentIteration) {
|
||||||
const prevIteration = store.currentIteration;
|
const prevIteration = store.currentIteration;
|
||||||
store.setCurrentIteration(data.iteration);
|
store.setCurrentIteration(data.iteration);
|
||||||
|
|
||||||
|
// M-7: 统一所有迭代的卡片创建路径(包括首轮)
|
||||||
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
|
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
|
||||||
// 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时
|
// 如果最后一条已经是当前迭代的 assistant 卡片(delta handler 提前创建),不重复
|
||||||
if (data.iteration > prevIteration && prevIteration > 0) {
|
if (data.iteration > prevIteration) {
|
||||||
const messages = store.messages;
|
const messages = store.messages;
|
||||||
const lastMsg = messages[messages.length - 1];
|
const lastMsg = messages[messages.length - 1];
|
||||||
if (
|
const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
|
||||||
lastMsg?.role === 'assistant' &&
|
|
||||||
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)
|
if (!isAlreadyCurrentIteration) {
|
||||||
) {
|
// 首轮不要求上一轮有内容(上一轮是用户消息)
|
||||||
store.addMessage({
|
const isFirstIteration = prevIteration === 0;
|
||||||
id: `msg_${Date.now()}_assistant`,
|
const prevHasContent = lastMsg?.role === 'assistant' &&
|
||||||
role: 'assistant',
|
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
|
||||||
content: '',
|
|
||||||
timestamp: Date.now(),
|
if (isFirstIteration || prevHasContent) {
|
||||||
iteration: data.iteration,
|
store.addMessage({
|
||||||
});
|
id: genMsgId('assistant'),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
iteration: data.iteration,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,13 +438,14 @@ export function useAgentStream(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Agent 状态映射 ---
|
// --- Agent 状态映射 ---
|
||||||
|
// INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态
|
||||||
const stateToStatus: Record<string, AgentStatus> = {
|
const stateToStatus: Record<string, AgentStatus> = {
|
||||||
THINKING: 'thinking',
|
THINKING: 'thinking',
|
||||||
EXECUTING: 'executing',
|
EXECUTING: 'executing',
|
||||||
PARSING: 'thinking',
|
PARSING: 'thinking',
|
||||||
OBSERVING: 'thinking',
|
OBSERVING: 'thinking', // L-4: 观察工具结果更接近"思考"而非"执行"
|
||||||
|
REFLECTING: 'thinking',
|
||||||
COMPRESSING: 'thinking',
|
COMPRESSING: 'thinking',
|
||||||
INIT: 'thinking',
|
|
||||||
TERMINATED: 'idle',
|
TERMINATED: 'idle',
|
||||||
};
|
};
|
||||||
const newStatus = stateToStatus[data.state];
|
const newStatus = stateToStatus[data.state];
|
||||||
@@ -327,9 +463,12 @@ export function useAgentStream(): void {
|
|||||||
if (!window.metona?.agent?.onProviderSwitched) return;
|
if (!window.metona?.agent?.onProviderSwitched) return;
|
||||||
|
|
||||||
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
|
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
|
||||||
const { from, to, reason } = data as { from?: string; to?: string; reason?: string };
|
const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string };
|
||||||
useAgentStore.getState().addMessage({
|
// L-3: 仅在当前会话中显示 Provider 切换消息
|
||||||
id: `msg_${Date.now()}_system`,
|
const store = useAgentStore.getState();
|
||||||
|
if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return;
|
||||||
|
store.addMessage({
|
||||||
|
id: genMsgId('system'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Provider 已切换: ${from ?? '未知'} → ${to ?? '未知'}${reason ? ` (${reason})` : ''}`,
|
content: `Provider 已切换: ${from ?? '未知'} → ${to ?? '未知'}${reason ? ` (${reason})` : ''}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function formatDuration(ms: number): string {
|
|||||||
// ===== 截断文本(< 20 行纯函数,允许自写)=====
|
// ===== 截断文本(< 20 行纯函数,允许自写)=====
|
||||||
|
|
||||||
export function truncate(text: string, maxLength: number): string {
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (typeof text !== 'string' || text.length === 0) return '';
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
return text.slice(0, maxLength - 1) + '…';
|
return text.slice(0, maxLength - 1) + '…';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<MetonaSessionInfo[]> {
|
|
||||||
return getBridge().sessions.list();
|
|
||||||
},
|
|
||||||
|
|
||||||
create(title?: string): Promise<MetonaSessionInfo> {
|
|
||||||
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<Array<{
|
|
||||||
id: string;
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
reasoningContent?: string;
|
|
||||||
toolCalls?: unknown[];
|
|
||||||
timestamp: number;
|
|
||||||
}>> {
|
|
||||||
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<MetonaMCPServerStatus[]> {
|
|
||||||
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<MetonaMemorySearchResult[]> {
|
|
||||||
return getBridge().memory.search(query, options);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Config API =====
|
|
||||||
|
|
||||||
export const configAPI = {
|
|
||||||
get<T = unknown>(key: string): Promise<T | null> {
|
|
||||||
return getBridge().config.get(key) as Promise<T | null>;
|
|
||||||
},
|
|
||||||
|
|
||||||
set(key: string, value: unknown): Promise<{ success: boolean }> {
|
|
||||||
return getBridge().config.set(key, value);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== App API =====
|
|
||||||
|
|
||||||
export const appAPI = {
|
|
||||||
getVersion(): Promise<string> {
|
|
||||||
return getBridge().app.getVersion();
|
|
||||||
},
|
|
||||||
|
|
||||||
getAppDataPath(): Promise<string> {
|
|
||||||
return getBridge().app.getAppDataPath();
|
|
||||||
},
|
|
||||||
|
|
||||||
openExternal(url: string): Promise<void> {
|
|
||||||
return getBridge().app.openExternal(url);
|
|
||||||
},
|
|
||||||
|
|
||||||
showItemInFolder(path: string): Promise<void> {
|
|
||||||
return getBridge().app.showItemInFolder(path);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Toast API =====
|
|
||||||
|
|
||||||
export const toastAPI = {
|
|
||||||
/**
|
|
||||||
* 监听主进程 Toast 通知桥接
|
|
||||||
* @returns 取消监听函数
|
|
||||||
*/
|
|
||||||
onShow(callback: (data: {
|
|
||||||
type: 'success' | 'error' | 'warning' | 'info';
|
|
||||||
message: string;
|
|
||||||
options?: Record<string, unknown>;
|
|
||||||
}) => void): () => void {
|
|
||||||
return getBridge().toast.onShow(callback);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+28
-15
@@ -7,6 +7,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useSessionStore } from './session-store';
|
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';
|
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||||
@@ -85,7 +89,9 @@ interface AgentState {
|
|||||||
|
|
||||||
// 流式
|
// 流式
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
streamingContent: string;
|
|
||||||
|
// Run 标识(用于过滤旧流事件)
|
||||||
|
currentRunId: string | null;
|
||||||
|
|
||||||
// Provider
|
// Provider
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -101,10 +107,10 @@ interface AgentState {
|
|||||||
updateLastAssistantMessage: (delta: string) => void;
|
updateLastAssistantMessage: (delta: string) => void;
|
||||||
setAgentStatus: (status: AgentStatus) => void;
|
setAgentStatus: (status: AgentStatus) => void;
|
||||||
setStreaming: (streaming: boolean) => void;
|
setStreaming: (streaming: boolean) => void;
|
||||||
appendStreamingContent: (delta: string) => void;
|
setCurrentRunId: (runId: string | null) => void;
|
||||||
clearStreamingContent: () => void;
|
|
||||||
addTraceStep: (step: TraceStep) => void;
|
addTraceStep: (step: TraceStep) => void;
|
||||||
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
||||||
|
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
|
||||||
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
|
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
|
||||||
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
||||||
setProvider: (provider: string, model: string) => void;
|
setProvider: (provider: string, model: string) => void;
|
||||||
@@ -125,7 +131,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
traceSteps: [],
|
traceSteps: [],
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
streamingContent: '',
|
currentRunId: null,
|
||||||
provider: '',
|
provider: '',
|
||||||
model: '',
|
model: '',
|
||||||
contextWindow: 0,
|
contextWindow: 0,
|
||||||
@@ -133,7 +139,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
// ===== Actions =====
|
// ===== Actions =====
|
||||||
|
|
||||||
setCurrentSession: (id) => {
|
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) {
|
if (id && window.metona?.sessions?.getMessages) {
|
||||||
@@ -208,7 +214,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
console.error('[AgentStore]', 'Failed to create session:', err);
|
console.error('[AgentStore]', 'Failed to create session:', err);
|
||||||
set({ agentStatus: 'error', isStreaming: false });
|
set({ agentStatus: 'error', isStreaming: false });
|
||||||
get().addMessage({
|
get().addMessage({
|
||||||
id: `msg_${Date.now()}_error`,
|
id: genMsgId('error'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `错误: 无法创建会话 — ${(err as Error).message}`,
|
content: `错误: 无法创建会话 — ${(err as Error).message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -218,7 +224,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: `msg_${Date.now()}_user`,
|
id: genMsgId('user'),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content, // 用户可见内容(纯文本)
|
content, // 用户可见内容(纯文本)
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -229,7 +235,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
messages: [...s.messages, userMessage],
|
messages: [...s.messages, userMessage],
|
||||||
agentStatus: 'thinking',
|
agentStatus: 'thinking',
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
streamingContent: '',
|
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
|
||||||
currentIteration: 0,
|
currentIteration: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -283,7 +289,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
console.error('[AgentStore]', 'IPC sendMessage failed:', err);
|
console.error('[AgentStore]', 'IPC sendMessage failed:', err);
|
||||||
set({ agentStatus: 'error', isStreaming: false });
|
set({ agentStatus: 'error', isStreaming: false });
|
||||||
get().addMessage({
|
get().addMessage({
|
||||||
id: `msg_${Date.now()}_error`,
|
id: genMsgId('error'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `错误: 消息发送失败 — ${(err as Error).message}`,
|
content: `错误: 消息发送失败 — ${(err as Error).message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -302,7 +308,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta };
|
messages[lastIdx] = { ...lastMsg, content: lastMsg.content + delta };
|
||||||
} else {
|
} else {
|
||||||
messages.push({
|
messages.push({
|
||||||
id: `msg_${Date.now()}_assistant`,
|
id: genMsgId('assistant'),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: delta,
|
content: delta,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -317,10 +323,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
|
|
||||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||||
|
|
||||||
appendStreamingContent: (delta) =>
|
setCurrentRunId: (runId) => set({ currentRunId: runId }),
|
||||||
set((s) => ({ streamingContent: s.streamingContent + delta })),
|
|
||||||
|
|
||||||
clearStreamingContent: () => set({ streamingContent: '' }),
|
|
||||||
|
|
||||||
addTraceStep: (step) =>
|
addTraceStep: (step) =>
|
||||||
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
|
||||||
@@ -332,6 +335,14 @@ export const useAgentStore = create<AgentState>((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) =>
|
updateLastTraceStep: (updates) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (s.traceSteps.length === 0) return s;
|
if (s.traceSteps.length === 0) return s;
|
||||||
@@ -376,11 +387,13 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
traceSteps: [],
|
traceSteps: [],
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
streamingContent: '',
|
currentRunId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
abort: () => {
|
abort: () => {
|
||||||
const sessionId = get().currentSessionId;
|
const sessionId = get().currentSessionId;
|
||||||
|
// 不清除 currentRunId — 保留用于过滤旧 run 的延迟终止事件(DONE/TERMINATED)
|
||||||
|
// 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖
|
||||||
set({ agentStatus: 'idle', isStreaming: false });
|
set({ agentStatus: 'idle', isStreaming: false });
|
||||||
if (sessionId && window.metona?.agent?.abortSession) {
|
if (sessionId && window.metona?.agent?.abortSession) {
|
||||||
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
|
window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); });
|
||||||
|
|||||||
Vendored
+97
-1
@@ -59,7 +59,13 @@ interface MetonaAgentAPI {
|
|||||||
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
/** 发送 MetonaMessage(IR 类型)+ sessionId 到主进程 */
|
||||||
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
|
||||||
onStreamEvent: (callback: (event: MetonaStreamEventData) => void) => () => void;
|
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 }>;
|
abortSession: (sessionId: string) => Promise<{ success: boolean }>;
|
||||||
onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void;
|
onProviderSwitched: (callback: (data: { from?: string; to?: string; reason?: string }) => void) => () => void;
|
||||||
}
|
}
|
||||||
@@ -93,6 +99,7 @@ interface MetonaSessionsAPI {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>>;
|
}>>;
|
||||||
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
|
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
|
||||||
|
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean }>;
|
||||||
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
||||||
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
||||||
@@ -142,6 +149,12 @@ interface MetonaMemoryAPI {
|
|||||||
type?: 'episodic' | 'semantic' | 'working';
|
type?: 'episodic' | 'semantic' | 'working';
|
||||||
minImportance?: number;
|
minImportance?: number;
|
||||||
}) => Promise<MetonaMemorySearchResult[]>;
|
}) => Promise<MetonaMemorySearchResult[]>;
|
||||||
|
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 =====
|
// ===== Config API =====
|
||||||
@@ -210,6 +223,86 @@ interface MetonaDataAPI {
|
|||||||
clearAuditLogs: () => Promise<{ success: boolean; error?: string }>;
|
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<MetonaAuditVerifyResult>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 接口 =====
|
// ===== Bridge 接口 =====
|
||||||
|
|
||||||
interface MetonaBridge {
|
interface MetonaBridge {
|
||||||
@@ -223,6 +316,9 @@ interface MetonaBridge {
|
|||||||
tools: MetonaToolsAPI;
|
tools: MetonaToolsAPI;
|
||||||
data: MetonaDataAPI;
|
data: MetonaDataAPI;
|
||||||
searxng: MetonaSearXNGAPI;
|
searxng: MetonaSearXNGAPI;
|
||||||
|
tasks: MetonaTasksAPI;
|
||||||
|
audit: MetonaAuditAPI;
|
||||||
|
tool: MetonaToolAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 全局 Window 增强 =====
|
// ===== 全局 Window 增强 =====
|
||||||
|
|||||||
Reference in New Issue
Block a user