v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新

P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制

P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义

P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃

文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
This commit is contained in:
紫影233
2026-06-23 17:06:43 +08:00
parent 20cbfbd169
commit 042e00e4a6
35 changed files with 3692 additions and 784 deletions
+1
View File
@@ -7,6 +7,7 @@ build-resources/
Thumbs.db Thumbs.db
*.log *.log
window-state.json window-state.json
nul
# Build output (release for desktop) # Build output (release for desktop)
!release/*.exe !release/*.exe
+65 -29
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.11.11-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.12.3-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -34,8 +34,10 @@
| | 功能 | 说明 | | | 功能 | 说明 |
|:---:|:---|:---| |:---:|:---|:---|
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。Thought → Action → Observation → Reflection 完整循环,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行 | | 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 |
| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 | | 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) |
| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 |
| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 | | 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 |
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
@@ -44,6 +46,8 @@
| 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 | | 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 |
| 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 | | 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 |
| 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩 | | 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩 |
| ⏱️ | **看门狗超时** | Agent Loop 全局超时保护(可配,默认 30min),防止模型卡死无限循环 |
| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置文件变更审计、工具结果校验 |
| 👥 | **子代理委派** | spawn_task 工具,独立上下文 + 超时保护 | | 👥 | **子代理委派** | spawn_task 工具,独立上下文 + 超时保护 |
| 📈 | **Token 仪表盘** | 全局 + 会话统计,消耗趋势柱状图,2 秒刷新,输入/输出分色 | | 📈 | **Token 仪表盘** | 全局 + 会话统计,消耗趋势柱状图,2 秒刷新,输入/输出分色 |
| 📋 | **系统提示词卡片** | 每条 AI 回复顶部折叠卡片,点击查看实际发送给模型的完整上下文 | | 📋 | **系统提示词卡片** | 每条 AI 回复顶部折叠卡片,点击查看实际发送给模型的完整上下文 |
@@ -161,20 +165,31 @@
</details> </details>
<details>
<summary><strong>📋 Plan Mode1 个,仅 Plan 模式激活)</strong></summary>
| 工具 | 功能 |
|------|------|
| `plan_track` | Plan 模式执行进度追踪,标记步骤完成,自动统计剩余 |
</details>
## 🏗️ 架构 ## 🏗️ 架构
``` ```
用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md 用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md
Agent Engine (ReAct Loop, ≤85轮)
记忆检索 (FTS5 + 向量语义搜索) → 上下文注入 记忆检索 (FTS5 + 向量语义搜索) → 上下文注入
Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min)
5 层抗幻觉系统 (提示词 → 中途检测 → 进度锚 → 完成闸门 → 校验)
Ollama API (流式响应,num_ctx 自动检测) Ollama API (流式响应,num_ctx 自动检测)
Tool Registry (42 内置 + MCP 动态) Tool Registry (42 内置 + MCP 动态 + Plan Mode plan_track)
观察结果 → 反思 → 循环 / 最终回答 Hook 系统 (pre/post tool/iteration/completion) → 观察结果 → 反思 → 循环 / 最终回答
``` ```
### 🧩 UI 组件(原生 DOM ### 🧩 UI 组件(原生 DOM
@@ -184,15 +199,15 @@ main.ts (入口)
├── header.ts # 顶部导航栏 ├── header.ts # 顶部导航栏
├── model-bar.ts # 模型选择栏 ├── model-bar.ts # 模型选择栏
├── chat-area.ts # 聊天消息区域 ├── chat-area.ts # 聊天消息区域
├── input-area.ts # 输入框 + 文件上传 ├── input-area.ts # 输入框 + 文件上传 + Plan Mode 开关
├── workspace-panel.ts # 终端 + 文件浏览器 ├── workspace-panel.ts # 终端 + 文件浏览器 + 工具卡片
├── settings-modal.ts # 设置面板 ├── settings-modal.ts # 设置面板(含看门狗超时配置)
├── history-modal.ts # 会话历史 ├── history-modal.ts # 会话历史
├── memory-modal.ts # 记忆管理 ├── memory-modal.ts # 记忆管理
├── tools-modal.ts # 工具列表 ├── tools-modal.ts # 工具列表
├── token-dashboard.ts # Token 消耗仪表盘 ├── token-dashboard.ts # Token 消耗仪表盘
├── tool-confirm-modal.ts # 工具执行确认 ├── tool-confirm-modal.ts # 工具执行确认
├── prompt-modal.ts # 系统提示词查看 ├── prompt-modal.ts # 系统提示词查看 + Plan 确认弹窗
├── toast.ts # Toast 通知 ├── toast.ts # Toast 通知
└── lightbox.ts # 图片灯箱 └── lightbox.ts # 图片灯箱
``` ```
@@ -214,13 +229,15 @@ SQLite (sql.js WASM)6 张表,WAL 模式 + FTS5 全文搜索:
| 层级 | 措施 | | 层级 | 措施 |
|------|------| |------|------|
| 📁 文件系统 | `checkPathAllowed()` — 路径黑名单 | | 📁 文件系统 | `checkPathAllowed()` — 路径黑名单(33 个系统/敏感目录,含 Linux + Windows |
| ⚡ 命令执行 | `checkCommandAllowed()` — 命令黑名单 + 三种模式 | | ⚡ 命令执行 | `checkCommandAllowed()` — 命令黑名单(30 条危险命令,含 POSIX + Windows+ 三种模式 |
| 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) | | 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) |
| 🔒 Electron | `contextIsolation: true` + IPC 白名单 | | 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 |
| 🔐 数据加密 | AES-256-GCM | | 🔐 数据加密 | AES-256-GCM |
| 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 | | 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 |
| 🌐 MCP 安全 | Shadowing 防护 | | 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 |
| 🌐 网络安全 | `web_fetch` 流式体积限制(10MB+ 无 content-length 时防 OOM |
| ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 |
## 🚀 快速开始 ## 🚀 快速开始
@@ -239,7 +256,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
产出:`release/Metona Ollama Setup v0.11.11.exe` 产出:`release/Metona Ollama Setup v0.12.3.exe`
## 🛠️ 常用命令 ## 🛠️ 常用命令
@@ -269,8 +286,10 @@ npm run dist # 构建 Windows 安装包
| | Feature | Description | | | Feature | Description |
|:---:|:---|:---| |:---:|:---|:---|
| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. Full Thought → Action → Observation → Reflection cycle, up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution | | 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine (INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED), up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution, watchdog timeout protection |
| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System | | 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (16 rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination, injection, file, tool — 4 critical blocks) |
| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, with plan_track tool marking completion |
| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking |
| 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay | | 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay |
| 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults | | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults |
| 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection | | 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection |
@@ -279,6 +298,8 @@ npm run dist # 构建 Windows 安装包
| 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks | | 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks |
| 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed | | 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed |
| 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression | | 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression |
| ⏱️ | **Watchdog Timeout** | Global Agent Loop timeout protection (configurable, default 30min), prevents infinite loops from model stalls |
| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in file change audit and tool result verification |
| 👥 | **Sub-agent Delegation** | spawn_task tool, isolated context + timeout protection | | 👥 | **Sub-agent Delegation** | spawn_task tool, isolated context + timeout protection |
| 📈 | **Token Dashboard** | Global + session stats, consumption trend bar chart, 2s refresh, input/output color-coded | | 📈 | **Token Dashboard** | Global + session stats, consumption trend bar chart, 2s refresh, input/output color-coded |
| 📋 | **System Prompt Card** | Collapsible card atop each AI reply — click to inspect the full context sent to the model | | 📋 | **System Prompt Card** | Collapsible card atop each AI reply — click to inspect the full context sent to the model |
@@ -395,6 +416,15 @@ npm run dist # 构建 Windows 安装包
</details> </details>
<details>
<summary><strong>📋 Plan Mode (1, only active in Plan Mode)</strong></summary>
| Tool | Function |
|------|------|
| `plan_track` | Plan Mode execution progress tracking, mark steps complete, auto tally remaining |
</details>
## 🏗️ Architecture ## 🏗️ Architecture
``` ```
@@ -402,11 +432,15 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md
Memory Retrieval (FTS5 + Vector Semantic Search) → Context Injection Memory Retrieval (FTS5 + Vector Semantic Search) → Context Injection
Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min)
5-Layer Anti-Hallucination (prompt → mid-task detection → anchors → gate → verification)
Ollama API (Streaming Response) Ollama API (Streaming Response)
Tool Registry (42 Built-in + MCP Dynamic) Tool Registry (42 Built-in + MCP Dynamic + Plan Mode plan_track)
Observation → Reflection → Loop / Final Answer Hook System (pre/post tool/iteration/completion) → Observation → Reflection → Loop / Final Answer
``` ```
### 🧩 UI Components (Native DOM) ### 🧩 UI Components (Native DOM)
@@ -416,15 +450,15 @@ main.ts (Entry)
├── header.ts # Top navigation bar ├── header.ts # Top navigation bar
├── model-bar.ts # Model selector ├── model-bar.ts # Model selector
├── chat-area.ts # Chat message area ├── chat-area.ts # Chat message area
├── input-area.ts # Input box + file upload ├── input-area.ts # Input box + file upload + Plan Mode toggle
├── workspace-panel.ts # Terminal + file browser ├── workspace-panel.ts # Terminal + file browser + tool cards
├── settings-modal.ts # Settings panel ├── settings-modal.ts # Settings panel (with watchdog timeout config)
├── history-modal.ts # Session history ├── history-modal.ts # Session history
├── memory-modal.ts # Memory management ├── memory-modal.ts # Memory management
├── tools-modal.ts # Tool list ├── tools-modal.ts # Tool list
├── token-dashboard.ts # Token consumption dashboard ├── token-dashboard.ts # Token consumption dashboard
├── tool-confirm-modal.ts # Tool execution confirmation ├── tool-confirm-modal.ts # Tool execution confirmation
├── prompt-modal.ts # System prompt viewer ├── prompt-modal.ts # System prompt viewer + Plan confirm dialog
├── toast.ts # Toast notifications ├── toast.ts # Toast notifications
└── lightbox.ts # Image lightbox └── lightbox.ts # Image lightbox
``` ```
@@ -446,13 +480,15 @@ SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search:
| Layer | Measure | | Layer | Measure |
|------|------| |------|------|
| 📁 File System | `checkPathAllowed()` — path blacklist | | 📁 File System | `checkPathAllowed()` — path blacklist (33 system/sensitive dirs, Linux + Windows) |
| ⚡ Command Execution | `checkCommandAllowed()` — command blacklist + three modes | | ⚡ Command Execution | `checkCommandAllowed()` — command blacklist (30 dangerous commands, POSIX + Windows) + three modes |
| 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) | | 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) |
| 🔒 Electron | `contextIsolation: true` + IPC whitelist | | 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation |
| 🔐 Data Encryption | AES-256-GCM | | 🔐 Data Encryption | AES-256-GCM |
| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection | | 🧠 Memory Security | Pre-write prompt injection / sensitive info detection |
| 🌐 MCP Security | Shadowing protection | | 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation |
| 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length |
| ⚡ Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation |
## 🚀 Quick Start ## 🚀 Quick Start
@@ -471,7 +507,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
Output: `release/Metona Ollama Setup v0.11.11.exe` Output: `release/Metona Ollama Setup v0.12.3.exe`
## 🛠️ Common Commands ## 🛠️ Common Commands
+439
View File
@@ -0,0 +1,439 @@
# AI Agent 工程化完整文档:ReAct Loop 与 Harness Engineering
> 本文围绕当下 AI Agent 工程化的两大核心命题展开:作为智能体"思考与执行内核"的 **ReAct Loop(推理-行动-观察循环)**,以及作为"运行环境与管控体系"的 **Harness Engineering(驾驭工程)**。两者共同构成生产级 AI Agent 的工程底座。
---
## 一、背景:从"调教模型"到"建造系统"
2026 年,AI Agent 的叙事重心发生了根本性转移:从追求单个 Agent 的"智力上限",转向构建整个系统的"可靠性下限"。大模型早已不是 AI 落地的唯一瓶颈,行业已达成共识:
```
Agent = Model + Harness
```
模型是引擎,而 Harness(驾驭系统)才是决定智能体能否稳定跑完复杂长任务、从演示级走向生产级的关键。
AI 工程范式经历了三个阶段的演进:
| 阶段 | 时间范围 | 核心关注点 | 解决的问题 | 典型技术 |
|---|---|---|---|---|
| Prompt Engineering | 2022-2024 | 如何让模型理解你的意图 | 单次输出的质量 | 提示词模板、Few-shot 示例 |
| Context Engineering | 2025 | 如何给模型正确的知识边界 | 给模型看什么信息 | RAG、上下文窗口管理 |
| Harness Engineering | 2026- | 如何让 Agent 可靠、持续、不失控 | 多步骤、长周期任务的可靠性 | 状态机、沙箱、权限系统 |
这个演进路径反映了 AI 工程从"单点优化"到"系统构建"的转变:早期关注如何让模型"听懂人话",中期关注如何给模型"正确的参考资料",而现在关注如何让整个系统"持续稳定运行"。
---
## 二、ReAct LoopAgent 的思考与执行内核
### 2.1 ReAct 的起源与定义
ReAct 源自普林斯顿大学与 Google Brain 于 2022 年联合发表的经典论文《ReAct: Synergizing Reasoning and Acting in Language Models》。在 ReAct 出现之前,大模型只有单纯的推理能力(Reasoning),存在严重的幻觉、知识滞后、无法实操的问题;而单纯的工具调用只有行动能力(Acting),缺乏逻辑推理,无法自主判断何时调用工具、调用什么工具。
ReAct 的核心颠覆式创新在于:**将大模型的推理思考(Thought)与外部工具行动(Action)进行闭环融合**。
```
ReAct = Reasoning(推理思考) + Acting(工具行动)
```
它是一套迭代式循环执行范式:Agent 不再一次性输出答案,而是通过「思考→行动→观察→再思考」的无限循环,逐步拆解复杂任务、调用外部工具、修正推理偏差,直到任务完成。ReAct 是目前 90% 以上开源 AgentLangChain、LlamaIndex、Meta Agent)的底层核心执行逻辑,也是工业界公认的 AI Agent 标准思考框架。
### 2.2 T-A-O 三步循环机制
所有基于 ReAct 的 Agent,底层都是统一的 **T-A-O 循环闭环**,这是必须掌握的核心底层逻辑。
```
用户提问 → Thought 思考 → Action 行动 → Observation 观察 → 任务完成? → 否 → 再次 Thought → 是 → 输出最终答案
```
**1. Thought(推理思考)**
大模型基于当前用户问题、历史上下文、已有工具列表,进行自主推理判断:
- 当前任务是否需要调用工具?
- 需要调用哪一个工具?
- 工具入参应该如何构造?
- 当前任务是否已经完成,可以直接输出答案?
这一步是 Agent 的智能核心,完全依靠大模型的理解与推理能力。
**2. Action(工具行动)**
Agent 根据 Thought 的推理结果,通过 Harness 工具编排层,执行具体外部操作:
- 调用计算器、搜索引擎、数据库查询、接口请求、代码解释器等
- 严格按照 Harness 约束规则执行,受超时、重试、权限管控
- 单次仅执行单一工具任务,保证流程可控
**3. Observation(结果观察)**
获取 Action 工具执行的返回结果,将结果作为新的上下文信息喂给大模型,进入下一轮循环。Observation 是修正模型幻觉、补充真实信息的关键,让模型不再依赖陈旧参数知识。
完整闭环逻辑为:用户提问 → Thought 思考 → Action 执行工具 → Observation 获取结果 → 再次 Thought 迭代 → 任务完成 → 输出最终答案。
### 2.3 ReAct 范式与传统一次性 Prompt 的对比
很多新手分不清普通问答和 Agent 的区别,本质就是「是否具备 ReAct 循环能力」:
| 对比维度 | 传统一次性 Prompt 问答 | ReAct 智能体范式 |
|---|---|---|
| 执行方式 | 单次推理、一次性输出结果 | 迭代式循环、多轮思考执行 |
| 信息来源 | 仅依赖模型训练参数知识 | 模型知识 + 实时外部工具数据 |
| 复杂任务能力 | 无法拆解,复杂问题直接答错 | 自动拆解分步解决,适配复杂业务 |
| 幻觉概率 | 极高,知识滞后严重 | 大幅降低,以工具真实结果为准 |
| 工程依赖 | 无需 Harness,纯 Prompt 即可 | 强依赖 Harness 流程与工具管控 |
| 落地场景 | 简单问答、文案生成 | 企业自动化、数据查询、任务调度 |
### 2.4 ReAct 核心约束 Prompt 模板
ReAct 之所以能自动完成 T-A-O 循环,核心靠固定格式的系统 Prompt 约束,这也是 Harness 规则约束层的核心体现。原生 ReAct 标准 Prompt 核心结构如下:
```
你是一个可以自主思考和调用工具的智能体。
你需要遵循【Thought → Action → Observation】循环逻辑解决问题。
可用工具列表:{tools}
严格遵循输出格式:
1. 思考(Thought): 分析当前问题,判断是否需要调用工具
2. 行动(Action): 需要调用工具时,输出工具名称和参数
3. 观察(Observation): 接收工具返回结果
如果已经获取足够信息,无需继续调用工具,直接输出最终答案。
问题:{input}
历史记录:{agent_scratchpad}
```
其中 `agent_scratchpad` 是 ReAct 的核心缓存,记录每一轮的 Thought、Action、Observation,保存迭代全过程状态,属于 Harness 上下文记忆层能力;`tools` 是 Harness 注册的全部可调用工具列表;`input` 是用户原始任务指令。
---
## 三、Harness EngineeringAgent 的运行环境与管控体系
### 3.1 核心概念与思想
Harness Engineering,也叫"驾驭工程"或 Agent Harness,是围绕 AI Agent 构建工作环境的一套工程方法。它的目标不是让模型"回答得更好看",而是让 Agent 在真实工程系统里:
- 能理解任务
- 能读取必要上下文
- 能调用合适工具
- 能安全修改代码
- 能运行测试验证
- 能观察日志和失败原因
- 能根据反馈继续修复
- 能在边界内完成交付
简而言之:**Prompt 是你怎么跟模型说话,Harness 是你怎么给 Agent 搭工作台**。如果说大模型本身提供的是推理和生成能力,那么 Harness 提供的就是工程环境、工具系统、反馈机制和安全边界。
Harness 的原意是"马具"——套在马身上用于控制方向、承受重负、连接马车的那套皮革与金属装置。这个比喻非常精准:大模型就像一匹充满力量但难以预测的野马,而 Harness 就是那套让它变得可控、有用的装置。更精确的公式是:
```
生产级 Agent = 模型潜能 - 模型熵增 + Harness 约束
```
其中"模型熵增"指大模型基于概率生成的不确定性,输入微小的 Prompt 变化可能导致巨大的行为漂移;而"Harness 约束"则是用确定性的代码逻辑去框住不确定的模型输出。Harness Engineering 的核心思想是:**每当 Agent 犯错,就将其工程化为一个永久性的系统修复,确保它不会再犯同样的错误**。
### 3.2 Harness 与 Prompt 工程的本质区别
| 维度 | Prompt Engineering | Harness Engineering |
|---|---|---|
| 核心问题 | 如何措辞指令 | 如何构建可靠系统 |
| 作用范围 | 单次推理 | 全任务生命周期 |
| 控制手段 | 文本指令 | 工具 + 约束 + 反馈 + 基建 |
| 失败模式 | 误解意图 | 缺乏纠错机制 |
| 可复现性 | 依赖模型一致性 | 依赖工程化保障 |
| 类比 | 写指令邮件 | 建项目管理体系 |
一个具体例子可以说明这种区别。你在 prompt 里写"请遵守项目架构,不要跨层调用"——这是 Prompt 工程;但如果你把架构边界写进 custom linter,每次 Agent 改完代码都会被自动检查,违反规则就失败——这就是 Harness 工程。Prompt 可以是 Harness 的一部分,但 Harness 远远不止 Prompt。
### 3.3 Harness 七层内核
一个生产级 Harness 由七大协同组件构成,共同约束与增强智能体行为:
1. **System Prompts(系统提示)**:行为宪法,定义身份、边界、硬约束。
2. **Tools and Capabilities(工具与能力)**:精准能力接口,命名自解释、参数精确、错误可修复。
3. **Infrastructure(基础设施)**:沙箱、执行引擎、文件系统等安全运行环境。
4. **Orchestration Logic(编排逻辑)**:子智能体调度、任务分发与路由。
5. **Hooks and Middleware(钩子与中间件)**:确定性检查点,安全门控、质量回路、完成门控、可观测性。
6. **Memory and State(记忆与状态)**:进度与记忆持久化,避免长任务"失忆"。
7. **Verification Systems(验证系统)**Linter、测试、审查 Agent,最后质量防线。
它们联动形成闭环:验证触发 Hook,记忆动态组装 Prompt,编排决定工具调用。
### 3.4 引导系统与反馈系统:双控机制
为方便理解,可以把 Harness 工程拆成两大子系统:
**引导系统(前馈控制 Guide**——Agent 执行前,怎么知道该怎么做。核心是把项目里的隐性规则显性化,常见内容包括 AGENTS.md、CLAUDE.md、README、架构文档、编码规范、目录结构说明、项目启动脚本、测试命令说明、API 文档、领域知识、任务拆解模板、团队 review checklist 等。它的作用是行动前设路标与护栏,从源头减少错误。
**反馈系统(反馈控制 Sensor**——Agent 执行后,怎么知道有没有做对。常见内容包括单元测试、接口测试、端到端测试、类型检查、linter、静态扫描、架构测试、安全扫描、浏览器自动化、运行日志、metrics、traces、错误堆栈、代码评审 Agent、LLM Judge、人工 review。反馈内部再分为:
- **计算性反馈**:规则驱动、毫秒级、100% 可靠,优先用
- **推理性反馈**:AI 判断、秒级、非确定,作为补充
---
## 四、ReAct 与 Harness 的层级关系
结合 AI Agent 完整工程化体系,三者层级关系清晰可见:
```
1. LLM 模型:提供基础推理智能,是 Agent 的大脑基础
2. ReAct 范式:定义大脑的思考方式(T-A-O 循环),是 Agent 的执行内核逻辑
3. Harness 工程:为 ReAct 循环提供约束、容错、记忆、监控、工具调度的整套运行环境
```
终极公式为:
```
企业级 Agent = LLM + ReAct 执行逻辑 + Harness 工程管控
```
如果大模型是 Agent 的大脑,那 ReAct 就是 Agent 的"思考与行动规则",是让 AI 从"只会说话"变成"会干活"的关键转折点;而 Harness 则是为这套规则提供可靠运行环境的"操作系统"。ReAct 是执行内核,Harness 是运行环境,二者缺一不可。
---
## 五、生产级 Agent Harness 四层架构
深入分析 Claude Code、OpenCode、OpenClaw、Hermess 等代表性生产级 Agent 项目,可以发现一个生产级的 Agent Harness 通常分为四层,每一层都有明确的职责和边界。
### 5.1 架构全景
这四层围绕"感知→决策→行动→反馈"闭环紧密协作:
1. **推理与编排层**Agent 的"大脑与调度中心",负责核心决策逻辑。
2. **上下文与记忆层**Agent 的"工作记忆与长期记忆",管理输入和进化。
3. **工具与安全执行层**Agent 的"双手与安全护栏",封装外部调用。
4. **支撑与基础架构层**Agent 的"神经系统与循环系统",提供底层支撑。
以"帮我在项目里添加用户登录功能"为例,完整流程为:支撑层接收请求并分配会话 ID → 上下文层组装输入(从 CLAUDE.md 读取技术栈、从记忆系统加载用户偏好)→ 推理层启动 Plan Mode 生成任务清单 → 用户批准后进入 Execute Mode → 安全层拦截工具调用做 AST 分析与风险评估 → 执行结果返回推理层 → 循环继续。
### 5.2 推理与编排层:Agent Loop 的状态机化
最基础的 Agent Loop 就是一个 while 循环:
```javascript
while (!done) {
const response = await callLLM(messages);
if (response.toolCalls.length > 0) {
const results = await executeTools(response.toolCalls);
messages.push(...results);
} else {
done = true;
return response.content;
}
}
```
但在生产环境中这远远不够,需要处理流式响应、并行执行、错误恢复、用户中断、状态持久化等问题。因此生产级系统普遍采用状态机管理循环,例如 Claude Code 内部定义了精细的状态:
```javascript
enum LoopState {
INIT = 'INIT', // 初始化,准备上下文
THINKING = 'THINKING', // 正在调用 LLM
PARSING = 'PARSING', // 解析 LLM 输出
EXECUTING = 'EXECUTING', // 执行工具(可能并行)
OBSERVING = 'OBSERVING', // 收集工具结果
REFLECTING = 'REFLECTING', // (可选)反思结果
COMPRESSING = 'COMPRESSING', // 触发上下文压缩
TERMINATED = 'TERMINATED' // 终止
}
```
状态机的优势在于明确的阶段划分、易于调试、支持暂停/恢复、错误隔离。实现要点包括:状态转换要原子化、状态数据要隔离、超时控制要精细、可观测性要内置。每个状态对应一个独立的处理器(Handler),状态机只负责调度。
对于复杂任务,还支持多智能体编排,常见两种模式:
- **父子委派模式**(主 Agent 通过 Task 工具委派子任务给 SubAgent,具备上下文隔离、递归深度限制、结果聚合)
- **对等协作模式**(多个 Agent 组成团队,通过消息总线异步通信)
### 5.3 上下文与记忆层:System Prompt 的结构化组装
生产级系统的 System Prompt 分为静态区和动态区。静态区(角色定义、输出格式、安全规范)放在前面以利用 LLM 缓存,减少 Token 消耗;动态区(项目名、技术栈、当前任务、历史摘要)放在后面每次更新。
项目级上下文通过智能加载机制实现:Agent 启动时自动扫描项目根目录,按优先级寻找 CLAUDE.md、AGENTS.md、.claude/context.md 等配置文件,并对内容做智能截断(只取前 N 个 Token)。
### 5.4 上下文工程:渐进式披露
上下文是稀缺资源,上下文腐烂和描述膨胀会让准确率暴跌。核心策略是**渐进式披露**,分三层管理:
- **索引层**:始终保留项目结构、入口地图。
- **接口层**:操作模块时加载 API 与约束。
- **实现层**:修改文件时才加载源码。
用目录式索引告诉智能体"去哪找",而非"全记住",上下文可从数万 Token 压至几千。
---
## 六、实战:从零实现标准 ReAct Agent
下面给出一份基于 LangChain 的标准原生 ReAct 智能体完整可运行代码,完整保留 T-A-O 循环、格式约束、容错机制与 Harness 管控能力,适配国内开源大模型。
### 6.1 环境依赖
```bash
pip install langchain langchain-openai python-dotenv
```
### 6.2 完整可运行代码
```python
from dotenv import load_dotenv
import os
from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_react_agent
from langchain.tools import CalculatorTool
from langchain_community.utilities import WikipediaAPIWrapper
from langchain_community.tools import WikipediaQueryRun
from langchain.prompts import PromptTemplate
from langchain.globals import set_debug
# 加载环境变量
load_dotenv()
# 开启全链路日志(Harness 可观测能力)
set_debug(True)
# ===================== 1. 初始化模型(适配国内任意 OpenAI 格式接口) =====================
llm = ChatOpenAI(
model="qwen-turbo",
temperature=0.0, # 零随机性,保证 ReAct 思考逻辑稳定
openai_api_key=os.getenv("OPENAI_API_KEY"),
openai_api_base=os.getenv("OPENAI_API_BASE")
)
# ===================== 2. 注册工具(Harness 工具层) =====================
calc_tool = CalculatorTool()
wiki_api = WikipediaAPIWrapper(top_k_results=1, doc_content_chars_max=500)
wiki_tool = WikipediaQueryRun(api_wrapper=wiki_api)
tools = [calc_tool, wiki_tool]
# ===================== 3. 标准 ReAct 约束 Prompt(核心) =====================
react_prompt = PromptTemplate.from_template("""
你是严格遵循 ReAct 范式的智能体,必须按照 Thought → Action → Observation 循环执行任务。
可用工具:{tools}
执行规则:
1. 遇到需要计算、外部知识查询的问题,必须调用工具,禁止自行编造答案
2. 每一轮只能做一次思考 + 一次工具调用
3. 信息足够后,停止循环,输出简洁完整的最终答案
用户问题:{input}
执行过程记录:{agent_scratchpad}
""")
# ===================== 4. 创建 ReAct Agent + Harness 管控 =====================
agent = create_react_agent(llm, tools, react_prompt)
# Harness 容错、限流、防死循环配置
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=5, # 最大循环次数,防止 ReAct 死循环
handle_parsing_errors=True, # 解析异常兜底
timeout=15, # 超时熔断
return_intermediate_steps=True # 返回完整 ReAct 步骤
)
# ===================== 5. 测试运行 =====================
if __name__ == "__main__":
query = "请查询圆周率的近似定义,并计算 3.14159 * 128 的结果"
result = agent_executor.invoke({"input": query})
print("=" * 50)
print("最终答案:", result["output"])
print("=" * 50)
print("完整 ReAct 迭代步骤:")
for step in result["intermediate_steps"]:
print(f"步骤详情:{step}")
```
### 6.3 运行逻辑解析
执行过程遵循标准 ReAct 多轮迭代闭环:
1. 第一轮 Thought 识别任务需要先查询圆周率定义,调用维基百科工具
2. 第一轮 Action 执行百科查询
3. 第一轮 Observation 拿到文本信息,判断还需要数学计算
4. 第二轮 Thought 决定调用计算器工具执行乘法
5. 第二轮 Action 执行计算
6. 信息充足,终止循环输出最终答案
---
## 七、ReAct 工程落地常见踩坑与优化
在 Harness 工程落地中,ReAct 是故障高发点,核心问题全部来自循环机制本身。
| 问题 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 无限循环 | Agent 反复调用同一工具,无法结束任务 | 模型无法判断任务是否完成、工具返回信息重复 | Harness 层配置 `max_iterations` 最大迭代限制,强制熔断 |
| 格式解析失败 | 模型输出不遵循 Thought/Action 格式,任务中断 | Prompt 约束不严格 | 开启 `handle_parsing_errors` 异常兜底,优化 Prompt 格式约束 |
| 过度调用工具 | 简单问题也强行调用工具,浪费 Token | 缺乏常识判断规则 | 在 Prompt 中增加规则:简单常识问题可直接回答 |
| 上下文溢出 | 多轮迭代后 `agent_scratchpad` 过长触发超限 | 迭代日志累积 | 依托 Harness 记忆层,定时精简迭代日志、截断无效历史 |
### 企业级 ReAct 工程优化方案(结合 Harness 架构)
原生 ReAct 仅能实现基础能力,企业落地必须结合 Harness 架构做优化:
1. **约束层优化**:分级规则管控,简单任务弱约束、复杂任务强约束,平衡稳定性与灵活性。
2. **容错层优化**:智能重试 + 失败降级,工具调用失败时自动重试 2 次,重试失败后触发兜底答案,不中断业务流程。
3. **记忆层优化**:迭代过程轻量化存储,区分"有效迭代步骤"和"冗余日志",长期只保存关键 ReAct 决策过程。
4. **可观测层优化**:步骤级监控,统计每轮 ReAct 迭代耗时、失败率、工具调用命中率,数据驱动优化。
---
## 八、Harness 落地实践与治理
### 8.1 真实项目落地清单
在一个前后端分离的业务项目里,要让 Coding Agent 帮忙修 Bug、改接口、补测试,可以这样设计 Harness:
1. 用 AGENTS.md 或 CLAUDE.md 做统一入口,告知 Agent 项目结构、常用命令、关键约束和禁止事项。
2. 把详细架构文档放到 docs 目录,让 Agent 按需读取,而不是每次都塞进 prompt。
3. 用 linter、类型检查和架构测试限制跨层调用,避免 Agent 改出"能跑但不符合架构"的代码。
4. 提供标准化启动脚本,如 `start-backend``start-frontend``run-unit-test``run-api-test`
5. 对接口任务接入 Swagger、OpenAPI、Postman Collection 或自动化接口测试。
6. 对前端任务接入 Playwright,让 Agent 不只是看代码,而是真的打开页面验证。
7. 暴露运行日志、metrics 和 traces,让 Agent 失败后能看到原因,而不是凭空猜。
8. 在任务结束前加入 checklist,要求确认需求点、测试结果、修改范围和风险点。
9. 对复杂任务引入独立 Review Agent 或人工 review,避免单 Agent 自说自话。
10. 关键仓库必须配置权限和沙箱,限制 Agent 能访问什么、能修改什么、能执行什么命令。
### 8.2 治理三维度与落地四阶段
**治理三维度(从易到难)**
1. **可维护性**:代码规范、圈复杂度,工具成熟、自动化高。
2. **架构适应性**:性能、安全、依赖审计,需复杂基建。
3. **行为正确性**:业务需求匹配,最难、自动化最低。
**落地四阶段**
1. **基础验证**:部署 Lint 与测试,打底质量底线。
2. **前馈增强**:把失败转为 AGENTS.md 规则,显性化隐性知识。
3. **闭环优化**:高频错误变 Hook,形成自纠错。
4. **度量驱动**:用指标仪表盘数据定向优化。
### 8.3 转向循环:让错误只犯一次
Harness 的终极价值是**复利效应**:观察失败 → 诊断根因 → 工程化修复 → 编码进 Harness → 验证部署。把单次人工修正,变成永久规则。比如智能体总提交超大代码,加一条"单次提交≤200 行",所有会话永久遵守,同类错误彻底消失。
Terminal-Bench 2.0 基准显示:同一模型仅换 Harness,排名可偏移超 25 位;精良 Harness 的中等模型,能打败粗糙 Harness 的顶级模型——**Harness 质量,才是性能决定性因素**。
---
## 九、总结
ReAct 不是一个框架、不是一个工具,而是 AI Agent 的标准思考与执行范式,是所有智能体实现"自主解决复杂任务"的核心底层。它通过 T-A-O 循环让 AI 从"只会说话"变成"会干活"。
Harness Engineering 则让 AI 工程范式从"调教模型"转向"建造系统",把不可控的概率输出变成可控、可复现、可持续优化的生产级能力。它的核心是搭建可验证、可约束的运行体系,让 AI 能可靠完成长链路任务。
二者的关系是:**ReAct 是执行内核,Harness 是运行环境**。只有吃透 ReAct 的迭代闭环、踩坑痛点与工程优化,同时构建完善的 Harness 约束、容错、记忆、监控体系,才能开发出稳定、可落地、可迭代的企业级 AI Agent,而不是只能跑 Demo 的玩具智能体。
AI Agent 的竞争早已不是模型军备竞赛,而是系统工程能力的比拼——未来决定 AI 落地上限的,不是模型有多强,而是你的 Harness 有多稳。
---
## 参考来源
- 《ReAct: Synergizing Reasoning and Acting in Language Models》— Yao et al., Princeton & Google Brain, 2022
- Anthropic Claude Agent SDK 工程博客 — "Agent Harness"
- Mitchell Hashimoto — "Harness Engineering" 概念提出
- Terminal-Bench 2.0 基准测试数据
- Claude Code / OpenCode / OpenClaw / Hermess 生产级 Agent 项目源码分析
- CSDN 博客:《AI Agent 驾驭工程:从理论到生产级系统架构实战》
- CSDN 博客:《AI Agent 核心范式 ReAct 深度详解》
- CSDN 博客:《Harness EngineeringAI Agent 从"能用"到"可靠"的工程革命》
- 博客园:《面试官问:什么是 Harness 工程?》
- 腾讯新闻:《AI 大模型实战篇:AI Agent 设计模式 ReAct》
+3 -3
View File
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `release/Metona Ollama Setup v0.11.11.exe` | NSIS 安装包(可选目录、创建快捷方式) | | `release/Metona Ollama Setup v0.12.2.exe` | NSIS 安装包(可选目录、创建快捷方式) |
> 仅提供 NSIS 安装包。 > 仅提供 NSIS 安装包。
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
TOKEN="your_access_token" TOKEN="your_access_token"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"tag_name":"v0.11.11","name":"v0.11.11","body":"Release notes","target_commitish":"master"}' -d '{"tag_name":"v0.12.2","name":"v0.12.2","body":"Release notes","target_commitish":"master"}'
``` ```
### 上传附件 ### 上传附件
@@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release
TOKEN="your_access_token" TOKEN="your_access_token"
RELEASE_ID="<release_id>" RELEASE_ID="<release_id>"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.11.exe" -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.12.2.exe"
``` ```
## 故障排查 ## 故障排查
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范 # Metona Ollama Desktop — 开发规范
> 版本: v0.11.11 | 更新: 2026-06-05 | 维护: 项目团队 > 版本: v0.12.2 | 更新: 2026-06-05 | 维护: 项目团队
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.11", "version": "0.12.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.11", "version": "0.12.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.11", "version": "0.12.3",
"description": "Metona Ollama - TypeScript + Electron \u684c\u9762 AI \u804a\u5929\u5ba2\u6237\u7aef", "description": "Metona Ollama - TypeScript + Electron \u684c\u9762 AI \u804a\u5929\u5ba2\u6237\u7aef",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+11 -25
View File
@@ -149,37 +149,23 @@ export async function browserScreenshot(params: { full_page?: boolean; selector?
} }
if (params.full_page) { if (params.full_page) {
// 全页面截图:先滚动获取完整高度,截完恢复 // 全页面截图:一次性捕获整个页面矩形区域
const pageSize = await win.webContents.executeJavaScript(` const pageSize = await win.webContents.executeJavaScript(`
(() => { (() => {
const w = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth); const w = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth);
const h = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight); const h = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight);
const origScroll = { x: window.scrollX, y: window.scrollY }; return { width: w, height: h };
window.scrollTo(0, 0);
return { width: w, height: h, origScroll };
})() })()
`, true) as { width: number; height: number; origScroll: { x: number; y: number } }; `, true) as { width: number; height: number };
// 分块截图拼接(超过窗口高度的页面) const image = await win.webContents.capturePage({
const viewHeight = 800; x: 0, y: 0,
const chunks: Buffer[] = []; width: pageSize.width,
for (let y = 0; y < pageSize.height; y += viewHeight) { height: pageSize.height
await win.webContents.executeJavaScript(`window.scrollTo(0, ${y})`, true); });
await new Promise(r => setTimeout(r, 200)); const buffer = image.toPNG();
const image = await win.webContents.capturePage({ sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${buffer.length} bytes`);
x: 0, y: 0, return { success: true, png: buffer.toString('base64'), width: pageSize.width, height: pageSize.height };
width: pageSize.width,
height: Math.min(viewHeight, pageSize.height - y)
});
chunks.push(image.toPNG());
}
// 恢复滚动位置
await win.webContents.executeJavaScript(`window.scrollTo(${pageSize.origScroll.x}, ${pageSize.origScroll.y})`, true);
// 拼接所有截图(简单合并 base64)
const fullPng = Buffer.concat(chunks).toString('base64');
sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${fullPng.length} chars`);
return { success: true, png: fullPng, width: pageSize.width, height: pageSize.height };
} }
// 视口截图(默认) // 视口截图(默认)
+15 -6
View File
@@ -87,8 +87,14 @@ function persist(): void {
try { try {
const data = db.export(); const data = db.export();
const buf = Buffer.from(data); const buf = Buffer.from(data);
fs.writeFileSync(dbPath, buf); // 先写临时文件再 rename,避免写一半崩溃导致数据库损坏
} catch { /* 静默失败,不阻断主流程 */ } const tmpPath = dbPath + '.tmp';
fs.writeFileSync(tmpPath, buf);
fs.renameSync(tmpPath, dbPath);
} catch (err) {
// 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获)
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
}
} }
/** 初始化数据库(异步,需加载 WASM) */ /** 初始化数据库(异步,需加载 WASM) */
@@ -200,6 +206,7 @@ export async function initDatabase(): Promise<SQL.Database> {
action_input TEXT, action_input TEXT,
observation TEXT, observation TEXT,
loop_count INTEGER, loop_count INTEGER,
error_pattern TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
); );
@@ -210,7 +217,8 @@ export async function initDatabase(): Promise<SQL.Database> {
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON) // 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ } try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
// 兼容迁移:为已有 messages 表补充 prompt_eval_count // 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
// 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5 // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5
@@ -305,6 +313,7 @@ export interface TraceRow {
action_input: string | null; action_input: string | null;
observation: string | null; observation: string | null;
loop_count: number | null; loop_count: number | null;
error_pattern: string | null;
created_at: number; created_at: number;
} }
@@ -481,9 +490,9 @@ export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
// ─── Traces CRUD ─── // ─── Traces CRUD ───
export function saveTrace(trace: TraceRow): string { export function saveTrace(trace: TraceRow): string {
runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, created_at) runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, error_pattern, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.created_at] [trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.error_pattern, trace.created_at]
); );
persist(); persist();
return trace.id; return trace.id;
+7 -1
View File
@@ -55,7 +55,7 @@ import {
} from './tool-handlers.js'; } from './tool-handlers.js';
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js'; import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js';
import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js'; import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; import { getAllowedDirs, getBlockedDirs, setAllowedDirs, checkPathAllowed } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
import { setHTTPTimeout } from './tool-handlers-system.js'; import { setHTTPTimeout } from './tool-handlers-system.js';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
@@ -132,6 +132,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFile', async (_, filePath: string) => { ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try { try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const content = await fs.promises.readFile(filePath, 'utf-8'); const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, content, name: path.basename(filePath) }; return { success: true, content, name: path.basename(filePath) };
} catch (err) { } catch (err) {
@@ -141,6 +143,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => { ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => {
try { try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const buf = await fs.promises.readFile(filePath); const buf = await fs.promises.readFile(filePath);
return { success: true, content: buf.toString('base64'), size: buf.length, name: path.basename(filePath) }; return { success: true, content: buf.toString('base64'), size: buf.length, name: path.basename(filePath) };
} catch (err) { } catch (err) {
@@ -150,6 +154,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => { ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => {
try { try {
const check = checkPathAllowed(path.resolve(filePath), 'write');
if (!check.ok) return { success: false, error: check.reason };
await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8'); await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8');
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
+1 -1
View File
@@ -277,7 +277,7 @@ export function getAllTools(): MCPToolInfo[] {
tools.push({ tools.push({
serverName, serverName,
name: tool.name, name: tool.name,
fullName: `mcp_${serverName}_${tool.name}`, fullName: `mcp_${serverName}__${tool.name}`,
description: `[MCP:${serverName}] ${tool.description || ''}`, description: `[MCP:${serverName}] ${tool.description || ''}`,
inputSchema: tool.inputSchema inputSchema: tool.inputSchema
}); });
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.11.11', message: 'Metona Ollama Desktop v0.12.3',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+5 -1
View File
@@ -357,7 +357,8 @@ export async function handleSearchFiles(params: { path: string; query: string; s
if (searchType === 'filename' || searchType === 'both') { if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath); const fileName = path.basename(filePath);
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase(); const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
if (nameToCheck.includes(query)) { const queryToCheck = caseSensitive ? query : query.toLowerCase();
if (nameToCheck.includes(queryToCheck)) {
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] }); results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
totalMatches++; totalMatches++;
continue; continue;
@@ -471,6 +472,9 @@ export async function handleEditFile(params: { path: string; old_text: string; n
if (!allowed.ok) return { success: false, error: allowed.reason }; if (!allowed.ok) return { success: false, error: allowed.reason };
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
if (content.length > 5 * 1024 * 1024) {
return { success: false, error: `文件过大 (${(content.length / 1024 / 1024).toFixed(1)}MB),最大支持 5MB` };
}
let replaceCount: number; let replaceCount: number;
let newContent: string; let newContent: string;
+69 -15
View File
@@ -375,7 +375,34 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
} }
let text = await resp.text(); // 无 content-length 头时,流式读取并限制最大体积(防 OOM)
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
let text: string;
if (!contentLength && resp.body) {
const reader = resp.body.getReader();
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.length;
if (totalSize > MAX_BODY_SIZE) {
reader.cancel();
return { success: false, error: `页面过大 (超过 10MB),已中止下载` };
}
chunks.push(value);
}
const decoder = new TextDecoder();
text = decoder.decode(Buffer.concat(chunks));
} else if (!contentLength) {
// 无 body stream 回退,但仍需限制
text = await resp.text();
if (text.length > MAX_BODY_SIZE) {
return { success: false, error: `页面过大 (${(text.length / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
}
} else {
text = await resp.text();
}
const isHTML = contentType.includes('html'); const isHTML = contentType.includes('html');
if (isHTML) { if (isHTML) {
text = htmlToText(text); text = htmlToText(text);
@@ -1163,6 +1190,7 @@ export async function handleDownloadFile(params: { url: string; destination: str
export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise<ToolResult> { export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise<ToolResult> {
try { try {
const format = params.format || 'tar.gz'; const format = params.format || 'tar.gz';
const isWin = process.platform === 'win32';
if (params.action === 'create') { if (params.action === 'create') {
const srcPath = resolvePath(params.path); const srcPath = resolvePath(params.path);
@@ -1176,13 +1204,28 @@ export async function handleCompress(params: { action: string; path: string; des
if (!destCheck.ok) return { success: false, error: destCheck.reason }; if (!destCheck.ok) return { success: false, error: destCheck.reason };
return new Promise((resolve) => { return new Promise((resolve) => {
const cmd = format === 'zip' let child;
? `zip -r "${destPath}" "${path.basename(srcPath)}"` const srcName = path.basename(srcPath);
: `tar czf "${destPath}" "${path.basename(srcPath)}"`; const cwd = path.dirname(srcPath);
const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] });
if (format === 'zip') {
if (isWin) {
// Windows: PowerShell Compress-Archive
child = spawn('powershell', [
'-NoProfile', '-Command',
`Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force`
], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
// tar.gz: Windows 10+ has tar, Unix has tar
child = spawn('tar', ['czf', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = ''; let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => { child.on('close', (code) => {
if (code === 0) { if (code === 0) {
fs.stat(destPath).then(s => { fs.stat(destPath).then(s => {
resolve({ success: true, action: 'create', archive: destPath, size: s.size }); resolve({ success: true, action: 'create', archive: destPath, size: s.size });
@@ -1191,7 +1234,7 @@ export async function handleCompress(params: { action: string; path: string; des
resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` }); resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` });
} }
}); });
proc.on('error', (err) => resolve({ success: false, error: err.message })); child.on('error', (err) => resolve({ success: false, error: err.message }));
}); });
} else { } else {
// extract // extract
@@ -1206,20 +1249,31 @@ export async function handleCompress(params: { action: string; path: string; des
await fs.mkdir(destDir, { recursive: true }); await fs.mkdir(destDir, { recursive: true });
return new Promise((resolve) => { return new Promise((resolve) => {
let child;
const isZip = archivePath.endsWith('.zip'); const isZip = archivePath.endsWith('.zip');
const cmd = isZip
? `unzip -o "${archivePath}" -d "${destDir}"` if (isZip) {
: `tar xzf "${archivePath}" -C "${destDir}"`; if (isWin) {
const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] }); child = spawn('powershell', [
'-NoProfile', '-Command',
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`
], { stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
child = spawn('tar', ['xzf', archivePath, '-C', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = ''; let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => { child.on('close', (code) => {
resolve(code === 0 resolve(code === 0
? { success: true, action: 'extract', destination: destDir } ? { success: true, action: 'extract', destination: destDir }
: { success: false, error: stderr || `解压失败 (exit ${code})` } : { success: false, error: stderr || `解压失败 (exit ${code})` }
); );
}); });
proc.on('error', (err) => resolve({ success: false, error: err.message })); child.on('error', (err) => resolve({ success: false, error: err.message }));
}); });
} }
} catch (err) { } catch (err) {
+23 -1
View File
@@ -21,15 +21,28 @@ let allowedDirs: string[] = [
/** 永久禁止的目录 */ /** 永久禁止的目录 */
const BLOCKED_DIRS: string[] = [ const BLOCKED_DIRS: string[] = [
// Linux/macOS 系统目录
'/etc', '/sys', '/proc', '/dev', '/boot', '/root', '/etc', '/sys', '/proc', '/dev', '/boot', '/root',
'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData', '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/lib',
'/var/log', '/var/run', '/var/spool', '/var/mail',
'/tmp/.X11-unix', '/tmp/.font-unix',
// Windows 系统目录
'C:\\Windows', 'C:\\Windows\\System32', 'C:\\Windows\\SysWOW64',
'C:\\Windows\\System', 'C:\\Windows\\WinSxS',
'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData',
// 用户敏感目录
path.join(HOME, '.ssh'), path.join(HOME, '.ssh'),
path.join(HOME, '.gnupg'), path.join(HOME, '.gnupg'),
path.join(HOME, '.aws'), path.join(HOME, '.aws'),
path.join(HOME, '.azure'),
path.join(HOME, '.config'),
path.join(HOME, '.kube'),
path.join(HOME, 'AppData'),
]; ];
/** 命令黑名单 */ /** 命令黑名单 */
const BLOCKED_COMMANDS: string[] = [ const BLOCKED_COMMANDS: string[] = [
// POSIX 危险命令
'rm -rf /', 'rm -rf /*', ':(){ :|:& };:', 'rm -rf /', 'rm -rf /*', ':(){ :|:& };:',
'mkfs', 'dd if=', 'wipefs', 'shred', 'mkfs', 'dd if=', 'wipefs', 'shred',
'shutdown', 'reboot', 'poweroff', 'halt', 'shutdown', 'reboot', 'poweroff', 'halt',
@@ -39,6 +52,15 @@ const BLOCKED_COMMANDS: string[] = [
'systemctl enable', 'systemctl disable', 'systemctl enable', 'systemctl disable',
'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash', 'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash',
'eval', 'exec >', 'eval', 'exec >',
// Windows 危险命令
'rmdir /s', 'del /f', 'del /s', 'del /q',
'format', 'diskpart',
'reg add', 'reg delete', 'reg import', 'reg export',
'sc stop', 'sc delete', 'sc config',
'net user', 'net localgroup', 'net share',
'icacls', 'takeown', 'cacls',
'bcdedit', 'wmic', 'rundll32',
'schtasks /create', 'schtasks /delete',
]; ];
export interface CheckResult { export interface CheckResult {
+19 -4
View File
@@ -142,7 +142,12 @@ export function killProcess(id: string): boolean {
return false; return false;
} }
try { try {
proc.kill('SIGTERM'); // Windows 不支持 POSIX 信号,需用 taskkill 强制终止进程树
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
activeProcesses.delete(id); activeProcesses.delete(id);
sendLog('info', `🖥️ 进程已终止`, `ID: ${id}`); sendLog('info', `🖥️ 进程已终止`, `ID: ${id}`);
return true; return true;
@@ -156,7 +161,13 @@ export function killProcess(id: string): boolean {
/** 停止所有进程(窗口关闭时调用) */ /** 停止所有进程(窗口关闭时调用) */
export function killAllProcesses(): void { export function killAllProcesses(): void {
for (const [id, proc] of activeProcesses) { for (const [id, proc] of activeProcesses) {
try { proc.kill('SIGTERM'); } catch { /* ignore */ } try {
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
} catch { /* ignore */ }
} }
activeProcesses.clear(); activeProcesses.clear();
} }
@@ -166,8 +177,12 @@ export function listWorkspaceDir(dirPath?: string): { success: boolean; entries?
try { try {
const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir; const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir;
// 安全检查:必须在 workspace 范围内 // 安全检查:必须在 workspace 范围内Windows 路径大小写不敏感)
if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) { const isWin = process.platform === 'win32';
const targetCheck = isWin ? targetDir.toLowerCase() : targetDir;
const wsCheck = isWin ? _workspaceDir.toLowerCase() : _workspaceDir;
const defaultCheck = isWin ? DEFAULT_WORKSPACE_DIR.toLowerCase() : DEFAULT_WORKSPACE_DIR;
if (!targetCheck.startsWith(wsCheck) && !targetCheck.startsWith(defaultCheck)) {
return { success: false, error: '只能浏览工作空间目录内的文件' }; return { success: false, error: '只能浏览工作空间目录内的文件' };
} }
+2 -2
View File
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️', edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍', git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
}; };
@@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
}; };
+6 -5
View File
@@ -55,11 +55,12 @@ export function initHistoryModal(): void {
const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement; const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
if (!target) return; if (!target) return;
const sessionId = target.dataset.id!; const sessionId = target.dataset.id!;
const db = state.get<ChatDB>(KEYS.DB); const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
if (target.classList.contains('btn-delete-session')) { if (target.classList.contains('btn-delete-session')) {
if (await showConfirm('确定删除此会话?', '删除会话')) { if (await showConfirm('确定删除此会话?', '删除会话')) {
await db!.deleteSession(sessionId); await db.deleteSession(sessionId);
logSession('删除', sessionId); logSession('删除', sessionId);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION); const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession && currentSession.id === sessionId) { if (currentSession && currentSession.id === sessionId) {
@@ -68,13 +69,13 @@ export function initHistoryModal(): void {
loadHistory(); loadHistory();
} }
} else if (target.classList.contains('btn-export-md')) { } else if (target.classList.contains('btn-export-md')) {
const session = await db!.getSession(sessionId); const session = await db.getSession(sessionId);
if (session) exportAsMarkdown(session); if (session) exportAsMarkdown(session);
} else if (target.classList.contains('btn-export-html')) { } else if (target.classList.contains('btn-export-html')) {
const session = await db!.getSession(sessionId); const session = await db.getSession(sessionId);
if (session) exportAsHtml(session); if (session) exportAsHtml(session);
} else if (target.classList.contains('btn-export-txt')) { } else if (target.classList.contains('btn-export-txt')) {
const session = await db!.getSession(sessionId); const session = await db.getSession(sessionId);
if (session) exportAsTxt(session); if (session) exportAsTxt(session);
} else if (target.classList.contains('history-info') || target.classList.contains('history-item')) { } else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
loadHistorySession(sessionId); loadHistorySession(sessionId);
+52 -3
View File
@@ -20,13 +20,14 @@ import { estimateTokens } from '../services/context-manager.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
import { showToolConfirm } from './tool-confirm-modal.js'; import { showToolConfirm } from './tool-confirm-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js'; import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord } from '../types.js'; import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
let chatInputEl: HTMLTextAreaElement; let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement; let btnSendEl: HTMLButtonElement;
let imagePreviewEl: HTMLElement; let imagePreviewEl: HTMLElement;
let filePreviewEl: HTMLElement; let filePreviewEl: HTMLElement;
let videoPreviewEl: HTMLElement; let videoPreviewEl: HTMLElement;
let planToggleCheckbox: HTMLInputElement;
let pendingImages: Array<{ name: string; base64: string }> = []; let pendingImages: Array<{ name: string; base64: string }> = [];
let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = []; let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = []; let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = [];
@@ -150,6 +151,21 @@ export function initInputArea(): void {
renderFilePreviews(); renderFilePreviews();
} }
}); });
// ── v0.12.0 Plan Mode 切换 ──
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
if (planToggleEl) {
planToggleCheckbox = planToggleEl;
planToggleEl.addEventListener('change', () => {
const mode: AgentMode = planToggleEl.checked ? 'plan' : 'auto';
state.set('agentMode', mode);
// Plan Mode 切换时注册/移除 plan_track 工具
import('../services/tool-registry.js').then(({ setPlanModeActive }) => {
setPlanModeActive(mode === 'plan');
});
logInfo(`Agent 模式切换: ${mode === 'plan' ? 'Plan(先规划后执行)' : 'Auto(直接执行)'}`);
});
}
} }
function autoResizeTextarea(): void { function autoResizeTextarea(): void {
@@ -211,13 +227,14 @@ function compressImage(base64: string): Promise<{ compressed: string; origBytes:
async function handleImagePaths(paths: string[]): Promise<void> { async function handleImagePaths(paths: string[]): Promise<void> {
const bridge = getBridge(); const bridge = getBridge();
if (!bridge) return;
const maxSize = 20 * 1024 * 1024; const maxSize = 20 * 1024 * 1024;
// 并行读取所有图片 // 并行读取所有图片
const results = await Promise.all(paths.map(async (filePath) => { const results = await Promise.all(paths.map(async (filePath) => {
const name = filePath.split(/[/\\]/).pop() || filePath; const name = filePath.split(/[/\\]/).pop() || filePath;
try { try {
const result = await bridge!.fs.readFileBase64(filePath); const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) return { name, error: result.error }; if (!result.success) return { name, error: result.error };
const size = result.size ?? 0; const size = result.size ?? 0;
if (size > maxSize) return { name, error: `超过 20MB 限制` }; if (size > maxSize) return { name, error: `超过 20MB 限制` };
@@ -372,13 +389,14 @@ function renderImagePreviews(): void {
async function handleTextFilePaths(paths: string[]): Promise<void> { async function handleTextFilePaths(paths: string[]): Promise<void> {
const bridge = getBridge(); const bridge = getBridge();
if (!bridge) return;
// 并行读取所有文件 // 并行读取所有文件
const results = await Promise.all(paths.map(async (filePath) => { const results = await Promise.all(paths.map(async (filePath) => {
const name = filePath.split(/[/\\]/).pop() || filePath; const name = filePath.split(/[/\\]/).pop() || filePath;
if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` }; if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` };
try { try {
const result = await bridge!.fs.readFile(filePath); const result = await bridge.fs.readFile(filePath);
if (!result.success) return { name, error: result.error }; if (!result.success) return { name, error: result.error };
const content = result.content as string; const content = result.content as string;
const size = new TextEncoder().encode(content).length; const size = new TextEncoder().encode(content).length;
@@ -561,6 +579,19 @@ async function handleRetry(): Promise<void> {
updateMessageToolRecord(name, 'error', { success: false, error }); updateMessageToolRecord(name, 'error', { success: false, error });
}, },
onConfirmTool: async (call) => showToolConfirm(call), onConfirmTool: async (call) => showToolConfirm(call),
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s: string, i: number) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
return showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
}
},
onDone: async (finalContent, toolRecords, loopStats) => { onDone: async (finalContent, toolRecords, loopStats) => {
retryContent = finalContent; retryContent = finalContent;
if (retryIterations > 0) { if (retryIterations > 0) {
@@ -1237,6 +1268,24 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
onConfirmTool: async (call) => { onConfirmTool: async (call) => {
return showToolConfirm(call); return showToolConfirm(call);
}, },
/** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s, i) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
const approved = await showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
if (approved) {
logInfo('Plan Mode: 用户批准计划');
}
return approved;
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
}
},
onDone: async (finalContent, toolRecords, loopStats) => { onDone: async (finalContent, toolRecords, loopStats) => {
assistantContent = finalContent; assistantContent = finalContent;
+17
View File
@@ -124,6 +124,23 @@ export function showPrompt(options: {
}); });
} }
/** 显示自定义 confirm(支持 HTML),返回 true/false */
export function showHtmlConfirm(html: string, title = '确认', wide = false): Promise<boolean> {
ensureModal();
return new Promise((resolve) => {
resolveFn = (val) => resolve(val !== null);
titleEl!.textContent = title;
bodyEl!.innerHTML = `<div style="color:var(--text-secondary);font-size:13px;line-height:1.6;">${html}</div>`;
inputEl = null;
overlayEl!.style.display = '';
// 宽屏模式:Plan Mode 确认框需要更大空间
const modalEl = overlayEl!.querySelector('.modal') as HTMLElement;
if (modalEl) modalEl.style.maxWidth = wide ? '700px' : '';
okBtn!.focus();
});
}
/** 显示自定义 confirm,返回 true/false */ /** 显示自定义 confirm,返回 true/false */
export function showConfirm(message: string, title = '确认'): Promise<boolean> { export function showConfirm(message: string, title = '确认'): Promise<boolean> {
ensureModal(); ensureModal();
+34 -5
View File
@@ -182,11 +182,13 @@ export function initSettingsModal(): void {
}); });
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
const api = state.get<OllamaAPI>(KEYS.API); const api = state.get<OllamaAPI | null>(KEYS.API, null);
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; if (!api) { showToast('Ollama API 未连接', 'warning'); return; }
const sel = document.querySelector('#modelSelect') as HTMLSelectElement | null;
const model = sel?.value || '';
logInfo('释放显存', model || '所有模型'); logInfo('释放显存', model || '所有模型');
try { try {
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 }); await api.chat({ model: model || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success'); showToast('显存已释放', 'success');
logSuccess('显存已释放'); logSuccess('显存已释放');
updateRunningModels(); updateRunningModels();
@@ -198,9 +200,10 @@ export function initSettingsModal(): void {
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => { document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
const db = state.get<ChatDB | null>(KEYS.DB); const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) { showToast('数据库未就绪', 'warning'); return; }
if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) { if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) {
logWarn('清空所有历史记录'); logWarn('清空所有历史记录');
await db!.clearAll(); await db.clearAll();
(document.querySelector('#btnNewChat') as HTMLElement).click(); (document.querySelector('#btnNewChat') as HTMLElement).click();
showToast('已清空所有历史记录', 'success'); showToast('已清空所有历史记录', 'success');
logSuccess('历史记录已清空'); logSuccess('历史记录已清空');
@@ -305,6 +308,19 @@ export function openSettingsModal(): void {
populateSubAgentModels(); populateSubAgentModels();
updateMemoryVectorStatus(); updateMemoryVectorStatus();
loadTimeoutSettings(); loadTimeoutSettings();
loadWatchdogSetting();
}
/** 加载看门狗设置到输入框 */
async function loadWatchdogSetting(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const ms = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
const el = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
if (!el) return;
if (ms === 0) el.value = '0';
else if (ms > 0) el.value = String(Math.round(ms / 60_000));
else el.value = '';
} }
export function closeSettingsModal(): void { export function closeSettingsModal(): void {
@@ -494,6 +510,18 @@ const saveSubAgentTimeout = debounce(async () => {
}, 500); }, 500);
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout); document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
// ── v0.12.1 看门狗超时(分钟,0=禁用)──
const saveLoopWatchdog = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
const minutes = val ? parseInt(val) : -1;
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
state.set('loopWatchdogMs', ms);
if (db) await db.saveSetting('loopWatchdogMs', ms);
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
}, 500);
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
function updateMemoryVectorStatus(): void { function updateMemoryVectorStatus(): void {
const statusEl = document.querySelector('#memoryVectorStatus'); const statusEl = document.querySelector('#memoryVectorStatus');
if (!statusEl) return; if (!statusEl) return;
@@ -520,7 +548,8 @@ async function importSessions(filePath: string): Promise<void> {
try { try {
const bridge = window.metonaDesktop; const bridge = window.metonaDesktop;
const result = await bridge!.fs.readFileBase64(filePath); if (!bridge) { showToast('仅桌面端支持导入', 'warning'); return; }
const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) { if (!result.success) {
showToast(`读取文件失败: ${result.error}`, 'error'); showToast(`读取文件失败: ${result.error}`, 'error');
return; return;
+2 -2
View File
@@ -965,7 +965,7 @@ function getToolDisplayName(name: string): string {
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
}; };
@@ -982,7 +982,7 @@ function getToolIcon(name: string): string {
git: '🔖', compress: '🗜️', web_search: '🔍', git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️', memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
session_list: '📋', session_read: '📖', spawn_task: '🤖', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
}; };
+5 -1
View File
@@ -12,7 +12,11 @@ function isDesktop(): boolean {
/** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */ /** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */
function dbBridge() { function dbBridge() {
return window.metonaDesktop!.db!; const bridge = window.metonaDesktop;
if (!bridge?.db) {
throw new Error('DB bridge 不可用:非桌面环境或 SQLite 未初始化');
}
return bridge.db;
} }
export class ChatDB { export class ChatDB {
+24 -5
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<span class="logo">🦙</span> <span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.11.11</span> <span class="app-version">v0.12.3</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -174,6 +174,14 @@
</svg> </svg>
</span> </span>
</label> </label>
<label class="plan-toggle" id="planToggleWrap" title="Plan 模式:先规划后执行">
<input type="checkbox" id="togglePlan">
<span class="plan-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
</svg>
</span>
</label>
<button class="send-btn" id="btnSend"> <button class="send-btn" id="btnSend">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/> <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
@@ -371,6 +379,11 @@
<span style="font-size:13px;">超时(秒)</span> <span style="font-size:13px;">超时(秒)</span>
<input type="number" id="inputSubAgentTimeout" class="setting-input" placeholder="300" min="0" max="3600" step="30" style="width:80px;"> <input type="number" id="inputSubAgentTimeout" class="setting-input" placeholder="300" min="0" max="3600" step="30" style="width:80px;">
</div> </div>
<div style="display:flex;gap:16px;align-items:center;margin-top:8px;">
<span style="font-size:13px;">看门狗(分)</span>
<input type="number" id="inputLoopWatchdog" class="setting-input" placeholder="30" min="0" max="120" step="5" style="width:80px;">
<span class="text-muted" style="font-size:11px;">0=禁用,默认30分钟</span>
</div>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">显存管理</label> <label class="setting-label">显存管理</label>
@@ -435,10 +448,12 @@
<div class="modal-body"> <div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div> <div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div> <div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>42 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read</li><li><strong>子代理</strong>1 个):spawn_task</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div> <div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>42+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read</li><li><strong>子代理</strong>1 个):spawn_task</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div> <div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop v0.11.11 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div> <div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div> <div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查)→ 钩子校验</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复</li><li><strong>16 条幻觉检测规则</strong>:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:消息数超过阈值时强制触发压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}__{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div> <div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div> <div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
<div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div> <div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div>
@@ -453,7 +468,7 @@
<div class="modal-overlay" id="toolsModal" style="display:none;"> <div class="modal-overlay" id="toolsModal" style="display:none;">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h3>🔧 工具面板(42 个)</h3> <h3>🔧 工具面板(42 个 + 1 Plan 专属</h3>
<button class="icon-btn" id="btnCloseTools"> <button class="icon-btn" id="btnCloseTools">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -639,6 +654,10 @@
<div class="tool-card-header"><span class="tool-card-icon">🔐</span><span class="tool-card-name">hash</span><span class="tool-card-badge auto">自动</span></div> <div class="tool-card-header"><span class="tool-card-icon">🔐</span><span class="tool-card-name">hash</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">哈希计算(MD5/SHA1/SHA256/SHA384/SHA512</div> <div class="tool-card-desc">哈希计算(MD5/SHA1/SHA256/SHA384/SHA512</div>
</div> </div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">plan_track</span><span class="tool-card-badge plan-mode">Plan</span></div>
<div class="tool-card-desc">Plan 模式专属:追踪执行进度,标记/查看步骤完成状态</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+19 -1
View File
@@ -31,6 +31,8 @@ import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, swit
import { initLogPanel, addLog } from './services/log-service.js'; import { initLogPanel, addLog } from './services/log-service.js';
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js'; import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js'; import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
import { initHarnessHooks } from './services/hooks.js';
import { initVerificationSystem } from './services/verification.js';
import type { ChatSession } from './types.js'; import type { ChatSession } from './types.js';
// ─── v4.0 数据迁移:IndexedDB → SQLite ─── // ─── v4.0 数据迁移:IndexedDB → SQLite ───
@@ -346,7 +348,14 @@ async function init(): Promise<void> {
await loadModels(); await loadModels();
await initMemoryManager(); await initMemoryManager();
await loadSearxngConfig(db); await loadSearxngConfig(db);
logInit('所有组件已就绪');
// ── v0.12.0: Harness Engineering 系统初始化 ──
initHarnessHooks();
initVerificationSystem();
// Agent Metrics 无需显式初始化(按需启动)
// Context Indexer 在 Agent INIT 状态按需构建
logInit('所有组件已就绪(含 Harness v0.12.0');
const savedModel = await db.getSetting('selectedModel', ''); const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) { if (savedModel) {
@@ -409,6 +418,13 @@ async function init(): Promise<void> {
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops); (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : ''; (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
// ── v0.12.1 看门狗超时 ──
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
state.set('loopWatchdogMs', loopWatchdogMs);
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
const elWatchdog = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
if (elWatchdog) elWatchdog.value = loopWatchdogMs > 0 ? String(watchdogMin) : (loopWatchdogMs === 0 ? '0' : '');
// ── Tool Calling 设置(永久开启,不可关闭)── // ── Tool Calling 设置(永久开启,不可关闭)──
state.set('toolCallingEnabled', true); state.set('toolCallingEnabled', true);
const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled'; const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
@@ -453,6 +469,8 @@ async function init(): Promise<void> {
initTokenDashboard(); initTokenDashboard();
initWorkspacePanel(); initWorkspacePanel();
setupDesktopIntegration(); setupDesktopIntegration();
initHarnessHooks();
initVerificationSystem();
checkConnection().then(loadModels); checkConnection().then(loadModels);
} }
} }
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
/**
* Agent Metrics (v0.12.0)
* Harness Engineering Phase 8: 度量驱动的优化循环
*
*
* -
* -
* -
* -
*/
import { logInfo, logDebug } from './log-service.js';
import type { AgentMetrics, LoopContext } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 度量采集
// ═══════════════════════════════════════════════════════════════
/** 当前会话的度量快照 */
interface SessionMetrics {
sessionId: string;
model: string;
startTime: number;
endTime: number;
totalIterations: number;
toolCalls: Array<{ name: string; status: string; duration?: number }>;
totalInputTokens: number;
totalOutputTokens: number;
completionGatePassed: boolean;
errorPatterns: string[];
}
let currentMetrics: SessionMetrics | null = null;
const sessionMetricsHistory: SessionMetrics[] = [];
/** 开始采集会话度量 */
export function startSessionMetrics(sessionId: string, model: string): void {
currentMetrics = {
sessionId,
model,
startTime: Date.now(),
endTime: 0,
totalIterations: 0,
toolCalls: [],
totalInputTokens: 0,
totalOutputTokens: 0,
completionGatePassed: false,
errorPatterns: [],
};
logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`);
}
/** 记录一轮迭代 */
export function recordIteration(ctx: LoopContext): void {
if (!currentMetrics) return;
currentMetrics.totalIterations = ctx.loopCount;
currentMetrics.totalInputTokens = ctx.totalPromptEvalCount;
currentMetrics.totalOutputTokens = ctx.totalEvalCount;
}
/** 记录工具调用 */
export function recordToolCall(name: string, status: string, durationMs?: number): void {
if (!currentMetrics) return;
currentMetrics.toolCalls.push({ name, status, duration: durationMs });
if (status === 'error') {
// 错误模式:记录工具名和失败状态
const pattern = `${name}:error`;
if (!currentMetrics.errorPatterns.includes(pattern)) {
currentMetrics.errorPatterns.push(pattern);
}
}
}
/** 完成门控结果 */
export function recordCompletionGate(passed: boolean): void {
if (!currentMetrics) return;
currentMetrics.completionGatePassed = passed;
}
/** 结束会话度量 */
export function endSessionMetrics(): SessionMetrics | null {
if (!currentMetrics) return null;
currentMetrics.endTime = Date.now();
sessionMetricsHistory.push({ ...currentMetrics });
const metrics = { ...currentMetrics };
logInfo(
`Agent Metrics: 会话完成 — ` +
`${metrics.totalIterations} 轮, ` +
`${metrics.toolCalls.length} 工具调用, ` +
`${formatPercent(getToolSuccessRate(metrics))} 成功率`
);
currentMetrics = null;
return metrics;
}
// ═══════════════════════════════════════════════════════════════
// 度量计算
// ═══════════════════════════════════════════════════════════════
/** 计算工具成功率 */
function getToolSuccessRate(m: SessionMetrics): number {
if (m.toolCalls.length === 0) return 1;
const successCount = m.toolCalls.filter(t => t.status === 'success').length;
return successCount / m.toolCalls.length;
}
/** 格式化百分比 */
function formatPercent(ratio: number): string {
return (ratio * 100).toFixed(1) + '%';
}
/** 聚合全局度量 */
export function aggregateMetrics(): AgentMetrics {
const history = sessionMetricsHistory;
if (history.length === 0) {
return {
totalSessions: 0,
avgIterationsPerTask: 0,
toolSuccessRate: 0,
avgCompletionScore: 0,
frequentErrors: [],
tokenEfficiency: 0,
collectedAt: Date.now(),
};
}
const totalIterations = history.reduce((s, m) => s + m.totalIterations, 0);
const allToolCalls = history.flatMap(m => m.toolCalls);
const successCalls = allToolCalls.filter(t => t.status === 'success').length;
const totalInputTokens = history.reduce((s, m) => s + m.totalInputTokens, 0);
const totalOutputTokens = history.reduce((s, m) => s + m.totalOutputTokens, 0);
// 聚合错误模式
const errorCounts = new Map<string, number>();
for (const m of history) {
for (const pattern of m.errorPatterns) {
errorCounts.set(pattern, (errorCounts.get(pattern) || 0) + 1);
}
}
const frequentErrors = Array.from(errorCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pattern, count]) => ({ pattern, count }));
return {
totalSessions: history.length,
avgIterationsPerTask: Math.round(totalIterations / history.length),
toolSuccessRate: allToolCalls.length > 0 ? successCalls / allToolCalls.length : 0,
avgCompletionScore: history.filter(m => m.completionGatePassed).length / history.length,
frequentErrors,
tokenEfficiency: totalInputTokens > 0 ? totalOutputTokens / totalInputTokens : 0,
collectedAt: Date.now(),
};
}
// ═══════════════════════════════════════════════════════════════
// 转向循环:错误模式 → 改进建议
// ═══════════════════════════════════════════════════════════════
export interface ImprovementSuggestion {
pattern: string;
frequency: number;
suggestion: string;
severity: 'high' | 'medium' | 'low';
}
/**
*
* "让错误只犯一次"
*/
export function generateImprovementSuggestions(): ImprovementSuggestion[] {
const metrics = aggregateMetrics();
const suggestions: ImprovementSuggestion[] = [];
for (const err of metrics.frequentErrors) {
const [tool, status] = err.pattern.split(':');
// 特定工具的重复失败
if (status === 'error') {
if (tool === 'web_fetch' && err.count >= 3) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `web_fetch 频繁失败 (${err.count}次)。考虑在 AGENT.md 中添加规则:优先使用 browser_open + browser_extract 作为替代方案。`,
severity: 'high',
});
} else if (tool === 'web_search' && err.count >= 3) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `web_search 频繁失败 (${err.count}次)。检查 SearXNG 配置或网络连接。可在 AGENT.md 中添加备用搜索策略。`,
severity: 'high',
});
} else if (err.count >= 5) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `工具 ${tool} 反复失败 ${err.count} 次。建议审查该工具的参数构造逻辑,或在 AGENT.md 中添加错误处理指南。`,
severity: 'medium',
});
}
}
}
// Token 效率过低
if (metrics.tokenEfficiency < 0.1 && metrics.totalSessions > 3) {
suggestions.push({
pattern: 'low_token_efficiency',
frequency: metrics.totalSessions,
suggestion: `Token 效率过低 (${formatPercent(metrics.tokenEfficiency)})。Agent 可能过度调用工具。建议在 AGENT.md 中添加:简单常识问题直接回答,不要调用工具。`,
severity: 'medium',
});
}
// 迭代次数过高
if (metrics.avgIterationsPerTask > 50) {
suggestions.push({
pattern: 'high_iterations',
frequency: metrics.totalSessions,
suggestion: `平均迭代次数过高 (${metrics.avgIterationsPerTask} 轮)。建议降低 maxTurns 设置或在 AGENT.md 中添加提前终止条件。`,
severity: 'medium',
});
}
return suggestions;
}
/**
* AGENT.md
*/
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
if (suggestions.length === 0) return '';
let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n';
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
for (const s of suggestions) {
rules += `### ${s.pattern}\n`;
rules += `- **严重程度**: ${s.severity}\n`;
rules += `- **出现频率**: ${s.frequency}\n`;
rules += `- **建议**: ${s.suggestion}\n\n`;
}
return rules;
}
// ═══════════════════════════════════════════════════════════════
// 格式化输出(供仪表盘使用)
// ═══════════════════════════════════════════════════════════════
export function formatMetricsReport(metrics: AgentMetrics): string {
return [
`Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`,
`${'─'.repeat(50)}`,
`总会话数: ${metrics.totalSessions}`,
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
`完成门控通过率: ${formatPercent(metrics.avgCompletionScore)}`,
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
].join('\n');
}
+368
View File
@@ -0,0 +1,368 @@
/**
* Completion Gate (v0.12.0)
* Harness Engineering Phase 3: Agent
*
*
* - 100%
* - AI
* -
*/
import { logWarn, logInfo } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import type { LoopContext, CompletionCheck } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 内置检查项
// ═══════════════════════════════════════════════════════════════
/** 内容质量检查:回复不应过短或不完整 */
const contentQualityCheck: CompletionCheck = {
name: 'contentQuality',
description: '检查回复长度和质量是否达到最低标准',
check: async (ctx: LoopContext) => {
if (ctx.content.length < 50) {
return { passed: false, reason: '回复内容过短(<50字),可能不完整。请给出更详细的最终回答。' };
}
return { passed: true, reason: '' };
},
};
/** 工具结果审查:是否存在未处理的工具错误 */
const toolResultReviewCheck: CompletionCheck = {
name: 'toolResultReview',
description: '检查是否存在未处理的工具执行错误',
check: async (ctx: LoopContext) => {
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
if (errorRecords.length === 0) return { passed: true, reason: '' };
// 检查这些错误是否在最近的上下文中被讨论过
const recentContent = ctx.messages.slice(-5).map(m => m.content || '').join(' ');
const unresolvedErrors = errorRecords.filter(r => {
const errorName = r.name;
return !recentContent.includes(errorName + ' 失败')
&& !recentContent.includes(errorName + ' error')
&& !recentContent.includes(errorName + ' 错误');
});
if (unresolvedErrors.length > 0) {
return {
passed: false,
reason: `存在 ${unresolvedErrors.length} 个未处理的工具错误: ${unresolvedErrors.map(r => r.name).join(', ')}。请说明这些错误的影响或提供替代方案。`
};
}
return { passed: true, reason: '' };
},
};
/** 思考阶段检测:回复不应仍在"思考中"状态 */
const notThinkingCheck: CompletionCheck = {
name: 'notThinking',
description: '检查回复是否已从思考阶段过渡到结论阶段',
check: async (ctx: LoopContext) => {
// 仅检查短回复(<200字)——长回复几乎肯定是完整回答
if (ctx.content.length > 200) return { passed: true, reason: '' };
const contentLower = ctx.content.slice(0, 100).toLowerCase();
const thinkingMarkers = ['让我看看', '观察一下', '先检查一下', '我需要先', '正在分析', '让我再'];
const continuationSignals = ctx.content.endsWith('...') || ctx.content.endsWith('等等') || ctx.content.endsWith('…');
const conclusionMarkers = [
/Final\s*Answer/i, /最终答案/, /最终回答/, /总结/, /任务完成/, /以上就是/,
/以上[是为]/, /我的能力/, /可以帮你/, /能够/, /我是/, /以下[是为]/,
];
const hasConclusion = conclusionMarkers.some(p => p.test(ctx.content));
if (hasConclusion) return { passed: true, reason: '' };
const thinkingCount = thinkingMarkers.filter(m => contentLower.includes(m)).length;
const isClearlyThinking = thinkingCount >= 2 || (thinkingCount >= 1 && continuationSignals);
if (isClearlyThinking) {
return {
passed: false,
reason: '回复看起来仍在思考阶段(短回复 + 思考动词 + 无结论标志),请基于已有结果给出明确的结论。'
};
}
return { passed: true, reason: '' };
},
};
/** 上下文效率检查:是否存在过度调用工具 */
const contextEfficiencyCheck: CompletionCheck = {
name: 'contextEfficiency',
description: '检查工具调用效率是否合理(避免过度调用)',
check: async (ctx: LoopContext) => {
const toolMessages = ctx.messages.filter(m => m.role === 'tool');
const nonToolMessages = ctx.messages.filter(m => m.role !== 'tool' && m.role !== 'system');
if (toolMessages.length > nonToolMessages.length * 3 && ctx.loopCount > 5) {
return {
passed: false,
reason: `工具调用过多(${toolMessages.length} 次 vs ${nonToolMessages.length} 次非工具消息),可能存在重复或低效的工具调用。请检查是否可以直接基于已有结果给出回答。`
};
}
return { passed: true, reason: '' };
},
};
/** 系统提示词注入检测:防止模型在回复中尝试注入 prompt */
const promptInjectionCheck: CompletionCheck = {
name: 'promptInjection',
description: '检测回复中是否包含疑似 prompt injection 内容',
check: async (ctx: LoopContext) => {
const injectionPatterns = [
/ignore\s+(all\s+)?(previous|above)\s+instructions/i,
/you\s+are\s+now\s+(a|an)\s+/i,
/new\s+system\s*prompt/i,
/forget\s+(all\s+)?(previous|above)/i,
/\[SOUL\.md\]/,
/\[AGENT\.md\]/,
];
for (const pattern of injectionPatterns) {
if (pattern.test(ctx.content)) {
return {
passed: false,
reason: '回复中包含疑似 prompt injection 内容(试图覆盖系统提示词)。请移除相关内容后重新回答。'
};
}
}
return { passed: true, reason: '' };
},
};
/** 工具幻觉检测:模型声称完成了某操作但该工具从未被调用 */
const toolHallucinationCheck: CompletionCheck = {
name: 'toolHallucination',
description: '检测模型是否编造了工具调用结果',
check: async (ctx: LoopContext) => {
const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
const content = ctx.content;
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)──
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
// 文件写入
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
/文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/],
requiredTools: ['write_file'], label: '写入/创建文件' },
// 文件读取
{ patterns: [/已读取文件/, /文件内容显示/, /读取了.*文件/, /文件.*内容.*如下/,
/查看.*文件.*内容/, /打开文件.*看到/],
requiredTools: ['read_file', 'read_multiple_files'], label: '读取文件' },
// 文件编辑
{ patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/,
/修改了.*文件/, /更新了.*文件/],
requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
// 文件删除
{ patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/],
requiredTools: ['delete_file'], label: '删除文件' },
// 文件移动/复制/重命名
{ patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/],
requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
// 目录浏览
{ patterns: [/目录包含/, /列出.*目录/, /目录.*结构/, /文件列表.*如下/, /目录.*内容/],
requiredTools: ['list_directory', 'tree'], label: '列出目录' },
// 文件搜索
{ patterns: [/搜索到.*文件/, /找到.*匹配/, /搜索.*结果.*文件/],
requiredTools: ['search_files'], label: '搜索文件' },
// 搜索/抓取
{ patterns: [/搜索结果显示/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
/查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/],
requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
// 浏览器
{ patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
/点击了.*按钮/, /输入了.*文本/, /页面.*截图/],
requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
// 命令执行
{ patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/,
/执行.*结果/, /终端.*显示/],
requiredTools: ['run_command'], label: '执行命令' },
// Git 操作
{ patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
/git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/],
requiredTools: ['git'], label: 'Git 操作' },
// 下载文件
{ patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/],
requiredTools: ['download_file'], label: '下载文件' },
// 压缩/解压
{ patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/],
requiredTools: ['compress'], label: '压缩/解压' },
// 文件信息
{ patterns: [/文件.*大小.*字节/, /文件.*权限.*为/, /文件.*修改日期/],
requiredTools: ['get_file_info'], label: '获取文件信息' },
// 内存/记忆操作
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' },
// 会话操作
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
// 子代理
{ patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/],
requiredTools: ['spawn_task'], label: '子代理委派' },
// 系统工具
{ patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/],
requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
];
for (const rule of CLAIM_RULES) {
for (const pattern of rule.patterns) {
if (pattern.test(content)) {
const anyCalled = rule.requiredTools.some(t => calledTools.has(t));
if (!anyCalled) {
const toolList = rule.requiredTools.join(' / ');
return {
passed: false,
reason: `回复声称进行了"${rule.label}"操作,但 ${toolList} 工具从未被调用。请实际调用 ${toolList} 来完成任务,不要编造结果。`
};
}
break; // 该类别匹配到了且工具已调用,跳出
}
}
}
return { passed: true, reason: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 检查项注册与管理
// ═══════════════════════════════════════════════════════════════
/** 默认启用的检查项 */
const DEFAULT_ENABLED_CHECKS = new Set([
'contentQuality',
'toolResultReview',
'notThinking',
'promptInjection',
'toolHallucination',
]);
/** 阻断级检查:不通过时强制模型重新回答 */
const CRITICAL_CHECKS = new Set([
'toolHallucination',
'promptInjection',
]);
/** 注册表 */
const checkRegistry = new Map<string, CompletionCheck>([
['contentQuality', contentQualityCheck],
['toolResultReview', toolResultReviewCheck],
['notThinking', notThinkingCheck],
['contextEfficiency', contextEfficiencyCheck],
['promptInjection', promptInjectionCheck],
['toolHallucination', toolHallucinationCheck],
]);
/** 已启用的检查项 */
const enabledChecks = new Set(DEFAULT_ENABLED_CHECKS);
/** 注册自定义检查项 */
export function registerCompletionCheck(check: CompletionCheck): void {
checkRegistry.set(check.name, check);
logInfo(`Completion Gate: 注册检查项 "${check.name}"`);
}
/** 移除检查项 */
export function unregisterCompletionCheck(name: string): void {
checkRegistry.delete(name);
enabledChecks.delete(name);
}
/** 启用/禁用检查项 */
export function setCheckEnabled(name: string, enabled: boolean): void {
if (enabled) {
if (checkRegistry.has(name)) enabledChecks.add(name);
} else {
enabledChecks.delete(name);
}
}
/** 获取所有已注册检查项 */
export function getRegisteredChecks(): CompletionCheck[] {
return Array.from(checkRegistry.values());
}
/** 获取已启用的检查项 */
export function getEnabledChecks(): CompletionCheck[] {
return getRegisteredChecks().filter(c => enabledChecks.has(c.name));
}
// ═══════════════════════════════════════════════════════════════
// 执行门控
// ═══════════════════════════════════════════════════════════════
export interface GateResult {
passed: boolean;
reason: string;
/** 是否为阻断级失败(必须重新回答) */
critical: boolean;
/** 各项检查的详细结果 */
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
}
/**
*
*/
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
const checks = getEnabledChecks();
if (checks.length === 0) {
return { passed: true, reason: '', critical: false, details: [] };
}
const details: GateResult['details'] = [];
// 关键检查优先(幻觉/注入),咨询检查在后
const sorted = [...checks].sort((a, b) => {
const aCrit = CRITICAL_CHECKS.has(a.name) ? 0 : 1;
const bCrit = CRITICAL_CHECKS.has(b.name) ? 0 : 1;
return aCrit - bCrit;
});
for (const check of sorted) {
const start = Date.now();
try {
const result = await check.check(ctx);
details.push({
name: check.name,
passed: result.passed,
reason: result.reason,
durationMs: Date.now() - start,
});
if (!result.passed) {
const isCritical = CRITICAL_CHECKS.has(check.name);
logWarn(`Completion Gate: "${check.name}" ${isCritical ? '🔴阻断' : '🟡咨询'}${result.reason}`);
return {
passed: false,
reason: result.reason,
critical: isCritical,
details,
};
}
} catch (err) {
details.push({
name: check.name,
passed: true,
reason: `检查异常: ${(err as Error).message}`,
durationMs: Date.now() - start,
});
}
}
return { passed: true, reason: '', critical: false, details };
}
/**
* /使
*/
export function formatGateReport(result: GateResult): string {
const status = result.passed ? '✅ 通过' : '❌ 未通过';
let report = `Completion Gate ${status}`;
if (!result.passed) {
report += `${result.reason}`;
}
report += `\n${'─'.repeat(40)}`;
for (const d of result.details) {
const icon = d.passed ? '✅' : '❌';
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
}
return report;
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Context Indexer (v0.12.0)
* Harness Engineering Phase 6: 三级上下文管理
*
* (Index) + +
* (Interface) API + +
* (Implementation)
*
*
* - "去哪找""全记住"
* - Token
* - load_context
*/
import { logInfo, logDebug, logWarn } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import type { ProjectIndex, ContextTier } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 项目索引缓存
// ═══════════════════════════════════════════════════════════════
/** 项目索引缓存(5 分钟 TTL) */
let cachedIndex: ProjectIndex | null = null;
let cacheTimestamp = 0;
const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
/** 最大索引 Token 预算 */
const MAX_INDEX_TOKENS = 2000;
/**
*
*
*/
export async function buildProjectIndex(workspaceDir: string): Promise<ProjectIndex> {
// 检查缓存
if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) {
return cachedIndex;
}
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
return createEmptyIndex();
}
// 利用现有 tree 工具扫描目录结构(限制深度 3 层)
const treeResult = await bridge.tool.execute('tree', {
path: workspaceDir,
max_depth: 3,
include_hidden: false,
});
let structure = '';
if (treeResult.success && treeResult.tree) {
structure = String(treeResult.tree);
// Token 预算截断
if (estimateTokens(structure) > MAX_INDEX_TOKENS) {
const lines = structure.split('\n');
structure = lines.slice(0, Math.min(lines.length, 60)).join('\n')
+ '\n... (目录结构已截断,使用 list_directory 查看完整内容)';
}
} else {
structure = '(无法读取工作空间目录结构)';
}
// 识别入口文件
const entryFiles: string[] = [];
const commonEntries = [
'package.json', 'tsconfig.json', 'vite.config.ts',
'main.ts', 'index.ts', 'index.html', 'app.ts',
'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt',
'README.md', 'Makefile', 'docker-compose.yml',
];
for (const entry of commonEntries) {
try {
// 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理)
const cleanDir = workspaceDir.replace(/[\\/]+$/, '');
const filePath = cleanDir + '/' + entry;
const checkResult = await bridge.workspace.readFile(filePath);
if (checkResult?.success) {
entryFiles.push(entry);
}
} catch { /* ignore */ }
}
// 检测技术栈
const techStack = detectTechStack(entryFiles);
const index: ProjectIndex = {
structure,
entryFiles,
techStack,
tokenCount: estimateTokens(structure),
generatedAt: Date.now(),
};
cachedIndex = index;
cacheTimestamp = Date.now();
logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`);
return index;
} catch (err) {
logWarn('项目索引构建失败', (err as Error).message);
return createEmptyIndex();
}
}
/** 创建空索引 */
function createEmptyIndex(): ProjectIndex {
return {
structure: '(未检测到工作空间)',
entryFiles: [],
techStack: [],
tokenCount: 0,
generatedAt: Date.now(),
};
}
/** 根据入口文件检测技术栈 */
function detectTechStack(entryFiles: string[]): string[] {
const stack: string[] = [];
const fileSet = new Set(entryFiles.map(f => f.toLowerCase()));
if (fileSet.has('package.json')) stack.push('Node.js');
if (fileSet.has('tsconfig.json')) stack.push('TypeScript');
if (fileSet.has('vite.config.ts')) stack.push('Vite');
if (fileSet.has('cargo.toml')) stack.push('Rust');
if (fileSet.has('pyproject.toml')) stack.push('Python');
if (fileSet.has('go.mod')) stack.push('Go');
if (fileSet.has('cmakelists.txt')) stack.push('C/C++');
if (fileSet.has('docker-compose.yml')) stack.push('Docker');
if (fileSet.has('makefile')) stack.push('Make');
return stack.length > 0 ? stack : ['未知'];
}
/**
*
* AI "去哪找"
*/
export function buildIndexContext(index: ProjectIndex): string {
if (!index.structure || index.tokenCount === 0) return '';
let context = `【项目索引 — 始终可见】
${index.structure}
技术栈: ${index.techStack.join(', ') || '未检测'}
入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'}
💡 使 list_directory 使 read_file
💡 使 search_files
`;
// Token 预算控制
if (estimateTokens(context) > MAX_INDEX_TOKENS) {
context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)';
}
return context;
}
/**
*
* @param modulePattern "src/services/"
*/
export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise<string> {
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return '';
// 搜索模块相关的类型定义和配置文件
const searchResult = await bridge.tool.execute('search_files', {
path: workspaceDir,
query: modulePattern,
search_type: 'filename',
max_results: 10,
});
if (!searchResult.success || !(searchResult as any).results?.length) {
return `(未找到与 "${modulePattern}" 相关的接口文件)`;
}
const results = (searchResult as any).results as Array<{ path: string }>;
const paths = results.map(r => r.path).slice(0, 8);
// 批量读取接口文件(限制每文件 2000 字符)
const readResult = await bridge.tool.execute('read_multiple_files', {
paths,
max_chars_per_file: 2000,
});
if (readResult.success) {
const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n');
return `【接口层 — ${modulePattern}
${filesInfo}
${JSON.stringify((readResult as any).files)}`;
}
return `【接口层 — ${modulePattern}
${paths.join(', ')}`;
} catch (err) {
logWarn('接口层上下文加载失败', (err as Error).message);
return '';
}
}
/**
*
*/
export async function loadContextByTier(
tier: ContextTier,
modulePattern: string,
workspaceDir: string,
): Promise<string> {
switch (tier) {
case 'index': {
const index = await buildProjectIndex(workspaceDir);
return buildIndexContext(index);
}
case 'interface':
return buildInterfaceContext(modulePattern, workspaceDir);
case 'implementation':
// 实现层由 Agent 自行通过 read_file 加载
return '';
}
}
+234
View File
@@ -0,0 +1,234 @@
/**
* Harness Hook (v0.12.0)
* Harness Engineering Phase 4: 可扩展的 Agent
*
*
* - pre_tool: 工具执行前
* - post_tool: 工具执行后 lint
* - post_iteration: 每轮迭代后
* - pre_completion: 最终输出前
*
*
* -
* -
* - /
* - MCP Hook
*/
import { logInfo, logWarn, logDebug } from './log-service.js';
import type { HarnessHook, HookPhase, HookResult, HookData, LoopContext } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// Hook 注册表
// ═══════════════════════════════════════════════════════════════
const hookRegistry = new Map<string, HarnessHook>();
const phaseIndex = new Map<HookPhase, HarnessHook[]>();
function ensurePhaseIndex(phase: HookPhase): void {
if (!phaseIndex.has(phase)) {
phaseIndex.set(phase, []);
}
}
/** 注册 Hook */
export function registerHook(hook: HarnessHook): void {
hookRegistry.set(hook.name, hook);
ensurePhaseIndex(hook.phase);
phaseIndex.get(hook.phase)!.push(hook);
// 按优先级降序排列
phaseIndex.get(hook.phase)!.sort((a, b) => b.priority - a.priority);
logDebug(`Hook 已注册: "${hook.name}" phase=${hook.phase} priority=${hook.priority}`);
}
/** 移除 Hook */
export function unregisterHook(name: string): void {
const hook = hookRegistry.get(name);
if (!hook) return;
hookRegistry.delete(name);
const list = phaseIndex.get(hook.phase);
if (list) {
const idx = list.findIndex(h => h.name === name);
if (idx !== -1) list.splice(idx, 1);
}
}
/** 启用/禁用 Hook */
export function setHookEnabled(name: string, enabled: boolean): void {
const hook = hookRegistry.get(name);
if (hook) {
hook.enabled = enabled;
logDebug(`Hook "${name}" ${enabled ? '已启用' : '已禁用'}`);
}
}
/** 获取所有已注册 Hook */
export function getRegisteredHooks(): HarnessHook[] {
return Array.from(hookRegistry.values());
}
/** 获取指定阶段已启用的 Hook */
export function getPhaseHooks(phase: HookPhase): HarnessHook[] {
return (phaseIndex.get(phase) || []).filter(h => h.enabled);
}
// ═══════════════════════════════════════════════════════════════
// Hook 执行引擎
// ═══════════════════════════════════════════════════════════════
/**
* Hook
* @returns Hook
*/
export async function executeHooks(
phase: HookPhase,
ctx: LoopContext,
data: HookData = {}
): Promise<{ results: HookResult[]; allPassed: boolean }> {
const hooks = getPhaseHooks(phase);
if (hooks.length === 0) {
return { results: [], allPassed: true };
}
const results = await Promise.all(
hooks.map(async (hook) => {
try {
const result = await hook.handler(ctx, data);
if (!result.passed) {
logWarn(`Hook "${hook.name}" 未通过: ${result.message}`);
}
return result;
} catch (err) {
logWarn(`Hook "${hook.name}" 执行异常: ${(err as Error).message}`);
return { passed: true, message: `Hook 异常(已忽略): ${(err as Error).message}` };
}
})
);
const allPassed = results.every(r => r.passed);
return { results, allPassed };
}
// ═══════════════════════════════════════════════════════════════
// 内置 Hook
// ═══════════════════════════════════════════════════════════════
/**
* SecurityCheckHook
*
*/
export const securityCheckHook: HarnessHook = {
name: 'SecurityCheck',
phase: 'pre_tool',
priority: 100, // 最高优先级
enabled: true,
handler: async (_ctx: LoopContext, data: HookData) => {
const { toolName, toolArgs } = data;
if (toolName === 'run_command' && typeof toolArgs?.command === 'string') {
const cmd = toolArgs.command as string;
// 快速黑名单检查
const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', ':(){', 'chmod 777 /'];
for (const d of dangerous) {
if (cmd.toLowerCase().includes(d.toLowerCase())) {
return { passed: false, message: `危险命令被拦截: ${cmd.slice(0, 100)}` };
}
}
// 管道执行 shell 检测
if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(cmd)) {
return { passed: false, message: `禁止通过管道执行 shell: ${cmd.slice(0, 100)}` };
}
}
if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') {
const p = (toolArgs.path as string).toLowerCase();
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
for (const pp of protectedPaths) {
if (p.includes(pp)) {
return { passed: false, message: `受保护路径不可删除: ${p}` };
}
}
}
return { passed: true, message: '' };
},
};
/**
* ResultValidationHook
* Hook post_tool
* agent-engine formatToolResultForModel 40
*/
export const resultValidationHook: HarnessHook = {
name: 'ResultValidation',
phase: 'post_tool',
priority: 90,
enabled: true,
handler: async (_ctx: LoopContext, data: HookData) => {
const { toolName, toolResult } = data;
if (!toolResult) return { passed: true, message: '' };
// 检查空结果
if (!toolResult.success && !toolResult.error) {
return { passed: false, message: `工具 ${toolName} 执行失败但未提供错误信息` };
}
// 检查超大结果。不同工具有不同阈值:
// web_search 包含自动抓取内容,允许较大结果
const SIZE_LIMITS: Record<string, number> = {
web_search: 1_000_000, // 1MB(含自动抓取的多页内容)
web_fetch: 500_000, // 500KB
read_file: 200_000, // 200KB
default: 500_000, // 默认 500KB
};
const limit = SIZE_LIMITS[toolName || ''] || SIZE_LIMITS.default;
if (toolResult.success && toolResult) {
const resultStr = JSON.stringify(toolResult);
if (resultStr.length > limit) {
// 仅警告,不阻断(实际截断由 agent-engine 负责)
return {
passed: true,
message: `工具 ${toolName} 返回结果较大 (${(resultStr.length / 1024).toFixed(0)}KB),将由上下文管理器自动截断`,
data: { oversized: true, size: resultStr.length },
};
}
}
return { passed: true, message: '' };
},
};
/**
* IterationMetricsHook
*
*/
export const iterationMetricsHook: HarnessHook = {
name: 'IterationMetrics',
phase: 'post_iteration',
priority: 50,
enabled: true,
handler: async (ctx: LoopContext, _data: HookData) => {
// 收集本轮迭代统计
const totalMessages = ctx.messages.length;
const toolMessages = ctx.messages.filter(m => m.role === 'tool').length;
logDebug(
`迭代度量 [第 ${ctx.loopCount} 轮]: ` +
`${totalMessages} 消息, ${toolMessages} 工具消息, ` +
`${ctx.totalEvalCount} eval tokens, ${ctx.allToolRecords.length} 总工具调用`
);
return { passed: true, message: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 初始化:注册所有内置 Hook
// ═══════════════════════════════════════════════════════════════
let hooksInitialized = false;
export function initHarnessHooks(): void {
if (hooksInitialized) return;
registerHook(securityCheckHook);
registerHook(resultValidationHook);
registerHook(iterationMetricsHook);
hooksInitialized = true;
logInfo('Harness Hook 系统已初始化', `${hookRegistry.size} 个 Hook 已注册`);
}
+2 -2
View File
@@ -151,8 +151,8 @@ export async function executeMCPTool(toolName: string, args: Record<string, unkn
return { success: false, error: 'MCP API 不可用' }; return { success: false, error: 'MCP API 不可用' };
} }
// 解析工具全名: mcp_{serverName}_{toolName} // 解析工具全名: mcp_{serverName}__{toolName}(双下划线分隔,避免 serverName/toolName 含下划线时歧义)
const match = toolName.match(/^mcp_(.+?)_(.+)$/); const match = toolName.match(/^mcp_(.+?)__(.+)$/);
if (!match) { if (!match) {
return { success: false, error: `无效的 MCP 工具名: ${toolName}` }; return { success: false, error: `无效的 MCP 工具名: ${toolName}` };
} }
+122 -1
View File
@@ -465,6 +465,25 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
} }
}, },
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// 新增工具:Plan Mode 执行追踪
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'plan_track',
description: 'Manage Plan Mode execution progress. Use this to mark plan steps as completed so you always know what remains. REQUIRED in Plan Mode — call plan_track after completing each step.',
parameters: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['status', 'mark_done', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_all_done=全部完成' },
step_index: { type: 'integer', description: 'Step number (1-indexed) to mark as done. Required for mark_done.' },
step_label: { type: 'string', description: 'Optional: description of what was completed, for logging.' }
}
}
}
},
// ══════════════════════════════════════════════
// v5.0 新增工具:浏览器控制 // v5.0 新增工具:浏览器控制
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// v5.1 Browser 控制工具(增强版) // v5.1 Browser 控制工具(增强版)
@@ -717,7 +736,7 @@ let enabledTools: Set<string> = new Set([
'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
'session_list', 'session_read', 'spawn_task', 'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract', 'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_close', 'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
'datetime', 'calculator', 'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash' 'random', 'uuid', 'json_format', 'hash'
]); ]);
@@ -727,6 +746,17 @@ export function setToolEnabled(toolName: string, enabled: boolean): void {
else enabledTools.delete(toolName); else enabledTools.delete(toolName);
} }
/** Plan Mode 激活时注册 plan_track,关闭时移除 */
export function setPlanModeActive(active: boolean): void {
if (active) {
enabledTools.add('plan_track');
} else {
enabledTools.delete('plan_track');
// 清除追踪器状态
state.set('_planTracker', null);
}
}
export function getEnabledToolDefinitions(): ToolDefinition[] { export function getEnabledToolDefinitions(): ToolDefinition[] {
const builtIn = TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name)); const builtIn = TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name));
@@ -879,6 +909,38 @@ export async function executeTool(toolName: string, args: Record<string, unknown
return result; return result;
} }
// plan_track — Plan Mode 执行追踪
if (toolName === 'plan_track') {
const action = args.action as string;
const stepIndex = args.step_index as number | undefined;
const stepLabel = (args.step_label as string) || '';
const tracker = getPlanTracker();
if (action === 'status') {
return { success: true, action: 'status', steps: tracker.steps, total: tracker.total, done: tracker.done };
}
if (action === 'mark_done' && typeof stepIndex === 'number' && stepIndex > 0) {
const idx = stepIndex - 1;
if (idx >= 0 && idx < tracker.steps.length) {
tracker.steps[idx].done = true;
tracker.done = tracker.steps.filter(s => s.done).length;
if (stepLabel) tracker.steps[idx].label = stepLabel;
savePlanTracker(tracker);
const remaining = tracker.total - tracker.done;
logToolResult('plan_track', true, `步骤 ${stepIndex} 已完成 (${tracker.done}/${tracker.total}${remaining > 0 ? `, 剩余 ${remaining}` : ', 全部完成!'})`);
return { success: true, action: 'mark_done', step: stepIndex, done: tracker.done, total: tracker.total, remaining };
}
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
}
if (action === 'mark_all_done') {
for (const s of tracker.steps) s.done = true;
tracker.done = tracker.total;
savePlanTracker(tracker);
logToolResult('plan_track', true, `全部 ${tracker.total} 步已完成`);
return { success: true, action: 'mark_all_done', done: tracker.total, total: tracker.total, remaining: 0 };
}
return { success: false, error: `未知操作: ${action}` };
}
// run_command 走 workspace IPC // run_command 走 workspace IPC
if (toolName === 'run_command') { if (toolName === 'run_command') {
const command = args.command as string; const command = args.command as string;
@@ -918,12 +980,70 @@ export function getToolIcon(name: string): string {
git: '🔖', compress: '🗜️', web_search: '🔍', git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️', memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️',
session_list: '📋', session_read: '📖', spawn_task: '🤖', session_list: '📋', session_read: '📖', spawn_task: '🤖',
plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
}; };
return icons[name] || '🔧'; return icons[name] || '🔧';
} }
/** ── Plan Mode 执行追踪器 ── */
export interface PlanStep {
index: number;
label: string;
done: boolean;
}
export interface PlanTracker {
steps: PlanStep[];
total: number;
done: number;
active: boolean;
}
let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false };
export function getPlanTracker(): PlanTracker {
// 每次读取时同步 state 中的最新状态
const saved = state.get<PlanTracker | null>('_planTracker', null);
if (saved) _planTracker = saved;
return _planTracker;
}
export function savePlanTracker(tracker: PlanTracker): void {
_planTracker = tracker;
state.set('_planTracker', tracker);
}
export function initPlanTracker(steps: string[]): PlanTracker {
const tracker: PlanTracker = {
steps: steps.map((label, i) => ({ index: i + 1, label, done: false })),
total: steps.length,
done: 0,
active: true,
};
savePlanTracker(tracker);
logInfo('Plan Tracker 已初始化', `${tracker.total} 个步骤`);
return tracker;
}
export function clearPlanTracker(): void {
_planTracker = { steps: [], total: 0, done: 0, active: false };
state.set('_planTracker', null);
}
export function formatPlanStatus(): string {
const t = getPlanTracker();
if (!t.active || t.steps.length === 0) return '';
const lines = t.steps.map(s => {
const icon = s.done ? '✅' : '⬜';
return `${icon} 步骤${s.index}: ${s.label}`;
});
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0;
return `[Plan Mode 执行进度: ${t.done}/${t.total} (${pct}%)]\n${lines.join('\n')}\n\n完成每一步后请调用 plan_track(action='mark_done', step_index=N) 标记已完成。`;
}
export function formatToolName(name: string): string { export function formatToolName(name: string): string {
const names: Record<string, string> = { const names: Record<string, string> = {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
@@ -935,6 +1055,7 @@ export function formatToolName(name: string): string {
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
}; };
+185
View File
@@ -0,0 +1,185 @@
/**
* Verification System (v0.12.0)
* Harness Engineering Phase 5: 计算性反馈管道
*
* Agent
* - Linter
* - Type Checker
* - Test Runner
* - Diff Analyzer
*
*
* - run_command
* - Hook hooks.ts post_tool
* - Observation Agent
* -
*/
import { registerHook } from './hooks.js';
import type { HarnessHook } from '../types.js';
import { logInfo, logDebug, logWarn } from './log-service.js';
import type { LoopContext, HookData, HookResult } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 验证配置
// ═══════════════════════════════════════════════════════════════
export interface VerificationConfig {
/** 是否启用自动 Lint */
autoLint: boolean;
/** 是否启用自动类型检查 */
autoTypeCheck: boolean;
/** 是否启用自动测试 */
autoTest: boolean;
/** Lint 命令(留空自动检测) */
lintCommand: string;
/** 类型检查命令 */
typeCheckCommand: string;
/** 测试命令 */
testCommand: string;
}
/** 默认配置 */
const defaultConfig: VerificationConfig = {
autoLint: false,
autoTypeCheck: false,
autoTest: false,
lintCommand: '',
typeCheckCommand: '',
testCommand: '',
};
/** 运行时配置 */
let config: VerificationConfig = { ...defaultConfig };
// ═══════════════════════════════════════════════════════════════
// 文件类型检测
// ═══════════════════════════════════════════════════════════════
/** 检测文件扩展名对应的语言 */
function detectFileType(filePath: string): string | null {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', rs: 'rust', go: 'go', java: 'java',
cpp: 'cpp', c: 'c', rb: 'ruby', css: 'css',
html: 'html', vue: 'vue', svelte: 'svelte',
};
return map[ext] || null;
}
/** 检测项目根目录(查找 package.json / pyproject.toml / Cargo.toml */
function detectProjectRoot(filePath: string): string | null {
// 简化实现:向上查找 package.json
const parts = filePath.replace(/\\/g, '/').split('/');
for (let i = parts.length - 1; i >= 0; i--) {
const dir = parts.slice(0, i + 1).join('/');
// 这里无法实际访问文件系统,返回文件所在目录的父级作为近似
}
// 返回文件所在目录
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return lastSep > 0 ? filePath.slice(0, lastSep) : null;
}
// ═══════════════════════════════════════════════════════════════
// 验证 Hook 实现
// ═══════════════════════════════════════════════════════════════
/**
* DiffAnalyzerHook
* Agent
*/
export const diffAnalyzerHook: HarnessHook = {
name: 'DiffAnalyzer',
phase: 'post_tool',
priority: 75,
enabled: true,
handler: async (_ctx: LoopContext, data: HookData): Promise<HookResult> => {
const { toolName, toolArgs } = data;
if (toolName !== 'write_file' && toolName !== 'edit_file') {
return { passed: true, message: '' };
}
const filePath = (toolArgs?.path as string) || '';
if (!filePath) return { passed: true, message: '' };
// 分析修改的文件类型
const fileType = detectFileType(filePath);
if (!fileType) return { passed: true, message: '' };
// 分析跨层调用风险
const warnings: string[] = [];
// 检测是否修改了核心基础模块
const criticalPaths = ['/core/', '/base/', '/common/', '/shared/', '/utils/', '/lib/'];
const filePathLower = filePath.toLowerCase();
for (const cp of criticalPaths) {
if (filePathLower.includes(cp)) {
warnings.push(`⚠️ 修改了核心基础模块 "${filePath}",可能影响多个依赖方`);
break;
}
}
if (warnings.length > 0) {
return {
passed: true, // 警告不阻止,但注入提醒
message: warnings.join('; '),
data: { warnings },
};
}
return { passed: true, message: '' };
},
};
/**
* FileChangeAuditHook
* Agent
*/
export const fileChangeAuditHook: HarnessHook = {
name: 'FileChangeAudit',
phase: 'post_tool',
priority: 30,
enabled: true,
handler: async (ctx: LoopContext, data: HookData): Promise<HookResult> => {
const { toolName, toolArgs, toolResult } = data;
if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'delete_file') {
return { passed: true, message: '' };
}
const filePath = (toolArgs?.path as string) || '';
const success = toolResult?.success || false;
logInfo(
`文件变更审计 [第 ${ctx.loopCount} 轮]: ` +
`${toolName} ${filePath}${success ? '✅' : '❌'}`
);
return { passed: true, message: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════
let verificationInitialized = false;
export function initVerificationSystem(): void {
if (verificationInitialized) return;
registerHook(diffAnalyzerHook);
registerHook(fileChangeAuditHook);
verificationInitialized = true;
logInfo('验证系统已初始化', 'DiffAnalyzer + FileChangeAudit Hook 已注册');
}
/** 获取验证配置 */
export function getVerificationConfig(): VerificationConfig {
return { ...config };
}
/** 更新验证配置 */
export function updateVerificationConfig(updates: Partial<VerificationConfig>): void {
Object.assign(config, updates);
logInfo('验证配置已更新', JSON.stringify(config));
}
+54 -2
View File
@@ -631,6 +631,52 @@ html, body {
background: var(--critical-bg); background: var(--critical-bg);
} }
/* ── Plan 按钮 (v0.12.0) ── */
.plan-toggle {
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
user-select: none;
}
.plan-toggle input { display: none; }
.plan-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-control);
background: var(--bg-card);
border: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
color: var(--text-tertiary);
}
.plan-btn svg {
width: 18px;
height: 18px;
}
.plan-toggle:hover .plan-btn {
border-color: var(--border-strong);
color: var(--text-secondary);
}
.plan-toggle input:checked + .plan-btn {
background: rgba(71, 132, 143, 0.1);
border-color: #47848F;
color: #47848F;
box-shadow: 0 0 16px rgba(71, 132, 143, 0.2);
}
.plan-toggle input:checked + .plan-btn:hover {
background: rgba(71, 132, 143, 0.15);
box-shadow: 0 0 20px rgba(71, 132, 143, 0.3);
}
/* ── 开关控件 ── */ /* ── 开关控件 ── */
.toggle-label { .toggle-label {
display: flex; display: flex;
@@ -2449,8 +2495,8 @@ html, body {
*/ */
.workspace-panel { .workspace-panel {
width: 480px; width: 400px;
min-width: 480px; min-width: 400px;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3539,6 +3585,12 @@ html, body {
border: 1px solid rgba(217, 79, 92, 0.15); border: 1px solid rgba(217, 79, 92, 0.15);
} }
.tool-card-badge.plan-mode {
color: var(--accent);
background: rgba(232, 115, 74, 0.08);
border: 1px solid rgba(232, 115, 74, 0.2);
}
.tool-card-desc { .tool-card-desc {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
+150 -1
View File
@@ -324,7 +324,22 @@ export type StateKey =
| 'runCommandMode' | 'runCommandMode'
| 'memoryEnabled' | 'memoryEnabled'
| 'memoryEntries' | 'memoryEntries'
| 'embeddingModel'; | 'embeddingModel'
| '_defaultModel'
| '_lastSystemPrompt'
| '_currentEvalCount'
| '_abortToolRecords'
| '_loopState'
| '_loopContext'
| 'modelSupportsTools'
| 'maxTurns'
| 'streamTimeout'
| 'subAgentMaxLoops'
| 'subAgentTimeout'
| 'subAgentModel'
| 'agentMode'
| 'loopWatchdogMs'
| '_planTracker';
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Tool Calling 类型 // Tool Calling 类型
@@ -379,6 +394,139 @@ export interface ToolCallRecord {
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done'; export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
// ═══════════════════════════════════════════════════════════
// Harness Engineering: Agent Loop 状态机 (v0.12.0)
// ═══════════════════════════════════════════════════════════
/** Agent Loop 状态枚举 */
export type LoopState =
| 'INIT' // 构建系统提示词、准备上下文
| 'THINKING' // 调用 LLM 流式
| 'PARSING' // 解析输出(tool_calls 提取 + 文本兜底)
| 'EXECUTING' // 执行工具(按批次并行)
| 'OBSERVING' // 收集工具结果、格式化
| 'REFLECTING' // 反思:去重检测、过早停止检测、Final Answer 检测
| 'COMPRESSING' // 触发上下文压缩
| 'TERMINATED'; // 终止
/** Agent Loop 上下文(可序列化,支持暂停/恢复) */
export interface LoopContext {
/** 状态机当前状态 */
state: LoopState;
/** 会话 ID */
sessionId: string;
/** 当前循环计数 */
loopCount: number;
/** 最大循环次数 */
maxLoops: number;
/** 当前轮模型输出内容 */
content: string;
/** 当前轮思考内容 */
thinking: string;
/** 本轮工具调用列表 */
toolCalls: ToolCall[];
/** 所有工具调用记录 */
allToolRecords: ToolCallRecord[];
/** 消息列表(Ollama 格式) */
messages: OllamaMessage[];
/** 累计 eval tokens */
totalEvalCount: number;
/** 累计 prompt_eval tokens */
totalPromptEvalCount: number;
/** 累计推理时间(纳秒) */
totalInferenceNs: number;
/** 本轮 eval tokens */
loopEvalCount: number;
/** 本轮 prompt_eval tokens */
loopPromptEvalCount: number;
/** 本轮推理时间 */
loopInferenceNs: number;
/** 上一轮成功工具调用的缓存键(用于去重) */
prevLoopSuccessKeys: string[];
/** 上一轮工具调用(用于 onNewIteration */
prevToolCalls: ToolCall[];
/** Agent 模式:auto(直接执行)| plan(先规划后执行) */
mode: AgentMode;
/** Plan 模式下等待用户确认的 Promise resolve */
planApproved?: boolean;
/** Plan Mode 重试计数(防止无限循环) */
planRetries: number;
/** 循环开始时间 */
startTime: number;
}
/** Agent 运行模式 */
export type AgentMode = 'auto' | 'plan';
/** System Prompt 分区(支持 LLM Prefix Caching */
export interface SystemPromptZones {
/** 静态区:SOUL.md + 环境基础 → 利用 LLM Prefix Caching */
staticZone: string;
/** 动态区:记忆检索 + 工作空间 + 实时日期 */
dynamicZone: string;
}
/** Hook 阶段 */
export type HookPhase = 'pre_tool' | 'post_tool' | 'post_iteration' | 'pre_completion';
/** Hook 执行结果 */
export interface HookResult {
/** 是否通过 */
passed: boolean;
/** 消息(通过/失败原因) */
message: string;
/** 附加数据 */
data?: Record<string, unknown>;
}
/** Harness Hook 接口 */
export interface HarnessHook {
name: string;
phase: HookPhase;
priority: number;
handler: (ctx: LoopContext, data: HookData) => Promise<HookResult>;
enabled: boolean;
}
/** Hook 上下文数据 */
export interface HookData {
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: ToolResult;
iterationIndex?: number;
[key: string]: unknown;
}
/** 完成门控检查项 */
export interface CompletionCheck {
name: string;
description: string;
check: (ctx: LoopContext) => Promise<{ passed: boolean; reason: string }>;
}
/** Agent 度量指标 */
export interface AgentMetrics {
totalSessions: number;
avgIterationsPerTask: number;
toolSuccessRate: number;
avgCompletionScore: number;
frequentErrors: Array<{ pattern: string; count: number }>;
tokenEfficiency: number;
collectedAt: number;
}
/** 渐进式披露:上下文层级 */
export type ContextTier = 'index' | 'interface' | 'implementation';
/** 项目索引摘要 */
export interface ProjectIndex {
structure: string;
entryFiles: string[];
techStack: string[];
tokenCount: number;
generatedAt: number;
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// ReAct Trace 类型 (v4.0) // ReAct Trace 类型 (v4.0)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -392,6 +540,7 @@ export interface TraceEntry {
actionInput: string; actionInput: string;
observation: string; observation: string;
loopCount: number; loopCount: number;
errorPattern?: string;
createdAt: number; createdAt: number;
} }