Compare commits

..
10 Commits
Author SHA1 Message Date
紫影233 ad44989ebb fix: 工具数量勘误 40→32 + 上下文长度选项补齐低端档位
- DEVELOPMENT.md/workspace-panel.ts/index.html 工具数量勘误(删除 8 个非必要工具后残留的 40,修正为 32)
- DEVELOPMENT.md 文件系统分类数 16→13
- index.html 上下文长度下拉框新增 8K/16K/32K/64K 四档(原仅 128K-1M,跳档过稀)
2026-07-16 17:59:40 +08:00
紫影233 94e0a36981 v0.16.12: 删除 8 个非必要工具 — get_file_info/diff_files/replace_in_files/random/uuid/json_format/hash/datetime 2026-07-16 17:47:02 +08:00
紫影233 45b50e4dc7 v0.16.11: Plan Mode 最小必要性清理 — 删除进度注入和死代码链
删除项:
- Plan Mode 每轮进度注入代码(formatPlanStatus() 主动注入到 user 消息)
  AI 改为从 plan_track mark_done/status 工具返回值自行推断进度
- _lastPlanStatus 死代码链(agent-engine.ts 2 处保存 + input-area.ts 2 处清空)
  保存后从未被读取使用,纯死代码
- formatPlanStatus import(已无调用点)
- _lastPlanStatus 和 _planLastInjectedDone StateKey 声明

简化项:
- "计划已批准"消息移除 (web_search、write_file 等) 工具名列举,让 AI 自行判断

修正项:
- L2431 注释从"AI 可通过 formatPlanStatus() 看到剩余步骤"
  改为"AI 通过 plan_track mark_done/status 的工具返回值获知进度"

总计 8 文件变更
2026-07-16 16:56:32 +08:00
紫影233 80581deb37 v0.16.10: 最小必要性清理 — 删除非必要校验和注入提示
按"让 AI 自己判断"原则,删除所有"工程师判断"性质的校验代码和注入提示,
只保留安全防护(路径沙箱/命令安全/参数消毒)和必要的上下文管理(压缩/截断)。

删除项:
- completion-gate.ts 整个文件(notThinking/toolResultReview/contextEfficiency/planModeCompletion)
- verifyToolResult 工具结果核验函数 + TOOLS_NEED_VERIFY 常量
- 跨轮次死循环检测器(recordLoopSignature/detectLoopDeadlock/resetLoopDeadlockDetector)
- R77 checkRateLimit 速率限制 + R87 isToolCircuitBroken 熔断器 + R104 isDuplicateToolResult 去重
- R56 目标对齐验证 + R63 速率限制 + R87 熔断器 + R104 去重检测 + R119 优先级排序
- agent-metrics recordCompletionGate + completionGatePassed + avgCompletionScore 相关代码
- context-manager 低价值关键词黑名单 + 快速摘要改用 user role
- LoopContext 的 completionGateFailCount/verifyWarnings 字段

修复项:
- R76 路径沙箱 replace bug:用 startsWith 前缀锚定替代 replace,避免子串误判
- R28 命令注入检测:缩小匹配范围,仅拦截命令替换中包含危险命令的情况

总计 13 文件变更,+46/-1124 行
2026-07-16 16:44:04 +08:00
紫影233 a4b82b45bc v0.16.9: 核心引擎深度分析修复 — 27 个问题(P0×8 + P1×19 + P2 死代码)
引擎层(agent-engine.ts):
- P0-R1: handleExecuting 中止改 throw AbortError,让主循环 catch 统一处理 UI 反馈
- P0-E1: executeToolWithTimeout 注释修正,明确 fire-and-forget 限制
- P0-P3: persistLoopContext 改名 snapshotLoopContext + 补全字段,明确语义为运行时快照
- P1-E1: handleInit 增加 isAborted 检查(loadCustomFiles 和 search 后)
- P1-E2: catch 块调用 snapshotLoopContext 确保终止状态写入
- P1-E3: onConfirmTool await 后检查中止信号
- P1-E4: flushAllTraces 改为 await
- P1-E5: 死锁检测 ephemeral 消息加入 PRESERVE_PATTERNS
- P1-E6: 记忆提取 setTimeout 存储到 _pendingMemoryTimers,finally 块清理
- P1-R5: handleExecuting 中止时设置 _abortToolRecords

上下文层(context-manager.ts + agent-engine.ts):
- P0-C1: 删除 nonSystemCompressed,旧摘要不再保留(已合并到新摘要)
- P0-C2: 部分删除 tool 消息时也 strip tool_calls,降级为纯文本 assistant
- P1-C1: handleCompressing 移除 shouldAutoCompress 二次判断,直接执行压缩
- P1-C2: COMPRESSING 状态增加中止检查
- P1-C3: R15 tail 边界保护 middle 无 assistant 时移除孤立 tool 消息
- P1-C4: 压缩接受条件改 AND(3 处:handleInit/R8/handleCompressing)
- P1-C6: head 中已压缩消息的 tool_calls 清除,避免累积膨胀

渲染层(chat-area.ts + input-area.ts + workspace-panel.ts):
- P0-R2: appendAssistantPlaceholder 重置 _streamLastRenderedLen/_streamLastRenderedHash/_streamRenderContent
- P1-R1: rAF 竞态通过重置 _streamRenderContent 解决
- P1-R2: clearMessages/clearMessagesDOM 重置 _sysPromptRendered
- P1-R3: onNewIteration 渲染顺序修正(remove → render → append)
- P1-R4: updateToolCardDOM 改用 querySelectorAll 取最后一个匹配

持久化层(chat-db.ts + sqlite.ts + ipc.ts + history-modal.ts):
- P0-P1: onNewIteration 调用 saveCurrentSession 持久化中间消息
- P0-P2: 非 AbortError 异常时保存 partial 消息(标记 interrupted: true)
- P1-P1: saveSession 增量保存(_savedMsgIds Set 追踪),resetSavedMsgTracking
- P1-P2: 新增 saveSettingsBatch 批量写盘接口 + IPC handler
- P1-P3: 删除死表 tool_calls 的 saveToolCall/getToolCallsBySession 函数和 IPC handler

死代码清理:
- chat-area.ts: 删除 appendToolCallCardToPlaceholder/updateToolCallCardInPlaceholder(从未调用)
- chat-area.ts: 删除 _streamRenderThink/_streamRenderModel 未使用变量
- input-area.ts: 删除 updateMessageToolRecord 空函数 + 对应 import

版本号 0.16.8 → 0.16.9
2026-07-16 15:53:28 +08:00
紫影233 2ac07208b5 v0.16.8: Agent Loop P0-P3 修复 + 死代码清理 + 版本号升级
- P0: executeToolWithTimeout 重写为 Promise.race,实现真正超时中断
- P1 #2: R8 紧急压缩限制最多 2 次,防止溢出→压缩→重试无限循环
- P1 #3: 新增 snapshotSafetyState/restoreSafetyState,Sub-Agent 在隔离环境中运行
- P1 #4: persistLoopContext 中止时持久化 TERMINATED 状态
- P1 #5: Completion Gate 失败时注入纠正提示并重试(最多 1 次)
- P2 #6: executeSingleTool 重试逻辑兜底返回完善
- P2 #7: 压缩阈值提高(0.3→0.5, 0.8→0.85)+ compressedThisCycle 防同轮重复压缩
- P2 #8: 路径依赖检查优先于 ALWAYS_PARALLEL,防止读写同路径被错误并行
- P2 #9: R105 消息年龄追踪改为内容哈希 key(后被确认为死代码并清理)
- P3 #11: ctx_tokens 统一语义为 prompt_eval_count
- P3 #12: 自动记忆提取捕获 signal 引用,避免竞态
- P3 #13: verifyToolResult 返回警告字符串供 Completion Gate 使用
- 注入消毒增强:零宽字符移除、全角→半角、新增覆盖模式
- 死代码清理:R105 上下文年龄追踪系列函数(无任何调用点)
- 版本号 0.16.7 → 0.16.8(package.json/lock, menu.ts, index.html, README.md)
2026-07-16 14:31:19 +08:00
紫影233 4b1b64dddc docs: 添加 Ollama API 文档 2026-07-14 16:28:49 +08:00
紫影233 bd3a06bfaf v0.16.7: 引擎修复 + 工具面板统一 + AGENT.md 改为仅工作空间加载
核心引擎修复:
- 状态转换表补全 THINKING/PARSING/EXECUTING -> COMPRESSING,修复紧急压缩成为死代码的 P1 问题
- 新增跨轮次死循环检测器(软性提示 + 硬性熔断),防止模型陷入重复工具调用死循环
- handleCompressing 空响应回到 THINKING 而非 REFLECTING,避免错误终止
- executeHooks 添加 .catch() 防止未处理的 Promise 拒绝
- ALWAYS_PARALLEL 移除 git 和 browser_evaluate(有副作用的工具不应并行)
- thinking fallback:content 为空但有 thinking 时,用 [推理过程] 作为 content 保留上下文
- 8个写类工具添加专用格式化器(含 success + message 字段)
- 清理死代码:3个未使用函数 + 3个未使用 import

工具面板统一:
- 10个工具独立下拉框统一为1个全局执行模式选择器
- FIFO 队列防止并行 showToolConfirm 导致静默取消
- delete_file 支持 paths 数组参数批量删除

工具定义与实现一致性修复:
- run_command 移除未使用的 timeout 参数,描述改为"超时可配置"
- list_directory 添加 2000 条截断逻辑 + filter_extension 参数
- calculator 正则移除 ^ 字符(parser 用 ** 替代)
- search_files/tree/web_search/fetch_top 描述与实现对齐

消息传递修复:
- trimByTokenLimit 改为原子组选择(assistant+tool_calls 与后续 tool 消息作为一组)
- 历史工具结果复用 formatToolResultForModel,与当前格式一致

AGENT.md 加载策略变更:
- 删除内置 AGENT.md 文件
- 仅从工作空间加载:有则注入,无则跳过

其他修复:
- 修复初始化失败 "Cannot convert undefined or null to object"(saveSetting null 导致 JSON.parse 陷阱)
- 修复工作空间命令行标签页 idle 状态残留导致样式错乱

版本号: 0.16.5 -> 0.16.7
2026-07-14 16:26:43 +08:00
thzxx 4326fcce42 v0.16.2: 附件JSON增加description字段,避免AI重复调用文件读取工具 2026-07-12 08:19:59 +08:00
thzxx b8ea18e568 v0.16.2: 优化附件提示词格式 — 去base64/真实文件名/用户文字前置 2026-07-12 08:04:53 +08:00
38 changed files with 3365 additions and 2856 deletions
+11 -27
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.16.1-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.16.12-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/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">
@@ -37,7 +37,7 @@
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),智能重试(永久错误立即返回、瞬态错误指数退避),工具去重,智能路径依赖串行化,看门狗超时保护 |
| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询) |
| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态,**支持断点续传**(中止后可恢复未完成计划) |
| 🔧 | **40 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
| 🔧 | **32 个内置工具** | 文件系统(13个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 |
| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空间 MEMORY.md 文件,受路径保护仅 memory 工具可访问,写入前安全扫描,容量 500 条,对话结束自动提取,严格格式校验 |
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则,内置 fallback)+ USER.md(用户画像,仅工作空间读取,不存在则不注入) |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
@@ -59,7 +59,7 @@
## 🔧 工具清单
<details>
<summary><strong>📁 文件系统(16 个)</strong></summary>
<summary><strong>📁 文件系统(13 个)</strong></summary>
| 工具 | 功能 |
|------|------|
@@ -72,11 +72,8 @@
| `move_file` | 移动/重命名文件 |
| `copy_file` | 复制文件 |
| `edit_file` | 编辑文件(支持正则替换) |
| `get_file_info` | 获取文件元信息 |
| `tree` | 目录树结构(默认5层深度) |
| `download_file` | 下载文件 |
| `diff_files` | 文件差异对比 |
| `replace_in_files` | 批量替换 |
| `read_multiple_files` | 批量读取(50文件/10KB |
| `compress` | 压缩文件/目录 |
@@ -150,16 +147,11 @@
</details>
<details>
<summary><strong>🕐 系统工具(6 个)</strong></summary>
<summary><strong>🕐 系统工具(1 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `datetime` | 获取系统精确时间(ISO/Unix/日期/时间/完整) |
| `calculator` | 安全数学计算(+ - * / ** % (),递归下降解析器) |
| `random` | 随机生成(整数/浮点/抽取/随机字符串) |
| `uuid` | 生成 UUID v4 唯一标识符 |
| `json_format` | JSON 格式化 + 语法验证 + 键排序 |
| `hash` | 哈希计算(MD5/SHA1/SHA256/SHA384/SHA512 |
</details>
@@ -185,7 +177,7 @@
Ollama API (流式响应,num_ctx 用户可配 128K/256K/512K/1M)
Tool Registry (40 内置 + MCP 动态 + Plan Mode plan_track)
Tool Registry (32 内置 + MCP 动态 + Plan Mode plan_track)
Hook 系统 (pre/post tool/iteration/completion) → 观察结果 → 反思 → 循环 / 最终回答
```
@@ -253,7 +245,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
产出:`release/Metona Ollama Setup v0.16.1.exe`
产出:`release/Metona Ollama Setup v0.16.12.exe`
## 🛠️ 常用命令
@@ -286,7 +278,7 @@ npm run dist # 构建 Windows 安装包
| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine, up to 85 iterations (configurable), smart retry (permanent errors return immediately, transient errors use exponential backoff), tool dedup, path-aware dependency serialization, watchdog + tiered timeout protection |
| 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (bilingual CN/EN rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination/injection → block, quality/efficiency → advisory) |
| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, **supports resume** (interrupted plans restore on next session) |
| 🔧 | **40 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking |
| 🔧 | **32 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), stored in workspace MEMORY.md file, path-protected — only the memory tool can access it, pre-write security scan, 500 capacity, auto-extract on conversation end, strict format validation |
| 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules, built-in fallback) + USER.md (user profile, workspace only, skipped if absent) |
| 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection |
@@ -308,7 +300,7 @@ npm run dist # 构建 Windows 安装包
## 🔧 Tool List
<details>
<summary><strong>📁 File System (16)</strong></summary>
<summary><strong>📁 File System (13)</strong></summary>
| Tool | Function |
|------|------|
@@ -321,11 +313,8 @@ npm run dist # 构建 Windows 安装包
| `move_file` | Move/rename file |
| `copy_file` | Copy file |
| `edit_file` | Edit file (regex support) |
| `get_file_info` | Get file metadata |
| `tree` | Directory tree (default 5 levels) |
| `download_file` | Download file |
| `diff_files` | File diff comparison |
| `replace_in_files` | Batch replace |
| `read_multiple_files` | Batch read (50 files/10KB) |
| `compress` | Compress file/directory |
@@ -398,16 +387,11 @@ npm run dist # 构建 Windows 安装包
</details>
<details>
<summary><strong>🕐 System Tools (6)</strong></summary>
<summary><strong>🕐 System Tools (1)</strong></summary>
| Tool | Function |
|------|------|
| `datetime` | Get precise system time (ISO/Unix/date/time/full) |
| `calculator` | Safe math evaluation (+ - * / ** % (), recursive descent parser) |
| `random` | Random generation (int/float/pick/string) |
| `uuid` | Generate UUID v4 unique identifier |
| `json_format` | JSON format + validate + sort keys |
| `hash` | Cryptographic hash (MD5/SHA1/SHA256/SHA384/SHA512) |
</details>
@@ -433,7 +417,7 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md (
Ollama API (Streaming Response, num_ctx user-configurable 128K/256K/512K/1M)
Tool Registry (40 Built-in + MCP Dynamic + Plan Mode plan_track)
Tool Registry (32 Built-in + MCP Dynamic + Plan Mode plan_track)
Hook System (pre/post tool/iteration/completion) → Observation → Reflection → Loop / Final Answer
```
@@ -501,7 +485,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
Output: `release/Metona Ollama Setup v0.16.1.exe`
Output: `release/Metona Ollama Setup v0.16.12.exe`
## 🛠️ Common Commands
+16 -18
View File
@@ -10,11 +10,11 @@ Metona Ollama Desktop 是基于 TypeScript + Electron 的 Windows 本地 AI 桌
核心架构:
- **ReAct Agent Loop** — 8 状态机驱动的智能体循环(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置)
- **42 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(4)、会话/子代理(3)、系统(6
- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate6 项检查)+ Agent Metrics + 渐进式披露
- **32 个内置工具** — 文件系统(13)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(1)、会话/子代理(3)、系统(1)、Plan Mode1
- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate5 项检查)+ Agent Metrics + 渐进式披露
- **MCP 协议扩展** — JSON-RPC 2.0 over stdio,动态工具发现
- **Plan Mode** — 开关切换,先规划后执行,步骤级进度追踪
- **SQLite 存储** — sql.js WASM6 张表,WAL 模式 + FTS5 全文搜索
- **SQLite 存储** — sql.js WASM5 张表,WAL 模式
---
@@ -47,12 +47,12 @@ src/
│ ├── mcp-manager.ts # MCP JSON-RPC 2.0 协议管理
│ ├── tool-security.ts # 路径/命令安全检查(黑名单 + 豁免机制)
│ ├── tool-handlers.ts # 工具处理器 re-export 聚合
│ ├── tool-handlers-fs.ts # 16 个文件系统工具实现
│ ├── tool-handlers-fs.ts # 13 个文件系统工具实现
│ ├── tool-handlers-system.ts # 系统/网络工具 + 联网搜索(双模式)+ 自动抓取
│ ├── tool-handlers-git.ts # Git 全操作
│ ├── tool-handlers-shared.ts # 共享类型和辅助函数
│ └── db/
│ ├── sqlite.ts # SQLite 数据库层(6 张表 + FTS5
│ ├── sqlite.ts # SQLite 数据库层(5 张表)
│ └── sql.js.d.ts # sql.js 类型声明
├── renderer/ # 渲染进程
@@ -78,24 +78,23 @@ src/
│ │ ├── toast.ts # Toast 通知
│ │ ├── token-dashboard.ts # Token 消耗仪表盘(全局 + 会话统计)
│ │ ├── tool-confirm-modal.ts # 工具执行确认对话框
│ │ ├── tools-modal.ts # 工具列表面板(42 个工具卡片)
│ │ ├── tools-modal.ts # 工具列表面板(32 个工具卡片)
│ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览)
│ ├── services/ # 15 个服务模块
│ ├── services/ # 14 个服务模块
│ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机)
│ │ ├── tool-registry.ts # 工具注册与调度中心(42 内置 + MCP 动态 + Plan Mode
│ │ ├── memory-manager.ts # 记忆管理核心(FTS5 + 向量语义搜索
│ │ ├── vector-memory.ts # 向量记忆索引(IVF)
│ │ ├── vector-store.ts # 向量存储 + IVF 索引引擎
│ │ ├── tool-registry.ts # 工具注册与调度中心(32 内置 + MCP 动态 + Plan Mode
│ │ ├── memory-service.ts # 记忆管理MEMORY.md 读写 + 格式校验 + 自动提取
│ │ ├── context-manager.ts # 上下文窗口管理(滑动窗口 + Token 校准 + LLM 压缩)
│ │ ├── sub-agent.ts # 子代理委派(独立上下文 + 超时保护)
│ │ ├── mcp-client.ts # MCP 渲染端客户端
│ │ ├── log-service.ts # 结构化日志(9 级分类)
│ │ ├── crypto.ts # AES-256-GCM 加密
│ │ ├── hooks.ts # 4 阶段 Hook 系统(pre_tool/post_tool/post_iteration/pre_completion
│ │ ├── completion-gate.ts # 完成门控(6 项检查,阻断/咨询两级)
│ │ ├── completion-gate.ts # 完成门控(5 项检查,阻断/咨询两级)
│ │ ├── agent-metrics.ts # Agent 度量采集 + 错误模式识别 + 改进建议
│ │ ├── agent-safety.ts # Agent 安全防护(工具阴影检测 + 路径校验)
│ │ ├── context-indexer.ts # 渐进式披露(索引层→接口层→实现层)
│ │ └── verification.ts # 验证系统(DiffAnalyzer + FileChangeAudit
│ │ └── infra-service.ts # 基础设施(全局错误处理 + 配置校验
│ ├── db/
│ │ └── chat-db.ts # 渲染端数据库接口 + IndexedDB→SQLite 迁移
│ ├── state/
@@ -179,11 +178,11 @@ logDebug('调试信息', '可选详情');
### 5.1 五大子系统
```
① Agent 系统 → agent-engine.ts + tool-registry.ts42 内置工具 + MCP 动态)
② 记忆系统 → memory-manager.ts + vector-memory.ts + vector-store.ts
① Agent 系统 → agent-engine.ts + tool-registry.ts32 内置工具 + MCP 动态)
② 记忆系统 → memory-service.tsMEMORY.md 文件存储 + 格式校验 + 自动提取)
③ 上下文系统 → context-manager.ts + context-indexer.ts(渐进式披露)
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
⑤ 数据层 → db/sqlite.tsSQLite, 6 张表, FTS5+ chat-db.ts(渲染端接口)
⑤ 数据层 → db/sqlite.tsSQLite, 5 张表)+ chat-db.ts(渲染端接口)
```
### 5.2 ReAct Agent Loop8 状态机)
@@ -214,14 +213,13 @@ INIT → THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (CO
### 5.3 SQLite 数据库
6 张表,WAL 模式 + NORMAL 同步:
5 张表,WAL 模式 + NORMAL 同步:
| 表 | 用途 | 关键特性 |
|---|---|---|
| `sessions` | 会话 | parent_id 父子关系 |
| `messages` | 消息 | 外键级联删除,thinking/tool_calls/attachments/eval_count |
| `tool_calls` | 工具调用记录 | 按会话+工具名索引 |
| `memories` | Agent 记忆 | FTS5 全文搜索,向量嵌入,容量 500 上限 |
| `settings` | 设置 | JSON 序列化 |
| `traces` | ReAct 执行轨迹 | Agent 可观测性 |
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "0.16.1",
"version": "0.16.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "0.16.1",
"version": "0.16.12",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.16.1",
"version": "0.16.12",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+26 -6
View File
@@ -3,7 +3,7 @@
* BrowserWindow JS
*/
import { BrowserWindow } from 'electron';
import { BrowserWindow, session } from 'electron';
import { mainWindow } from './main.js';
function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, detail?: string): void {
@@ -12,6 +12,12 @@ function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string,
let agentBrowser: BrowserWindow | null = null;
/** Agent 浏览器使用的独立 session partition(内存隔离,重启清空) */
const AGENT_PARTITION = 'agent';
/** 伪装的 User-Agent — 移除 Electron 字样,使用常见 Chrome UA */
const AGENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
/** 浏览器是否已打开且可用 */
export function isBrowserOpen(): boolean {
return agentBrowser !== null && !agentBrowser.isDestroyed();
@@ -41,10 +47,14 @@ function getAgentBrowser(): BrowserWindow {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: false,
webSecurity: true, // 开启同源策略(防跨域攻击)
partition: AGENT_PARTITION, // 独立 session:与主窗口隔离 cookie/storage/缓存
}
});
// 设置伪装 User-Agent
agentBrowser.webContents.setUserAgent(AGENT_UA);
agentBrowser.on('closed', () => { agentBrowser = null; });
return agentBrowser;
}
@@ -385,11 +395,21 @@ export async function browserWait(params: { selector?: string; time_ms?: number
}
}
/** 关闭浏览器 */
export function browserClose(): void {
/** 关闭浏览器并清理 session 资源 */
export async function browserClose(): Promise<void> {
if (agentBrowser && !agentBrowser.isDestroyed()) {
agentBrowser.close();
sendLog('info', '🌐 browser 已关闭');
}
agentBrowser = null;
// 清理 Agent session 的缓存和存储数据(内存 partition 关闭后自动清空,这里做显式清理确保彻底)
try {
const agentSession = session.fromPartition(AGENT_PARTITION);
await Promise.all([
agentSession.clearCache(),
agentSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] }),
]);
} catch { /* 清理失败不阻塞 */ }
sendLog('info', '🌐 browser 已关闭,session 已清理');
} else {
agentBrowser = null;
}
}
+16 -14
View File
@@ -91,7 +91,7 @@ function persist(): void {
fs.writeFileSync(tmpPath, buf);
fs.renameSync(tmpPath, dbPath);
} catch (err) {
// 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获)
// 主进程无 log-service,使用 console.error 输出到 stderr(会被 Electron 日志捕获)
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
}
}
@@ -327,6 +327,17 @@ export function saveSetting(key: string, value: unknown): void {
persist();
}
/** P1-P2 修复:批量保存设置,只触发一次 persist,避免每个 setting 都全库写盘 */
export function saveSettingsBatch(entries: Array<{ key: string; value: unknown }>): void {
const d = getDb();
for (const { key, value } of entries) {
runExec(d, 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
[key, JSON.stringify(value), Date.now()]
);
}
persist(); // 只写盘一次
}
export function getSetting<T = unknown>(key: string, defaultValue: T | null = null): T {
const row = queryOne(getDb(), 'SELECT value FROM settings WHERE key = ?', [key]) as { value: string } | null;
if (!row) return defaultValue as T;
@@ -338,19 +349,10 @@ export function getSetting<T = unknown>(key: string, defaultValue: T | null = nu
}
// ─── Tool Calls CRUD ───
export function saveToolCall(tc: ToolCallRow): string {
runExec(getDb(), `INSERT OR REPLACE INTO tool_calls (id, message_id, session_id, tool_name, arguments, result, status, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[tc.id, tc.message_id, tc.session_id, tc.tool_name, tc.arguments, tc.result, tc.status, tc.duration_ms, tc.created_at]
);
persist();
return tc.id;
}
export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
return queryAll(getDb(), 'SELECT * FROM tool_calls WHERE session_id = ? ORDER BY created_at ASC', [sessionId]) as unknown as ToolCallRow[];
}
// P1-P3 修复:saveToolCall 和 getToolCallsBySession 是死代码(渲染进程从未调用)。
// tool_calls 表保留(已有数据库可能包含此表,删除表定义会导致升级时报错),
// 但删除未使用的函数和 IPC handler。
// 工具调用记录实际通过 messages.tool_calls JSON 列存储。
// ─── Traces CRUD ───
+19 -52
View File
@@ -11,8 +11,7 @@ import { showNotification } from './utils.js';
import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession,
saveSetting, getSetting,
saveToolCall, getToolCallsBySession,
saveSetting, getSetting, saveSettingsBatch,
saveTrace, getTracesBySession,
exportAllSessions, importSessions,
getAllSessionsTokenStats
@@ -37,20 +36,12 @@ import {
handleWebFetch,
handleWebSearch,
handleEditFile,
handleGetFileInfo,
handleTree,
handleDownloadFile,
handleDiffFiles,
handleReplaceInFiles,
handleReadMultipleFiles,
handleGit,
handleCompress,
handleDateTime,
handleCalculator,
handleRandom,
handleUUID,
handleJsonFormat,
handleHash
handleCalculator
} from './tool-handlers.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';
@@ -75,26 +66,18 @@ function summarizeResult(toolName: string, result: Record<string, unknown>): str
case 'list_directory': return `${result.path} (${result.total}${result.truncated ? ', 已截断' : ''})`;
case 'search_files': return `"${result.query}" → ${result.total_matches} 匹配 / ${result.total_files} 文件`;
case 'create_directory': return String(result.path);
case 'delete_file': return String(result.path);
case 'delete_file': return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths} 个路径` : String(result.path);
case 'move_file': return `${result.source}${result.destination}`;
case 'copy_file': return `${result.source}${result.destination}`;
case 'web_fetch': return `${result.status} | ${result.length} chars`;
case 'web_search': return `[${(result as any)._mode === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`;
case 'web_search': return `[${String(result._mode) === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`;
case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`;
case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`;
case 'tree': return `${result.path} (${result.fileCount} 文件 / ${result.dirCount} 目录)`;
case 'download_file': return `${result.url}${result.destination} (${result.size}B)`;
case 'diff_files': return result.hasChanges ? `${result.file1}${result.file2} (有差异)` : '无差异';
case 'replace_in_files': return `${result.filesChanged} 文件 / ${result.totalReplacements} 处替换`;
case 'read_multiple_files': return `${result.total} 个文件`;
case 'git': return `${result.action}`;
case 'compress': return `${result.action}${result.archive || result.destination}`;
case 'datetime': return `${(result as any).date || (result as any).iso}`;
case 'calculator': return `${(result as any).expression} = ${(result as any).result}`;
case 'random': return `${(result as any).type === 'pick' ? '🎲 ' + String((result as any).result) : String((result as any).result)}`;
case 'uuid': return `${(result as any).result}`;
case 'json_format': return `${(result as any).keys || 0} keys, ${(result as any).formatted_size}B`;
case 'hash': return `${(result as any).algorithm}: ${(result as any).hash?.slice(0, 16)}...`;
case 'calculator': return `${result.expression} = ${result.result}`;
default: return '完成';
}
}
@@ -206,27 +189,19 @@ export async function setupIPC(): Promise<void> {
case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; limit?: number; offset?: number }); break;
case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; use_regex?: boolean; max_results?: number; file_extensions?: string[] }); break;
case 'create_directory': result = await handleCreateDir(args as { path: string }); break;
case 'delete_file': result = await handleDeleteFile(args as { path: string; recursive?: boolean }); break;
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); // run_command 自身管理日志
case 'delete_file': result = await handleDeleteFile(args as { path?: string; paths?: string[]; recursive?: boolean }); break;
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string }); // run_command 自身管理日志
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break;
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean; fetch_top?: number }); break;
case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean; use_regex?: boolean }); break;
case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break;
case 'tree': result = await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); break;
case 'download_file': result = await handleDownloadFile(args as { url: string; destination: string }); break;
case 'diff_files': result = await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number }); break;
case 'replace_in_files': result = await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string }); break;
case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break;
case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; tag_name?: string; stash_sub?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break;
case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break;
case 'datetime': result = handleDateTime(args as { format?: string; timezone?: string }); break;
case 'calculator': result = handleCalculator(args as { expression: string }); break;
case 'random': result = handleRandom(args as { type?: string; min?: number; max?: number; count?: number; items?: string[]; length?: number }); break;
case 'uuid': result = handleUUID(args as { count?: number }); break;
case 'json_format': result = handleJsonFormat(args as { json: string; indent?: number; sort_keys?: boolean }); break;
case 'hash': result = handleHash(args as { text: string; algorithm?: string }); break;
// v5.1 Browser 控制(增强版)
case 'browser_open': result = await browserOpen(args.url as string, args.wait_selector as string | undefined); break;
case 'browser_screenshot': result = await browserScreenshot({ full_page: args.full_page as boolean, selector: args.selector as string }); break;
@@ -236,7 +211,7 @@ export async function setupIPC(): Promise<void> {
case 'browser_type': result = await browserType(args.selector as string, args.text as string, args.clear !== false, args.submit as boolean || false); break;
case 'browser_scroll': result = await browserScroll({ direction: args.direction as string, selector: args.selector as string }); break;
case 'browser_wait': result = await browserWait({ selector: args.selector as string, time_ms: args.time_ms as number }); break;
case 'browser_close': browserClose(); result = { success: true }; break;
case 'browser_close': await browserClose(); result = { success: true }; break;
default: return { success: false, error: `未知工具: ${toolName}` };
}
// 结果日志
@@ -391,20 +366,17 @@ export async function setupIPC(): Promise<void> {
try { saveSetting(key, value); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
// P1-P2 修复:批量保存设置,只触发一次 persist
ipcMain.handle('db:saveSettingsBatch', (_, entries: Array<{ key: string; value: unknown }>) => {
try { saveSettingsBatch(entries); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getSetting', (_, key: string, defaultValue?: unknown) => {
try { return getSetting(key, defaultValue ?? null); }
catch { return defaultValue ?? null; }
});
// Tool Calls
ipcMain.handle('db:saveToolCall', (_, tc) => {
try { return { success: true, id: saveToolCall(tc) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getToolCalls', (_, sessionId) => {
try { return getToolCallsBySession(sessionId); }
catch { return []; }
});
// Tool Calls — P1-P3 修复:删除死代码(渲染进程从未调用,工具记录通过 messages.tool_calls 存储)
// Traces
ipcMain.handle('db:saveTrace', (_, trace) => {
@@ -588,14 +560,12 @@ function validateMemoryContent(content: string): boolean {
const firstLine = lines[0]?.trim();
if (firstLine !== '# METONA MEMORY') return false;
// 检查是否有 ## 条目头,且格式正确
let entryCount = 0;
// 检查 ## 条目头格式是否正确
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('>')) continue;
const match = trimmed.match(/^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i);
if (match) {
entryCount++;
const importance = parseInt(match[3], 10);
if (importance < 1 || importance > 10) return false;
const tagsStr = match[4]?.trim();
@@ -635,8 +605,7 @@ function parseVideoInfo(stderr: string): { duration: number; width: number; heig
return { duration, width, height };
}
function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> {
return new Promise(async (resolve) => {
async function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> {
const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`);
try {
await fs.promises.mkdir(tmpDir, { recursive: true });
@@ -688,8 +657,7 @@ function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: numbe
const jpegs = files.filter(f => f.endsWith('.jpg')).sort();
if (jpegs.length === 0) {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
resolve({ success: false, error: '没有提取到视频帧' });
return;
return { success: false, error: '没有提取到视频帧' };
}
// 如果没解析到时长,从帧数估算(fps=1)
@@ -712,12 +680,11 @@ function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: numbe
await fs.promises.rm(tmpDir, { recursive: true, force: true });
resolve({ success: true, frames, videoInfo });
return { success: true, frames, videoInfo };
} catch (err) {
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
sendLog('error', '视频帧提取失败', (err as Error).message);
resolve({ success: false, error: (err as Error).message });
return { success: false, error: (err as Error).message };
}
});
}
+1 -1
View File
@@ -197,7 +197,7 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => {
isQuitting = true;
// 清理浏览器
browserClose();
browserClose().catch(() => {});
// 清理 MCP 服务器
stopAllServers();
// 清理所有工作空间进程
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.16.1',
message: 'Metona Ollama Desktop v0.16.12',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+69 -207
View File
@@ -5,7 +5,6 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
@@ -243,21 +242,24 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
if (!allowed.ok) return { success: false, error: allowed.reason };
const startOffset = params.offset || 0;
const MAX_ENTRIES = 2000;
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
let truncated = false;
let skipped = 0;
async function scanDir(dir: string, depth: number): Promise<void> {
if (truncated) return;
if (params.recursive && params.max_depth && depth > params.max_depth) return;
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
if (truncated) return;
if (!params.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
const fullPath = path.join(dir, item.name);
// P0 修复:递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树)
// 递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树)
if (params.recursive && item.isDirectory()) {
await scanDir(fullPath, depth + 1);
continue;
@@ -266,6 +268,9 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
// 分页:跳过前 offset 个条目(仅对非目录条目计数)
if (skipped < startOffset) { skipped++; continue; }
// 截断保护:超过 2000 条标记 truncated
if (entries.length >= MAX_ENTRIES) { truncated = true; return; }
const stat = await fs.stat(fullPath);
entries.push({
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
@@ -413,9 +418,67 @@ export async function handleCreateDir(params: { path: string }): Promise<ToolRes
}
}
export async function handleDeleteFile(params: { path: string; recursive?: boolean }): Promise<ToolResult> {
export async function handleDeleteFile(params: { path?: string; paths?: string[]; recursive?: boolean }): Promise<ToolResult> {
// 收集所有要删除的路径
const allPaths: string[] = [];
if (params.paths && Array.isArray(params.paths) && params.paths.length > 0) {
allPaths.push(...params.paths);
}
if (params.path) {
allPaths.push(params.path);
}
if (allPaths.length === 0) {
return { success: false, error: '未提供 path 或 paths 参数' };
}
// 单个路径 — 保持原逻辑(返回单个结果)
if (allPaths.length === 1) {
return handleDeleteSingle(allPaths[0], params.recursive);
}
// 批量删除
const results: Array<{ path: string; success: boolean; error?: string; type?: string; deletedSize?: number; filesDeleted?: number }> = [];
let totalDeletedSize = 0;
let totalFilesDeleted = 0;
let successCount = 0;
let failCount = 0;
for (const p of allPaths) {
const result = await handleDeleteSingle(p, params.recursive);
if (result.success) {
successCount++;
totalDeletedSize += (result as Record<string, unknown>).deletedSize as number || 0;
totalFilesDeleted += (result as Record<string, unknown>).filesDeleted as number || 1;
results.push({
path: (result as Record<string, unknown>).path as string,
success: true,
type: (result as Record<string, unknown>).type as string,
deletedSize: (result as Record<string, unknown>).deletedSize as number,
...((result as Record<string, unknown>).filesDeleted !== undefined && { filesDeleted: (result as Record<string, unknown>).filesDeleted as number }),
});
} else {
failCount++;
results.push({ path: p, success: false, error: (result as Record<string, unknown>).error as string });
}
}
return {
success: failCount === 0,
batch: true,
totalPaths: allPaths.length,
successCount,
failCount,
results,
deletedSize: totalDeletedSize,
...(totalFilesDeleted > 0 && { filesDeleted: totalFilesDeleted }),
...(failCount > 0 && { error: `${failCount} 个路径删除失败` }),
};
}
/** 删除单个文件或目录 */
async function handleDeleteSingle(rawPath: string, recursive?: boolean): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const filePath = resolvePath(rawPath);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
@@ -442,7 +505,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
await countDir(filePath);
deletedCount = fileCount;
if (params.recursive) {
if (recursive) {
await fs.rm(filePath, { recursive: true, force: true });
} else {
await fs.rmdir(filePath);
@@ -453,6 +516,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
return {
success: true, path: filePath, deleted: true,
message: `已删除${isDir ? '目录' : '文件'}${filePath}`,
type: isDir ? 'directory' : 'file',
...(isDir && { filesDeleted: deletedCount }),
deletedSize,
@@ -515,29 +579,6 @@ export async function handleEditFile(params: { path: string; old_text: string; n
}
}
export async function handleGetFileInfo(params: { path: string }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const stat = await fs.stat(filePath);
return {
success: true,
path: filePath,
name: path.basename(filePath),
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
size: stat.size,
created: stat.birthtime.toISOString(),
modified: stat.mtime.toISOString(),
accessed: stat.atime.toISOString(),
permissions: (stat.mode & 0o777).toString(8)
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleTree(params: { path: string; max_depth?: number; include_hidden?: boolean }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
@@ -594,185 +635,6 @@ export async function handleTree(params: { path: string; max_depth?: number; inc
}
}
/** 运行 shell 命令并返回 stdout/stderr */
function runShell(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 }));
proc.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 }));
});
}
export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise<ToolResult> {
try {
const path1 = resolvePath(params.file1);
const path2 = resolvePath(params.file2);
const check1 = checkPathAllowed(path1, 'read');
if (!check1.ok) return { success: false, error: check1.reason };
const check2 = checkPathAllowed(path2, 'read');
if (!check2.ok) return { success: false, error: check2.reason };
const contextSize = params.context_lines || 3;
let diffOutput = '';
let usedCommand = '';
// 1) 优先使用系统 diff 命令
const diffResult = await runShell('diff', ['-u', `-U${contextSize}`, path1, path2]);
if (diffResult.code <= 1) { // diff exits 0=identical, 1=different
diffOutput = diffResult.stdout;
usedCommand = 'diff -u';
}
// 2) diff 不可用,尝试 git diff
if (!usedCommand) {
const gitResult = await runShell('git', ['diff', `--unified=${contextSize}`, '--no-index', path1, path2]);
if (gitResult.code <= 1) {
diffOutput = gitResult.stdout;
usedCommand = 'git diff';
}
}
// 3) 回退到内置简易 diff
if (!usedCommand) {
diffOutput = builtinDiff(path1, path2, contextSize);
usedCommand = 'builtin';
}
const hasChanges = diffOutput.trim().length > 0;
return {
success: true,
file1: path1,
file2: path2,
diff: diffOutput,
hasChanges,
method: usedCommand
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/** 内置简易 diff(回退方案) */
function builtinDiff(path1: string, path2: string, contextSize: number): string {
const fsSync = require('fs');
const content1 = fsSync.readFileSync(path1, 'utf-8');
const content2 = fsSync.readFileSync(path2, 'utf-8');
const lines1 = content1.split('\n');
const lines2 = content2.split('\n');
const diffLines: string[] = [];
let i = 0, j = 0;
while (i < lines1.length || j < lines2.length) {
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) {
const ctxStart = Math.max(0, i - contextSize);
for (let k = ctxStart; k < i; k++) diffLines.push(` ${lines1[k]}`);
}
diffLines.push(` ${lines1[i]}`);
i++; j++;
} else {
let found = false;
for (let look = 1; look <= 10 && i + look < lines1.length; look++) {
if (lines1[i + look] === lines2[j]) {
for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`);
i += look; found = true; break;
}
}
if (!found) {
for (let look = 1; look <= 10 && j + look < lines2.length; look++) {
if (lines1[i] === lines2[j + look]) {
for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`);
j += look; found = true; break;
}
}
}
if (!found) {
if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; }
if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; }
}
}
}
return diffLines.join('\n');
}
/** 将 glob 模式转换为正则表达式(支持 **, *, ?) */
function globToRegex(glob: string): RegExp {
// 转义正则特殊字符
let pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// ** → 匹配任意路径(含 /)
pattern = pattern.replace(/\*\*/g, '\u0000'); // 临时占位
// * → 匹配除 / 外的任意字符
pattern = pattern.replace(/\*/g, '[^/]*');
// ? → 匹配除 / 外的单个字符
pattern = pattern.replace(/\?/g, '[^/]');
// 恢复 ** → .*
pattern = pattern.replace(/\u0000/g, '.*');
return new RegExp(`^${pattern}$`);
}
export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
// 正确的 glob → regex 转换:支持 ** (跨目录通配), * (单层通配), ? (单字符)
const globRegex = globToRegex(params.glob);
const results: Array<{ file: string; replacements: number }> = [];
let totalReplacements = 0;
const maxFiles = 200;
let fileCount = 0;
async function scanDir(dir: string): Promise<void> {
if (fileCount >= maxFiles) return;
let items;
try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const item of items) {
if (fileCount >= maxFiles) return;
if (item.name.startsWith('.')) continue;
const fullPath = path.join(dir, item.name);
const relPath = path.relative(rootPath, fullPath);
if (item.isDirectory()) {
await scanDir(fullPath);
} else if (globRegex.test(relPath) || globRegex.test(item.name)) {
fileCount++;
try {
const content = await fs.readFile(fullPath, 'utf-8');
if (content.includes(params.old_text)) {
const parts = content.split(params.old_text);
const count = parts.length - 1;
const newContent = parts.join(params.new_text);
await fs.writeFile(fullPath, newContent, 'utf-8');
results.push({ file: path.relative(rootPath, fullPath), replacements: count });
totalReplacements += count;
}
} catch { /* skip unreadable */ }
}
}
}
await scanDir(rootPath);
return {
success: true,
pattern: params.glob,
filesChanged: results.length,
totalReplacements,
results,
truncated: fileCount >= maxFiles
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try {
const maxChars = params.max_chars_per_file || 0; // 0 = 不截断
+5 -209
View File
@@ -5,17 +5,17 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
import * as crypto from 'crypto';
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
import { mainWindow } from './main.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
import { getSetting } from './db/sqlite.js';
import { browserOpen, browserExtract, browserClose } from './browser.js';
/** 当前工具命令进程(用于用户手动终止) */
let _toolProc: ReturnType<typeof spawn> | null = null;
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
export async function handleRunCommand(params: { command: string; cwd?: string }): Promise<ToolResult> {
try {
const cmdCheck = checkCommandAllowed(params.command);
if (!cmdCheck.ok) {
@@ -375,8 +375,6 @@ function htmlToMarkdown(html: string): string {
return text.trim();
}
import { browserOpen, browserExtract, browserClose } from './browser.js';
// ──────────────────────────────────────────────────
// web_fetch 重试配置
// ──────────────────────────────────────────────────
@@ -407,7 +405,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
if (!openResult.success) return null;
await new Promise(r => setTimeout(r, 2500)); // 等待 JS 渲染
const extractResult = await browserExtract();
browserClose();
await browserClose();
if (extractResult.success && extractResult.text && extractResult.text.length > 0) {
let text = extractResult.text;
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
@@ -419,7 +417,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
}
return null;
} catch {
try { browserClose(); } catch { /* */ }
try { await browserClose(); } catch { /* */ }
return null;
}
}
@@ -1554,85 +1552,6 @@ export async function handleCompress(params: { action: string; path: string; des
}
}
// ── datetime: 获取系统精确时间 ──
export function handleDateTime(params: { format?: string; timezone?: string }): ToolResult {
try {
const now = new Date();
const tz = params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const fmt = params.format || 'full';
const iso = now.toISOString();
const unixSec = Math.floor(now.getTime() / 1000);
const unixMs = now.getTime();
// 中文日期(无前导零月份)
const y = now.getFullYear();
const m = now.getMonth() + 1;
const d = now.getDate();
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
const weekday = `星期${weekdays[now.getDay()]}`;
const dateStr = `${y}${m}${d}${weekday}`;
// 24小时制时间
const h = now.getHours();
const min = now.getMinutes();
const sec = now.getSeconds();
const time24 = `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
// 时段
const period = h < 6 ? '凌晨' : h < 12 ? '上午' : h < 14 ? '中午' : h < 18 ? '下午' : '晚上';
// 人性化时间(12小时制 + 时段)
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
const timeFriendly = `${period} ${h12}:${String(min).padStart(2, '0')}`;
// 完整人性化字符串
const friendly = `${y}${m}${d}${weekday} ${timeFriendly}`;
let result: Record<string, unknown>;
switch (fmt) {
case 'iso':
result = { iso, timezone: tz };
break;
case 'unix':
result = { unix_seconds: unixSec, unix_milliseconds: unixMs, timezone: tz };
break;
case 'date':
result = { date: dateStr, weekday, timezone: tz };
break;
case 'time':
result = { time_24h: time24, period, time_friendly: timeFriendly, timezone: tz };
break;
default: // full
result = {
iso,
unix_seconds: unixSec,
unix_milliseconds: unixMs,
date: dateStr,
weekday,
time_24h: time24,
period,
time_friendly: timeFriendly,
friendly,
timezone: tz,
year: y,
month: m,
day: d,
hour: h,
minute: min,
second: sec,
millisecond: now.getMilliseconds(),
day_of_week: now.getDay(),
};
}
return { success: true, ...result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── calculator: 安全数学计算器(纯 JS 递归下降解析,无 eval) ──
export function handleCalculator(params: { expression: string }): ToolResult {
try {
@@ -1649,7 +1568,7 @@ export function handleCalculator(params: { expression: string }): ToolResult {
function safeCalc(expr: string): number {
expr = expr.replace(/\s+/g, '');
if (!/^[\d+\-*/().%^]+$/.test(expr)) {
if (!/^[\d+\-*/().%]+$/.test(expr)) {
throw new Error('表达式包含非法字符');
}
let pos = 0;
@@ -1711,126 +1630,3 @@ function safeCalc(expr: string): number {
if (!isFinite(result)) throw new Error('计算结果为无穷大');
return result;
}
// ── random: 随机数生成 ──
export function handleRandom(params: { type?: string; min?: number; max?: number; count?: number; items?: string[]; length?: number }): ToolResult {
try {
const type = params.type || 'int';
switch (type) {
case 'int': {
const min = params.min ?? 0;
const max = params.max ?? 100;
if (min > max) return { success: false, error: 'min 不能大于 max' };
const count = Math.min(params.count ?? 1, 100);
if (count === 1) {
return { success: true, type: 'int', result: Math.floor(Math.random() * (max - min + 1)) + min, range: [min, max] };
}
const results: number[] = [];
for (let i = 0; i < count; i++) results.push(Math.floor(Math.random() * (max - min + 1)) + min);
return { success: true, type: 'int', results, count, range: [min, max] };
}
case 'float': {
const min = params.min ?? 0;
const max = params.max ?? 1;
if (min > max) return { success: false, error: 'min 不能大于 max' };
const count = Math.min(params.count ?? 1, 100);
if (count === 1) {
return { success: true, type: 'float', result: Number((Math.random() * (max - min) + min).toFixed(6)), range: [min, max] };
}
const results: number[] = [];
for (let i = 0; i < count; i++) results.push(Number((Math.random() * (max - min) + min).toFixed(6)));
return { success: true, type: 'float', results, count, range: [min, max] };
}
case 'pick': {
const items = params.items;
if (!items || items.length === 0) return { success: false, error: 'pick 类型需要提供 items 数组' };
const count = Math.min(params.count ?? 1, items.length);
// Fisher-Yates 部分洗牌
const pool = [...items];
const picked: string[] = [];
for (let i = 0; i < count; i++) {
const idx = Math.floor(Math.random() * pool.length);
picked.push(pool[idx]);
pool.splice(idx, 1);
}
return { success: true, type: 'pick', result: count === 1 ? picked[0] : picked, from: items.length, count };
}
case 'string': {
const length = Math.min(params.length ?? 8, 256);
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// 使用 crypto 增强随机性
const buf = crypto.randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(buf[i] % chars.length);
}
return { success: true, type: 'string', result, length, charset: 'alphanumeric' };
}
default:
return { success: false, error: `未知类型: ${type}。支持: int / float / pick / string` };
}
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── uuid: 生成 UUID v4 ──
export function handleUUID(params: { count?: number }): ToolResult {
try {
const crypto = require('crypto');
const count = Math.min(params.count ?? 1, 20);
if (count === 1) {
return { success: true, result: crypto.randomUUID() };
}
const results: string[] = [];
for (let i = 0; i < count; i++) results.push(crypto.randomUUID());
return { success: true, results, count };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// ── json_format: JSON 格式化 + 验证 ──
export function handleJsonFormat(params: { json: string; indent?: number; sort_keys?: boolean }): ToolResult {
try {
const parsed = JSON.parse(params.json);
const indent = params.indent ?? 2;
let formatted: string;
if (params.sort_keys) {
formatted = JSON.stringify(sortObjectKeys(parsed), null, indent);
} else {
formatted = JSON.stringify(parsed, null, indent);
}
const size = Buffer.byteLength(formatted, 'utf-8');
return { success: true, formatted, original_size: params.json.length, formatted_size: size, keys: Object.keys(parsed).length };
} catch (err) {
return { success: false, error: `JSON 解析失败: ${(err as Error).message}`, input_preview: params.json.slice(0, 200) };
}
function sortObjectKeys(obj: any): any {
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
if (obj !== null && typeof obj === 'object') {
const sorted: Record<string, any> = {};
for (const k of Object.keys(obj).sort()) sorted[k] = sortObjectKeys(obj[k]);
return sorted;
}
return obj;
}
}
// ── hash: 哈希计算 ──
export function handleHash(params: { text: string; algorithm?: string }): ToolResult {
try {
const crypto = require('crypto');
const algo = (params.algorithm || 'sha256').toLowerCase().replace('-', '');
const validAlgos = ['md5', 'sha1', 'sha256', 'sha384', 'sha512'];
if (!validAlgos.includes(algo)) {
return { success: false, error: `不支持的算法: ${algo}。支持: ${validAlgos.join(', ')}` };
}
const hash = crypto.createHash(algo).update(params.text, 'utf-8').digest('hex');
return { success: true, algorithm: algo, hash, input_length: params.text.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
+2 -10
View File
@@ -6,7 +6,7 @@
export { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
// 文件系统操作(15 个)
// 文件系统操作(11 个)
export {
handleReadFile,
handleWriteFile,
@@ -17,14 +17,11 @@ export {
handleMoveFile,
handleCopyFile,
handleEditFile,
handleGetFileInfo,
handleTree,
handleDiffFiles,
handleReplaceInFiles,
handleReadMultipleFiles,
} from './tool-handlers-fs.js';
// 系统与网络操作(8 个)
// 系统与网络操作(7 个)
export {
handleRunCommand,
killToolProcess,
@@ -32,12 +29,7 @@ export {
handleWebSearch,
handleDownloadFile,
handleCompress,
handleDateTime,
handleCalculator,
handleRandom,
handleUUID,
handleJsonFormat,
handleHash,
} from './tool-handlers-system.js';
// Git 操作(1 个)
+13 -40
View File
@@ -376,8 +376,6 @@ function patchIncompleteMarkdown(md: string): string {
// ── 流式渲染节流状态 ──
let _streamRenderPending = false;
let _streamRenderContent = '';
let _streamRenderThink: string | null = null;
let _streamRenderModel: string | undefined;
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
@@ -400,6 +398,9 @@ function _doStreamRender(): void {
_streamRenderPending = false;
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
// P1-R1 修复:rAF 回调执行时 currentPlaceholder 可能已被 appendAssistantPlaceholder 替换为新节点,
// 且 _streamRenderContent 已被重置为 '',此时不会误渲染。appendAssistantPlaceholder 中的
// _streamRenderContent = '' 重置确保了旧 rAF 回调拿到的是空内容,不会渲染到新 placeholder。
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
const contentLen = _streamRenderContent.length;
@@ -551,44 +552,6 @@ export function updateLastAssistantMessage(
scrollToBottom();
}
/** 实时追加工具调用卡片到当前 placeholder(直播聊天时使用) */
export function appendToolCallCardToPlaceholder(tc: ToolCallRecord): void {
if (!currentPlaceholder) return;
let container = currentPlaceholder.querySelector('.tool-calls-container');
if (!container) {
container = document.createElement('div');
container.className = 'tool-calls-container';
const msgBody = currentPlaceholder.querySelector('.msg-body');
if (msgBody) msgBody.appendChild(container);
}
const cardDiv = document.createElement('div');
cardDiv.innerHTML = renderToolCallCard(tc);
const card = cardDiv.firstElementChild as HTMLElement;
card.dataset.toolName = tc.name;
container.appendChild(card);
scrollToBottom();
}
/** 更新 placeholder 中已有的工具卡片状态(running → success/error */
export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void {
if (!currentPlaceholder) return;
const container = currentPlaceholder.querySelector('.tool-calls-container');
if (!container) return;
// 找到第一个同名且状态为 running 的卡片(CSS.escape 防特殊字符)
const escapedName = CSS.escape(tc.name);
const existing = container.querySelector(`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running`) as HTMLElement;
if (existing) {
const cardDiv = document.createElement('div');
cardDiv.innerHTML = renderToolCallCard(tc);
const newCard = cardDiv.firstElementChild as HTMLElement;
newCard.dataset.toolName = tc.name;
existing.replaceWith(newCard);
} else {
// 没找到 running 的卡片,直接追加
appendToolCallCardToPlaceholder(tc);
}
}
export function appendAssistantPlaceholder(): void {
const div = document.createElement('div');
div.className = 'message assistant loading';
@@ -606,6 +569,12 @@ export function appendAssistantPlaceholder(): void {
messagesContainerEl.appendChild(div);
currentPlaceholder = div;
// P0-R2 修复:新 placeholder 必须重置流式渲染状态,否则上一条的 _streamLastRenderedLen 会压制新消息渲染
_streamLastRenderedLen = 0;
_streamLastRenderedHash = 0;
_streamRenderContent = '';
_streamRenderPending = false;
// R34: 启动旋转提示
startLoadingHintRotation(div);
@@ -654,12 +623,16 @@ export function appendSystemMessage(text: string, tempClass?: string): void {
export function clearMessagesDOM(): void {
messagesContainerEl.innerHTML = '';
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:重置系统提示词标记,否则清空后首条消息不再显示系统卡片
_sysPromptRendered = false;
}
export function clearMessages(): void {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:与 clearMessagesDOM 保持一致
_sysPromptRendered = false;
}
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
+2 -2
View File
@@ -5,7 +5,7 @@
import { state, KEYS } from '../state/state.js';
import { formatSize } from '../utils/utils.js';
import { OllamaAPI } from '../api/ollama.js';
import { logConnection, logError, logDebug } from '../services/log-service.js';
import { logConnection, logError, logDebug, logInfo } from '../services/log-service.js';
let connStatusEl: HTMLElement;
@@ -54,7 +54,7 @@ export async function updateConnectionInfo(): Promise<void> {
badge.className = 'status-badge success';
verEl.textContent = `v${version.version}`;
corsHint.style.display = 'none';
logConnection('info', `Ollama v${version.version}`);
logInfo(`Ollama 服务器版本: v${version.version}`);
} catch (err) {
badge.textContent = '✗ 未连接';
badge.className = 'status-badge error';
+2
View File
@@ -177,6 +177,8 @@ async function loadHistorySession(sessionId: string): Promise<void> {
logSession('加载', `${session.title} (${session.messages.length}条消息)`);
state.set(KEYS.CURRENT_SESSION, session);
state.set(KEYS.IS_HISTORY_VIEW, true);
// P1-P1 修复:切换会话时重置已保存消息追踪
db.resetSavedMsgTracking();
(document.querySelector('#historyBar') as HTMLElement).style.display = '';
(document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
+49 -41
View File
@@ -9,15 +9,16 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder, removeCurrentPlaceholder
resetCurrentPlaceholder, removeCurrentPlaceholder
} from './chat-area.js';
import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
import { runAgentLoop, formatToolResultForModel } from '../services/agent-engine.js';
import { estimateTokens } from '../services/context-manager.js';
import { showToolConfirm } from './tool-confirm-modal.js';
import { showConfirm } from './prompt-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
@@ -95,9 +96,9 @@ export function initInputArea(): void {
// Ctrl+K: 清空聊天(需要确认)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (confirm('确定要清空当前对话吗?')) {
clearMessages();
}
showConfirm('确定要清空当前对话吗?', '清空对话').then(ok => {
if (ok) clearMessages();
});
}
// Escape: 停止生成(当正在流式输出时)
if (e.key === 'Escape' && state.get<boolean>(KEYS.IS_STREAMING)) {
@@ -668,8 +669,6 @@ async function handleRetry(): Promise<void> {
// renderMessages 统一渲染 session 数据,先清掉流式 placeholder 避免 DOM 重复
clearMessagesDOM();
renderMessages();
// ── 消费 _lastPlanStatus(不再保存为 session 消息,避免 system 消息渲染到聊天界面)──
state.set('_lastPlanStatus', '');
await saveCurrentSession();
}
});
@@ -925,17 +924,7 @@ function buildPlanConfirmHtml(plan: string, steps: string[]): string {
`;
}
/** Base64 编码(支持 UTF-8 */
function base64Encode(str: string): string {
const bytes = new TextEncoder().encode(str);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function buildFileContentParts(fileContents: Array<{ language: string; content: string }>): string[] {
function buildFileContentParts(fileContents: Array<{ name: string; language: string; content: string }>): string[] {
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
const fileBudget = Math.floor(numCtx * FILE_TOKEN_BUDGET_RATIO);
let usedTokens = 0;
@@ -963,10 +952,10 @@ function buildFileContentParts(fileContents: Array<{ language: string; content:
usedTokens += finalTokens;
return JSON.stringify({
file_name: `文件 ${i + 1}/${fileContents.length}`,
file_name: f.name,
file_type: lang,
context_encode: 'base64',
context: base64Encode(text),
description: '用户上传的附件,内容已包含在 context 字段中,无需使用文件读取工具再次读取。',
context: text,
});
}).filter(Boolean);
}
@@ -1033,17 +1022,21 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage
result.push(assistantMsg);
// ── 注入 tool 结果消息(role: 'tool')──
// 复用 formatToolResultForModel 保持与当前 Loop 格式一致
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
if (!tc.result) continue;
if (result.length >= maxCount) break;
const resultContent = tc.status === 'success'
? JSON.stringify(tc.result)
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
const formattedResult = formatToolResultForModel(tc.name, tc.result);
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
const resultDuration = Date.now() - tc.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
result.push({
role: 'tool',
tool_name: tc.name,
content: resultContent,
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
});
}
}
@@ -1103,10 +1096,6 @@ export async function sendMessage(): Promise<void> {
return;
}
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
// 工具卡片统一由 onDone 挂载,不在执行中更新消息记录
}
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> {
const now = Date.now();
@@ -1127,10 +1116,12 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
// ── 聊天卡片:只显示用户自己的文字 + 附件视觉预览(缩略图/文件卡片/视频帧),不含代码生成的文本标记 ──
const displayContent = text || '';
// ── Ollama API 用:附件 JSON 结构化数据在前,用户文字消息始终在最后 ──
// ── Ollama API 用:用户文字消息在最前,附件 JSON 结构化数据在后 ──
const apiParts: string[] = [];
// 用户文字始终放最前
if (text) apiParts.push(text);
if (userFiles.length > 0) {
const fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
const fileContents = pendingFiles.map(f => ({ name: f.name, language: f.language, content: f.content }));
const fileParts = buildFileContentParts(fileContents);
apiParts.push(...fileParts);
logInfo('📄 文件内容已注入', `${fileParts.length} 段, 共 ${fileContents.reduce((s, f) => s + f.content.length, 0)} 字符`);
@@ -1150,8 +1141,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
apiParts.push(JSON.stringify({
file_name: v.name,
file_type: v.name.split('.').pop() || 'video',
context_encode: 'base64',
context: base64Encode(JSON.stringify({
description: '用户上传的视频附件,帧图片已通过 images 数组传递,帧索引映射已包含在 context 字段中,无需使用文件读取工具再次读取。',
context: JSON.stringify({
frame_count: v.count,
fps: 1,
duration: Math.round(v.dur),
@@ -1161,13 +1152,11 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
timestamp: f.timestampSeconds,
image_index: imageStart + fi,
})),
})),
}),
}));
imageStart += v.count;
}
}
// 用户文字始终放最后
if (text) apiParts.push(text);
apiContentForModel = apiParts.join('\n\n');
const msg: ChatMessage = {
@@ -1184,7 +1173,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
if (userFiles.length > 0) {
msg.files = userFiles;
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
msg._fileContents = pendingFiles.map(f => ({ name: f.name, language: f.language, content: f.content }));
}
msgsToAdd.push(msg);
} else if (text || pendingFiles.length > 0) {
@@ -1196,7 +1185,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
};
if (userFiles.length > 0) {
msg.files = userFiles;
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
msg._fileContents = pendingFiles.map(f => ({ name: f.name, language: f.language, content: f.content }));
}
msgsToAdd.push(msg);
}
@@ -1291,6 +1280,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
messages: [...session.messages, prevMsg],
updatedAt: Date.now()
}));
// P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失
saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e)));
// P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙
removeCurrentPlaceholder();
renderMessages();
appendAssistantPlaceholder();
@@ -1378,9 +1370,6 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
// 先移除流式 placeholder 再 renderMessages,避免未渲染(纯文本)和已渲染(markdown)两个卡片同时存在
removeCurrentPlaceholder();
renderMessages();
// ── 消费 _lastPlanStatus(不再保存为 session 消息,避免 system 消息渲染到聊天界面)──
state.set('_lastPlanStatus', '');
await saveCurrentSession();
}
});
@@ -1438,6 +1427,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行';
}
// P0-P2 修复:非 AbortError 异常时也保存已生成的 partial 消息,避免网络断开/Ollama 崩溃时内容丢失
if (assistantContent) {
const partialMsg: ChatMessage = {
role: 'assistant',
content: assistantContent,
model: getSelectedModel(),
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }),
interrupted: true,
};
currentIterationToolRecords = [];
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
await saveCurrentSession();
}
appendSystemMessage(errMsg);
}
} finally {
+45 -44
View File
@@ -285,6 +285,51 @@ export function initSettingsModal(): void {
}
});
}
// ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)──
// 子代理模型设置保存
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
await db.saveSetting('subAgentModel', val);
logSetting('子代理默认模型', val || '跟随当前模型');
});
// 子代理最大轮次
const saveSubAgentMaxLoops = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
const maxLoops = val ? parseInt(val) : 10;
state.set('subAgentMaxLoops', maxLoops);
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
logSetting('子代理最大轮次', `${maxLoops}`);
}, 500);
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
// 子代理超时(秒)
const saveSubAgentTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
const sec = val ? parseInt(val) : -1; // -1=默认
const ms = sec >= 0 ? sec * 1000 : 300_000;
state.set('subAgentTimeout', ms);
if (db) await db.saveSetting('subAgentTimeout', sec);
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
}, 500);
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
// ── 看门狗超时(分钟,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);
}
export function openSettingsModal(): void {
@@ -429,50 +474,6 @@ async function populateSubAgentModels(): Promise<void> {
}
}
// 子代理模型设置保存
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
await db.saveSetting('subAgentModel', val);
logSetting('子代理默认模型', val || '跟随当前模型');
});
// 子代理最大轮次
const saveSubAgentMaxLoops = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
const maxLoops = val ? parseInt(val) : 10;
state.set('subAgentMaxLoops', maxLoops);
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
logSetting('子代理最大轮次', `${maxLoops}`);
}, 500);
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
// 子代理超时(秒)
const saveSubAgentTimeout = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
const sec = val ? parseInt(val) : -1; // -1=默认
const ms = sec >= 0 ? sec * 1000 : 300_000;
state.set('subAgentTimeout', ms);
if (db) await db.saveSetting('subAgentTimeout', sec);
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
}, 500);
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
// ── 看门狗超时(分钟,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);
async function importSessions(filePath: string): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
+2 -13
View File
@@ -4,7 +4,7 @@
*/
import { state, KEYS } from '../state/state.js';
import { formatTime } from '../utils/utils.js';
import { formatTime, escapeHtml } from '../utils/utils.js';
import { logError } from '../services/log-service.js';
import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js';
import { estimateTokens } from '../services/context-manager.js';
@@ -39,17 +39,6 @@ interface AllSessionsTokenStats {
totals: GlobalTokenTotals;
}
/** P1 #5: HTML 转义,防止 XSS */
function escapeHtml(s: unknown): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 打开仪表盘 */
export function openTokenDashboard(): void {
if (!dashboardModalEl) return;
@@ -198,7 +187,7 @@ async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
return result;
}
} catch (err) {
logError('Token Dashboard: 获取全局统计失败', String(err));
logError('Token Dashboard: 获取全局统计失败', (err as Error).message);
}
return null;
}
+57 -31
View File
@@ -1,5 +1,8 @@
/**
* ToolConfirmModal -
*
* FIFO
*
*/
import type { ToolCall } from '../types.js';
@@ -8,9 +11,18 @@ import { logDebug, logInfo } from '../services/log-service.js';
import { escapeHtml } from '../utils/utils.js';
let modalEl: HTMLElement | null = null;
let resolveConfirm: ((confirmed: boolean) => void) | null = null;
let confirmBtn: HTMLButtonElement | null = null;
let cancelBtn: HTMLButtonElement | null = null;
let initialized = false;
/** 确认请求队列 */
interface ConfirmRequest {
call: ToolCall;
resolve: (confirmed: boolean) => void;
}
const confirmQueue: ConfirmRequest[] = [];
let isShowing = false;
export function initToolConfirmModal(): void {
modalEl = document.querySelector('#toolConfirmModal')!;
if (!modalEl || initialized) return;
@@ -18,7 +30,7 @@ export function initToolConfirmModal(): void {
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) {
cancelConfirm();
cancelCurrent();
}
});
}
@@ -31,28 +43,36 @@ export function showToolConfirm(call: ToolCall): Promise<boolean> {
if (!initialized) {
initialized = true;
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) cancelConfirm();
if (e.target === modalEl) cancelCurrent();
});
}
}
// 如果有未完成的确认,先取消
if (resolveConfirm) {
const oldResolve = resolveConfirm;
resolveConfirm = null;
oldResolve(false);
// 加入队列,如果当前没有显示的确认框,则立即显示
confirmQueue.push({ call, resolve });
if (!isShowing) {
showNext();
}
});
}
resolveConfirm = resolve;
/** 显示队列中的下一个确认 */
function showNext(): void {
if (confirmQueue.length === 0) {
isShowing = false;
return;
}
isShowing = true;
const { call, resolve } = confirmQueue.shift()!;
const icon = getToolIcon(call.function.name);
const name = formatToolName(call.function.name);
logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments));
const titleEl = modalEl.querySelector('#toolConfirmTitle')!;
const bodyEl = modalEl.querySelector('#toolConfirmBody')!;
const confirmBtn = modalEl.querySelector('#toolConfirmBtn')! as HTMLButtonElement;
const cancelBtn = modalEl.querySelector('#toolCancelBtn')! as HTMLButtonElement;
const titleEl = modalEl!.querySelector('#toolConfirmTitle')!;
const bodyEl = modalEl!.querySelector('#toolConfirmBody')!;
confirmBtn = modalEl!.querySelector('#toolConfirmBtn') as HTMLButtonElement;
cancelBtn = modalEl!.querySelector('#toolCancelBtn') as HTMLButtonElement;
titleEl.textContent = `${icon} 确认操作:${name}`;
@@ -63,7 +83,11 @@ export function showToolConfirm(call: ToolCall): Promise<boolean> {
if (call.function.name === 'read_file' || call.function.name === 'write_file' ||
call.function.name === 'list_directory' || call.function.name === 'search_files' ||
call.function.name === 'create_directory' || call.function.name === 'delete_file') {
if (args.path) {
// 支持 paths 数组(批量删除)和单个 path
if (args.paths && Array.isArray(args.paths) && args.paths.length > 0) {
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径(${args.paths.length} 项):</span></div>`;
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(args.paths.map((p: string) => String(p)).join('\n'))}</pre>`;
} else if (args.path) {
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径:</span><code>${escapeHtml(String(args.path))}</code></div>`;
}
}
@@ -81,41 +105,43 @@ export function showToolConfirm(call: ToolCall): Promise<boolean> {
bodyHtml += `</div>`;
bodyEl.innerHTML = bodyHtml;
modalEl.style.display = '';
// 队列计数提示
if (confirmQueue.length > 0) {
bodyEl.querySelector('.tool-confirm-info')!.insertAdjacentHTML('beforeend',
`<div class="tool-confirm-row" style="margin-top:8px;color:var(--text-muted);font-size:12px;">队列中还有 ${confirmQueue.length} 个待确认</div>`);
}
modalEl!.style.display = '';
const onConfirm = (e: Event) => {
e.stopPropagation();
const r = resolveConfirm;
cleanup();
logInfo(`已确认: ${call.function.name}`);
r?.(true);
resolve(true);
showNext();
};
const onCancel = (e: Event) => {
e.stopPropagation();
const r = resolveConfirm;
cleanup();
logInfo(`已取消: ${call.function.name}`);
r?.(false);
resolve(false);
showNext();
};
const cleanup = () => {
confirmBtn.removeEventListener('click', onConfirm);
cancelBtn.removeEventListener('click', onCancel);
confirmBtn?.removeEventListener('click', onConfirm);
cancelBtn?.removeEventListener('click', onCancel);
if (modalEl) modalEl.style.display = 'none';
resolveConfirm = null;
};
confirmBtn.addEventListener('click', onConfirm);
cancelBtn.addEventListener('click', onCancel);
});
}
function cancelConfirm(): void {
if (resolveConfirm) {
const r = resolveConfirm;
resolveConfirm = null;
if (modalEl) modalEl.style.display = 'none';
r(false);
/** 取消当前显示的确认(不影响队列中的后续确认) */
function cancelCurrent(): void {
if (!isShowing) return;
// 当前显示的确认已从队列 shift 出来,直接触发取消按钮
if (cancelBtn) {
cancelBtn.click();
}
}
+46 -44
View File
@@ -1,35 +1,43 @@
/**
* ToolsModal -
* Tool Calling
* auto/confirm/disabled
*
*/
import { state, KEYS } from '../state/state.js';
import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js';
import { setToolMode, type ToolMode } from '../services/tool-registry.js';
import { showToast } from './toast.js';
import { logInfo } from '../services/log-service.js';
import type { ChatDB } from '../db/chat-db.js';
let modalEl: HTMLElement;
const MODE_NAMES: Record<ToolMode, string> = {
const MODE_NAMES: Record<string, string> = {
auto: '自动执行',
confirm: '需确认',
disabled: '已禁用',
};
const BADGE_MAP: Record<ToolMode, { text: string; cls: string }> = {
auto: { text: '自动', cls: 'auto' },
confirm: { text: '需确认', cls: 'confirm' },
disabled: { text: '禁用', cls: 'disabled' },
};
// 需要全局模式管理的工具列表(与 tool-registry.ts MODE_TOOLS 一致)
const MANAGED_TOOLS = [
'run_command',
'write_file', 'create_directory', 'delete_file',
'edit_file', 'move_file', 'copy_file',
'download_file', 'compress',
];
function updateToolBadge(toolName: string, mode: ToolMode): void {
const badge = modalEl.querySelector(`#badge_${toolName}`) as HTMLElement;
if (!badge) return;
const info = BADGE_MAP[mode];
badge.textContent = info.text;
badge.className = `tool-card-badge ${info.cls}`;
function updateAllBadges(mode: string): void {
const badgeText = mode === 'auto' ? '自动' : '需确认';
const badgeCls = mode === 'auto' ? 'auto' : 'confirm';
// 更新所有被管理工具的 badge
const badges = modalEl.querySelectorAll<HTMLElement>('.tool-card-badge');
badges.forEach(badge => {
// 只更新被管理工具的 badge(通过 tool-card-name 文本匹配)
const nameEl = badge.closest('.tool-card')?.querySelector('.tool-card-name');
if (nameEl && MANAGED_TOOLS.includes(nameEl.textContent || '')) {
badge.textContent = badgeText;
badge.className = `tool-card-badge ${badgeCls}`;
}
});
}
function updatePlanBadge(autoConfirm: boolean): void {
@@ -47,36 +55,33 @@ function updatePlanBadge(autoConfirm: boolean): void {
export function initToolsModal(): void {
modalEl = document.querySelector('#toolsModal')!;
// Header 按钮
document.querySelector('#btnTools')!.addEventListener('click', openToolsModal);
document.querySelector('#btnCloseTools')!.addEventListener('click', closeToolsModal);
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) closeToolsModal();
});
// 所有带 .tool-mode-select 的下拉框统一绑定 change 事件
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
selects.forEach(selectEl => {
selectEl.addEventListener('change', async () => {
const toolName = selectEl.dataset.tool;
if (!toolName) return;
const mode = selectEl.value as ToolMode;
// 全局工具执行模式
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
if (globalSelect) {
globalSelect.addEventListener('change', async () => {
const mode = globalSelect.value as ToolMode;
const db = state.get<ChatDB | null>(KEYS.DB);
// 更新运行时模式
// 更新所有被管理工具的运行时模式
for (const toolName of MANAGED_TOOLS) {
setToolMode(toolName, mode);
updateToolBadge(toolName, mode);
}
// 持久化到数据库(所有工具模式合并存储为 JSON)
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
allModes[toolName] = mode;
state.set('toolModes', allModes);
if (db) await db.saveSetting('toolModes', allModes);
// 持久化全局模式
state.set('globalToolMode', mode);
if (db) await db.saveSetting('globalToolMode', mode);
showToast(`${toolName}${MODE_NAMES[mode]}`, 'info');
logInfo(`工具模式切换: ${toolName}${mode}`);
});
updateAllBadges(mode);
showToast(`工具执行模式:${MODE_NAMES[mode]}`, 'info');
logInfo(`全局工具模式切换: → ${mode}`);
});
}
// Plan 确认模式下拉框
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
@@ -96,18 +101,15 @@ export function initToolsModal(): void {
}
export function openToolsModal(): void {
// 从 state 读取所有工具模式,同步 UI
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
selects.forEach(selectEl => {
const toolName = selectEl.dataset.tool;
if (!toolName) return;
const mode = allModes[toolName] || getToolMode(toolName);
selectEl.value = mode;
updateToolBadge(toolName, mode);
});
// 同步全局工具执行模式
const globalMode = state.get<string>('globalToolMode', 'confirm');
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
if (globalSelect) {
globalSelect.value = globalMode;
updateAllBadges(globalMode);
}
// 同步 Plan 确认模式下拉框
// 同步 Plan 确认模式
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
if (planSelect) {
const autoConfirm = state.get<boolean>('planAutoConfirm', false);
+29 -10
View File
@@ -555,6 +555,12 @@ function renderTerminal(): void {
_termRenderedCount = 0;
}
// 从 idle 状态切换到有内容时,清空 idle DOM
// idle 状态用 position:absolute 覆盖在内容上方,不清空会同时显示
if (_termRenderedCount === 0 && container.querySelector('.ws-idle-state')) {
container.innerHTML = '';
}
// 增量追加新行
const fragment = document.createDocumentFragment();
for (let i = _termRenderedCount; i < session.lines.length; i++) {
@@ -872,11 +878,15 @@ function updateToolCardDOM(tc: ToolCallRecord): void {
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
if (!container) return;
// 查找同名且状态为 pending 或 running 的卡片 DOM 节点
const runningCard = (
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) ||
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`)
) as HTMLElement | null;
// P1-R4 修复:同名工具并发执行时 querySelector 只返回第一个匹配,可能更新到错误的卡片。
// 改为 querySelectorAll 查找所有同名 pending/running 卡片,取最后一个(最新的那个)更新。
// 这与 updateToolCard 函数(行 839-853)在 JS 数组层面反向遍历找最后一个 running/pending 的逻辑一致。
const escapedName = escapeAttr(tc.name);
const allRunningCards = Array.from(container.querySelectorAll(
`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running,` +
`.tool-call-card[data-tool-name="${escapedName}"].tool-call-pending`
)) as HTMLElement[];
const runningCard = allRunningCards.length > 0 ? allRunningCards[allRunningCards.length - 1] : null;
if (runningCard) {
const wrapper = document.createElement('div');
@@ -967,7 +977,7 @@ function renderToolCalls(): void {
</div>
<div class="ws-idle-divider"></div>
<div class="ws-idle-desc">AI </div>
<div class="ws-idle-hint"> 40 · MCP </div>
<div class="ws-idle-hint"> 32 · MCP </div>
</div>
`;
return;
@@ -986,8 +996,8 @@ function getToolDisplayName(name: string): string {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令',
move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取',
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
edit_file: '编辑文件', tree: '目录树', download_file: '下载文件',
read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory: '记忆管理',
@@ -1003,8 +1013,8 @@ function getToolIcon(name: string): string {
read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
move_file: '📦', copy_file: '📋', web_fetch: '🌐',
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
edit_file: '✂️', tree: '🌳', download_file: '⬇️',
read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory: '🧠',
@@ -1068,7 +1078,16 @@ function renderToolCard(tc: ToolCallRecord): string {
} else if (tc.name === 'write_file') {
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))}</div>`;
} else if (tc.name === 'delete_file') {
if (r.batch) {
resultHtml = `<div class="tool-result-entry">✅ 批量删除 ${r.successCount}/${r.totalPaths} 个路径</div>`;
if (r.results) {
for (const res of r.results) {
resultHtml += `<div class="tool-result-entry">${res.success ? '✅' : '❌'} ${escapeHtml(String(res.path || ''))}${res.success ? '' : ' — ' + escapeHtml(String(res.error || ''))}</div>`;
}
}
} else {
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
}
} else if (tc.name === 'create_directory') {
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
} else {
+13 -2
View File
@@ -69,6 +69,9 @@ export class ChatDB {
// ── Sessions ──
// P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息
private _savedMsgIds = new Set<string>();
async saveSession(session: ChatSession): Promise<string> {
if (isDesktop()) {
const row = {
@@ -82,11 +85,14 @@ export class ChatDB {
updated_at: session.updatedAt
};
await dbBridge().saveSession(row);
// 同步保存消息
// P1-P1 修复:只保存新增消息(不在 _savedMsgIds 中的),避免全量重写
for (let mi = 0; mi < session.messages.length; mi++) {
const msg = session.messages[mi];
const msgId = `${session.id}_${msg.timestamp}_${msg.role}_${mi}`;
if (this._savedMsgIds.has(msgId)) continue; // 已保存,跳过
this._savedMsgIds.add(msgId);
const msgRow = {
id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`,
id: msgId,
session_id: session.id,
role: msg.role,
content: msg.content || null,
@@ -107,6 +113,11 @@ export class ChatDB {
return this._idbSaveSession(session);
}
/** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */
resetSavedMsgTracking(): void {
this._savedMsgIds.clear();
}
async getSession(id: string): Promise<ChatSession | null> {
if (isDesktop()) {
const row = await dbBridge().getSession(id);
+30 -126
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.16.1</span>
<span class="app-version">v0.16.12</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<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"/>
@@ -297,6 +297,10 @@
<p class="text-muted" style="margin-top:4px;font-size:11px;">Agent Loop 最大工具调用轮数。默认 85 轮,复杂任务可能需要更多轮次。</p>
<label class="setting-label" style="margin-top:12px;">上下文长度(Context Length</label>
<select class="setting-input" id="selectContextLength" style="margin-bottom:0;">
<option value="8192">8K</option>
<option value="16384">16K</option>
<option value="32768">32K</option>
<option value="65536">64K</option>
<option value="131072">128K</option>
<option value="262144">256K</option>
<option value="524288">512K</option>
@@ -438,14 +442,14 @@
<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><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong> — 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</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>40+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>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</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>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</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>32 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>13 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / tree / download_file / 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>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>1 个):calculator</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>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</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>11个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</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>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 任务感知 → 中途幻觉检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询)</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测工具幻觉和 prompt injection,阻断有问题的回复</li><li><strong>中英双语检测规则</strong>:覆盖中英文模型输出,防止 AI 声称执行了未调用的工具</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:200 条消息强制压缩 + 120 条增量压缩,防止 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摘要,120条触发增量压缩 + 200条硬上限强制压缩</li><li><strong>流式总超时</strong>:可配置(默认300s),Ollama 假死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>智能工具调度</strong>:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存</li><li><strong>Plan Mode 断点续传</strong>:中止后可恢复未完成的计划,进度自动保存</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li><li><strong>旧工具结果智能截断</strong>:超过10轮后自动截断到2000字符,优先在JSON边界处截断</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>📋 自定义文件(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>SOUL.md / AGENT.md 删除工作空间文件可恢复内置默认版本;USER.md 无内置版,不存在则不注入</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>SOUL.md 删除工作空间文件可恢复内置默认版本;AGENT.md / USER.md 无内置版,工作空间不存在则不注入</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>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含 3 个页签:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),单一终端进程</li><li><strong>🔧 工具 Tab</strong> — 展示本轮对话的工具调用卡片,含状态统计(总数 ✅ 成功 ❌ 失败),AI 执行命令时自动切到此页签</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容(带行号),支持上级目录导航</li></ul></li><li>工作空间目录可在设置中修改</li></ul></div>
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 SQLite</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 JSON / <code>.metona</code> 加密备份</li></ul></div>
@@ -458,7 +462,7 @@
<div class="modal-overlay" id="toolsModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>🔧 工具面板(40 个 + 1 Plan 专属)</h3>
<h3>🔧 工具面板(32 个 + 1 Plan 专属)</h3>
<button class="icon-btn" id="btnCloseTools">
<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"/>
@@ -467,22 +471,26 @@
</div>
<div class="modal-body">
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">AI 始终可在对话中自主调用以下工具(Tool Calling 永久开启)。高风险操作需用户确认。</p>
<div style="margin-bottom:16px;padding:12px;background:var(--bg-card);border:1px solid var(--border-default);border-radius:8px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
<div>
<div style="font-weight:600;font-size:14px;">⚙️ 全局工具执行模式</div>
<div style="font-size:11px;color:var(--text-secondary);margin-top:2px;">统一管理所有工具的执行模式,不区分工具</div>
</div>
<select id="select_global_tool_mode" style="font-size:13px;padding:6px 12px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:6px;min-width:140px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
</select>
</div>
</div>
<div class="tools-grid">
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">读取文件(文本/binary/base64)2000行默认,截断自动提示续读</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge confirm" id="badge_write_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">写入/追加文件,支持base64二进制,最大10MB</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="write_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📁</span><span class="tool-card-name">list_directory</span><span class="tool-card-badge auto">自动</span></div>
@@ -493,116 +501,40 @@
<div class="tool-card-desc">按文件名或内容搜索,支持正则表达式</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge confirm" id="badge_create_directory">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">创建目录,自动创建父级</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="create_directory" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge confirm" id="badge_delete_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">删除文件或目录,返回被删文件数和总大小</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="delete_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge confirm" id="badge_move_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">移动或重命名文件/目录</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="move_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge confirm" id="badge_copy_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">复制文件或目录到新位置</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="copy_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🌐</span><span class="tool-card-name">web_fetch</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">抓取网页内容,反爬headers+UA切换+自动回退浏览器渲染</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge confirm" id="badge_edit_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">查找替换(支持正则use_regex),全部或单次替换</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="edit_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm" id="badge_run_command">需确认</span></div>
<div class="tool-card-desc">执行 shell 命令,无超时限制</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="run_command" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">get_file_info</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">获取文件/目录详细信息:大小、日期、权限</div>
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">执行 shell 命令,支持超时配置</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🌳</span><span class="tool-card-name">tree</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">以树形结构展示目录内容</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge confirm" id="badge_download_file">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">从 URL 下载文件到本地</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="download_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔀</span><span class="tool-card-name">diff_files</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">对比两个文件,显示差异</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">replace_in_files</span><span class="tool-card-badge confirm" id="badge_replace_in_files">需确认</span></div>
<div class="tool-card-desc">按 glob 模式批量查找替换多个文件</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="replace_in_files" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📚</span><span class="tool-card-name">read_multiple_files</span><span class="tool-card-badge auto">自动</span></div>
@@ -613,16 +545,8 @@
<div class="tool-card-desc">Git 全操作(17个子命令:status/log/diff/add/commit/push等)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm" id="badge_compress">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm">需确认</span></div>
<div class="tool-card-desc">创建或解压 zip/tar.gz 归档</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="compress" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔍</span><span class="tool-card-name">web_search</span><span class="tool-card-badge auto">自动</span></div>
@@ -680,30 +604,10 @@
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">browser_close</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">关闭 Agent 浏览器并释放资源</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🕐</span><span class="tool-card-name">datetime</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">获取系统精确时间(ISO/Unix/日期/时间/完整)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔢</span><span class="tool-card-name">calculator</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">安全数学计算(+ - * / ** % (),递归下降解析器)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🎲</span><span class="tool-card-name">random</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">随机数/随机选择(整数/浮点/抽取/字符串)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🆔</span><span class="tool-card-name">uuid</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">生成 UUID v4 唯一标识符(批量最多20个)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">json_format</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">JSON 格式化 + 语法验证 + 键排序</div>
</div>
<div class="tool-card">
<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>
<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 confirm" id="badge_plan_track">需确认</span></div>
<div class="tool-card-desc">Plan 模式专属:追踪执行进度,标记/查看步骤完成状态</div>
+59 -67
View File
@@ -22,7 +22,7 @@ import { initHistoryModal, closeHistoryModal } from './components/history-modal.
import { initMemoryModal } from './components/memory-modal.js';
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
import { setToolEnabled, setToolMode, refreshMCPTools } from './services/tool-registry.js';
import { initGlobalToolMode, refreshMCPTools } from './services/tool-registry.js';
import { startAllMCPServers } from './services/mcp-client.js';
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
@@ -296,29 +296,7 @@ async function init(): Promise<void> {
logInit('启动...');
try {
db = new ChatDB();
await db.init();
state.set(KEYS.DB, db);
// v4.0 数据迁移:检测 IndexedDB 是否有旧数据需要迁移到 SQLite
await migrateIndexedDBToSQLite(db);
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
api = new OllamaAPI(serverUrl);
state.set(KEYS.API, api);
logInit(`Ollama 地址: ${serverUrl}`);
state.set(KEYS.CURRENT_SESSION, createNewSession());
state.set(KEYS.IS_STREAMING, false);
state.set(KEYS.IS_HISTORY_VIEW, false);
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
state.set('toolCallingEnabled', true);
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
state.set('thinkEnabled', false);
// ── UI 组件初始化(不依赖数据库,先初始化以避免 catch 路径重复绑定)──
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
initGlobalErrorHandler(); // R42: 全局错误处理器(紧随日志面板之后)
initToast();
@@ -340,20 +318,22 @@ async function init(): Promise<void> {
bindGlobalEvents();
await checkConnection();
await loadModels();
await loadSearxngConfig(db);
// ── MEMORY.md 初始化 ──
import('./services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
// ── Harness Engineering 系统初始化 ──
// ── Harness Engineering 系统初始化(幂等,UI 就绪后即可注册)──
initHarnessHooks();
// Agent Metrics 无需显式初始化(按需启动)
// Context Indexer 在 Agent INIT 状态按需构建
// ── 状态默认值(不依赖数据库)──
state.set(KEYS.CURRENT_SESSION, createNewSession());
state.set(KEYS.IS_STREAMING, false);
state.set(KEYS.IS_HISTORY_VIEW, false);
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
state.set('toolCallingEnabled', true);
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
state.set('thinkEnabled', false);
// ── 注入应用版本号到 Agent Metrics ──
const _bridge = window.metonaDesktop;
if (_bridge?.info) {
@@ -362,6 +342,28 @@ async function init(): Promise<void> {
}).catch(() => {});
}
try {
db = new ChatDB();
await db.init();
state.set(KEYS.DB, db);
// v4.0 数据迁移:检测 IndexedDB 是否有旧数据需要迁移到 SQLite
await migrateIndexedDBToSQLite(db);
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
api = new OllamaAPI(serverUrl);
state.set(KEYS.API, api);
logInit(`Ollama 地址: ${serverUrl}`);
await checkConnection();
await loadModels();
await loadSearxngConfig(db);
// ── MEMORY.md 初始化 ──
import('./services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
});
logInit('所有组件已就绪(含 Harness');
const savedModel = await db.getSetting('selectedModel', '');
@@ -444,22 +446,29 @@ async function init(): Promise<void> {
// ── Tool Calling 设置(永久开启,不可关闭)──
state.set('toolCallingEnabled', true);
// 统一加载所有工具执行模式(auto/confirm/disabled
// 全局工具执行模式(统一管理所有工具,不区分工具
{
// 优先读取合并存储的 toolModes JSON
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {});
// 兼容旧版:如果 toolModes 中没有 run_command 但有独立的 runCommandMode,则迁移
if (!savedModes['run_command']) {
const legacy = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
savedModes['run_command'] = legacy;
await db.saveSetting('toolModes', savedModes);
await db.saveSetting('runCommandMode', null); // 清除旧键
}
state.set('toolModes', savedModes);
// 为每个工具应用模式
for (const [toolName, mode] of Object.entries(savedModes)) {
setToolMode(toolName, mode);
// 读取全局模式,默认 confirm
let globalMode = await db.getSetting<'auto' | 'confirm'>('globalToolMode', 'confirm');
// 兼容旧版:从旧的 toolModes 迁移(如果存在)
// 注意:之前用 saveSetting('toolModes', null) 清除旧键,JSON.parse("null") 返回 null
// 所以这里必须做 null 防御,否则 Object.keys(null) 会抛出 "Cannot convert undefined or null to object"
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {}) ?? {};
if (savedModes && Object.keys(savedModes).length > 0) {
// 如果旧 toolModes 中所有被管理工具都是 auto,则迁移为 auto;否则 confirm
const allAuto = [
'run_command', 'write_file', 'create_directory', 'delete_file',
'edit_file', 'move_file', 'copy_file',
'download_file', 'compress',
].every(t => savedModes[t] === 'auto');
globalMode = allAuto ? 'auto' : 'confirm';
await db.saveSetting('globalToolMode', globalMode);
// 清除旧键:用空对象而非 null,避免 JSON.parse("null") 返回 null 的陷阱
await db.saveSetting('toolModes', {});
}
state.set('globalToolMode', globalMode);
// 应用全局模式到所有被管理工具
initGlobalToolMode(globalMode);
}
// ── Plan 自动确认设置 ──
@@ -478,7 +487,8 @@ async function init(): Promise<void> {
logSuccess('应用初始化完成');
} catch (err) {
logError('初始化失败', (err as Error).message);
if (!api!) {
// UI 组件已在 try 块之前初始化,此处不重复调用以避免重复绑定事件监听器
if (!api) {
api = new OllamaAPI();
state.set(KEYS.API, api);
}
@@ -486,24 +496,6 @@ async function init(): Promise<void> {
state.set(KEYS.IS_STREAMING, false);
state.set(KEYS.IS_HISTORY_VIEW, false);
state.set(KEYS.CURRENT_SESSION, createNewSession());
initLogPanel();
bindGlobalEvents();
initToast();
initLightbox();
initHeader();
initModelBar();
initChatArea();
initInputArea();
initSettingsModal();
initHistoryModal();
initHelpModal();
initToolConfirmModal();
initMemoryModal();
initToolsModal();
initTokenDashboard();
initWorkspacePanel();
setupDesktopIntegration();
initHarnessHooks();
// ── MEMORY.md 初始化(fallback 路径)──
import('./services/memory-service.js').then(({ initMemoryFile }) => {
initMemoryFile().catch(() => {});
-21
View File
@@ -1,21 +0,0 @@
# AGENTS.md — Agent 行为规范准则(10 条)
## 1. 敏感信息红线
禁止读取、复制、修改、输出或提交以下任何内容:`.env` 文件、API Key、Token、数据库密码、云服务密钥、私钥文件。调试配置问题时,只能查看脱敏示例文件(如 `.env.example`)。不确定某文件是否属于敏感信息时,必须先询问用户,禁止自行打开。
## 2. 先读后改
收到任何修改任务后,必须先阅读项目目录结构、相关模块和现有代码,再动手修改。修改代码前必须确认已理解该模块的上下文、命名约定和分层关系。禁止在未阅读相关代码的情况下直接编写或修改。
## 3. 需求模糊时先澄清
任务存在歧义、信息不足或多种合理解读时,必须向用户提出澄清问题,禁止基于假设自行推进。涉及技术选型、范围边界或优先级取舍时,必须先获得用户确认。仅在小范围、低风险且意图明确的情况下,才可直接执行。
## 4. 高风险改动先出方案
涉及以下任一领域的修改,必须先给出方案并获得用户确认后才能动手:架构调整、数据库结构变更、接口契约变更、权限逻辑修改、安全相关代码、跨模块改动。方案中必须列出影响范围和至少两种可选路径的优缺点。
## 5. 最小改动
只修改与当前任务直接相关的代码。禁止做以下任何行为:无关重构、全局格式化、顺手调整命名风格、修改与任务无关的文件。发现额外问题时,记录为建议并告知用户,禁止直接扩大修改范围。
## 6. 修改前列文件清单
正式修改前,必须列出预计会创建、修改或删除的全部文件。以下操作必须等待用户明确确认后才能执行:删除文件、迁移目录、批量格式化、修改配置文件、修改数据库脚本。实际修改范围超出原计划时,必须暂停并重新说明。
## 7. 不臆造信息
禁止编造不存在的函数、接口、依赖、配置项或文档引用。对不确定的事实,必须明确标注"不确定"或主动检索验证。禁止伪造测试结果或验证证据。发现自己之前的回答有误时,必须立即说明错误并给出修正。
## 8. 完成后必须验证
每次修改完成后,必须运行项目已有的测试命令、构建命令或类型检查命令进行验证。无法运行测试时,必须说明原因并给出可手动验证的具体步骤。禁止在未提供任何验证方式的情况下声称"已修复""完全可用"或"已完成"。
## 9. 遵循项目既有规范
修改或新增代码时,必须遵循项目已有的代码风格、目录结构、命名约定和分层设计。禁止引入与项目现有风格冲突的个人习惯。项目存在 lint 规则或格式化配置时,必须严格遵守。新增代码必须复用项目中已有的工具类、组件和封装,禁止重复造轮子。
## 10. 规约不越界
本文件的规则在不违反系统安全策略、平台权限限制和用户当前明确指令的前提下生效。本文件与上述任何一项冲突时,以安全策略、平台限制和用户当前指令为准。禁止为了满足本文件的规则而执行破坏性操作或绕过安全机制。
File diff suppressed because it is too large Load Diff
-17
View File
@@ -37,7 +37,6 @@ interface SessionMetrics {
toolCalls: Array<{ name: string; status: string; duration?: number }>;
totalInputTokens: number;
totalOutputTokens: number;
completionGatePassed: boolean;
errorPatterns: string[];
}
@@ -55,7 +54,6 @@ export function startSessionMetrics(sessionId: string, model: string): void {
toolCalls: [],
totalInputTokens: 0,
totalOutputTokens: 0,
completionGatePassed: false,
errorPatterns: [],
};
logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`);
@@ -82,12 +80,6 @@ export function recordToolCall(name: string, status: string, durationMs?: number
}
}
/** 完成门控结果 */
export function recordCompletionGate(passed: boolean): void {
if (!currentMetrics) return;
currentMetrics.completionGatePassed = passed;
}
/** 结束会话度量 */
export function endSessionMetrics(): SessionMetrics | null {
if (!currentMetrics) return null;
@@ -124,7 +116,6 @@ function persistMetricsHistory(): void {
iterations: m.totalIterations,
toolCallCount: m.toolCalls.length,
toolSuccessRate: getToolSuccessRate(m),
completionGatePassed: m.completionGatePassed,
errorPatterns: m.errorPatterns,
inputTokens: m.totalInputTokens,
outputTokens: m.totalOutputTokens,
@@ -152,7 +143,6 @@ export function loadMetricsHistory(): void {
toolCalls: [],
totalInputTokens: item.inputTokens || 0,
totalOutputTokens: item.outputTokens || 0,
completionGatePassed: item.completionGatePassed || false,
errorPatterns: item.errorPatterns || [],
});
}
@@ -192,7 +182,6 @@ export function aggregateMetrics(): AgentMetrics {
totalSessions: 0,
avgIterationsPerTask: 0,
toolSuccessRate: 0,
avgCompletionScore: 0,
frequentErrors: [],
tokenEfficiency: 0,
collectedAt: Date.now(),
@@ -221,7 +210,6 @@ export function aggregateMetrics(): AgentMetrics {
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(),
@@ -330,7 +318,6 @@ export function formatMetricsReport(metrics: AgentMetrics): string {
`总会话数: ${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');
@@ -347,7 +334,6 @@ export function exportMetricsJSON(): string {
sessions_total: metrics.totalSessions,
avg_iterations_per_task: metrics.avgIterationsPerTask,
tool_success_rate: parseFloat(metrics.toolSuccessRate.toFixed(4)),
completion_gate_pass_rate: parseFloat(metrics.avgCompletionScore.toFixed(4)),
token_efficiency: parseFloat(metrics.tokenEfficiency.toFixed(4)),
top_errors: metrics.frequentErrors.slice(0, 5),
improvement_suggestions: generateImprovementSuggestions().slice(0, 3).map(s => ({
@@ -373,9 +359,6 @@ export function exportMetricsPrometheus(): string {
lines.push('# HELP metona_tool_success_rate Tool call success rate (0-1)');
lines.push('# TYPE metona_tool_success_rate gauge');
lines.push(`metona_tool_success_rate ${metrics.toolSuccessRate.toFixed(4)}`);
lines.push('# HELP metona_completion_gate_pass_rate Completion gate pass rate (0-1)');
lines.push('# TYPE metona_completion_gate_pass_rate gauge');
lines.push(`metona_completion_gate_pass_rate ${metrics.avgCompletionScore.toFixed(4)}`);
lines.push('# HELP metona_token_efficiency Token output/input efficiency ratio');
lines.push('# TYPE metona_token_efficiency gauge');
lines.push(`metona_token_efficiency ${metrics.tokenEfficiency.toFixed(4)}`);
+55 -471
View File
@@ -1,4 +1,4 @@
/**
/**
* Agent Safety Agent
*
* agent-engine.ts R51-R56
@@ -97,227 +97,10 @@ export function detectConsecutiveIdentical(minCount: number): { detected: boolea
}
// ═══════════════════════════════════════════════════════════════
// R56: 目标对齐验证
// R56 已删除:目标对齐验证 — 关键词匹配粗糙,反复注入干扰 AI 判断
// R63 已删除:工具调用速率限制 — 剥夺 AI 试错空间,误伤密集型任务
// ═══════════════════════════════════════════════════════════════
/** 用户原始目标(在 handleInit 时设置) */
let _userGoal: string = '';
/** 设置用户原始目标 */
export function setUserGoal(goal: string): void {
_userGoal = goal.slice(0, 500); // 限制长度防止占用过多内存
}
/** 获取用户原始目标 */
export function getUserGoal(): string {
return _userGoal;
}
/** 重置用户目标 */
export function resetUserGoal(): void {
_userGoal = '';
}
/** 目标对齐检测结果 */
export interface GoalAlignmentResult {
aligned: boolean;
reason: string;
suggestion?: string;
}
/**
* R56: 检测 Agent
*
*
* 1.
* 2. 5
* 3.
*
* @param recentToolNames N
* @param recentContent N
* @param loopCount
*/
export function checkGoalAlignment(
recentToolNames: string[],
recentContent: string,
loopCount: number,
): GoalAlignmentResult {
if (!_userGoal || _userGoal.length < 10) {
return { aligned: true, reason: '目标太短,跳过对齐检测' };
}
// 仅在循环数 >= 6 时开始检测(前几轮可能是探索阶段)
if (loopCount < 6) {
return { aligned: true, reason: '前期探索阶段,跳过检测' };
}
const goalLower = _userGoal.toLowerCase();
// 提取目标关键词
const goalKeywords = extractGoalKeywords(_userGoal);
// 检查最近内容是否包含目标关键词
const contentLower = recentContent.toLowerCase();
const contentMatches = goalKeywords.filter(kw => contentLower.includes(kw.toLowerCase()));
// 检查最近的工具调用是否与目标相关
// 每种工具与特定目标类型的关联度
const toolGoalRelevance = assessToolGoalRelevance(recentToolNames, goalLower);
// 如果最近内容中包含目标关键词,认为是对齐的
if (contentMatches.length >= 1) {
return { aligned: true, reason: `内容中包含目标关键词: ${contentMatches.join(', ')}` };
}
// 如果工具调用与目标高度相关,认为是对齐的
if (toolGoalRelevance.relevant) {
return { aligned: true, reason: toolGoalRelevance.reason };
}
// 偏离判定:内容无关键词 + 工具不相关 + 已过探索阶段
return {
aligned: false,
reason: `最近 ${recentToolNames.length} 轮工具调用 (${recentToolNames.slice(-3).join(', ')}) 与用户目标"${_userGoal.slice(0, 60)}..."可能不相关`,
suggestion: `请回顾用户原始任务: "${_userGoal.slice(0, 100)}"。如果当前操作是在为任务做准备,请继续;如果已经偏离,请调整方向。`,
};
}
/** 从用户目标中提取关键词 */
function extractGoalKeywords(goal: string): string[] {
const keywords: string[] = [];
// 中文关键词
const cnPatterns = [
/搜索|查找|查一下|搜一下|检索|查询/g,
/写入|创建|生成|保存|输出/g,
/运行|执行|编译|构建/g,
/读取|查看|打开/g,
/修改|编辑|更新|替换/g,
/删除|移除|清理/g,
/下载|安装/g,
/分析|研究|评估|对比/g,
/测试|验证|检查/g,
/部署|发布/g,
];
// 英文关键词
const enPatterns = [
/\b(search|find|lookup)\b/gi,
/\b(write|create|generate|save)\b/gi,
/\b(run|execute|build|compile)\b/gi,
/\b(read|view|open)\b/gi,
/\b(edit|modify|update|replace)\b/gi,
/\b(delete|remove|clean)\b/gi,
/\b(download|install)\b/gi,
/\b(analyze|research|evaluate|compare)\b/gi,
/\b(test|verify|check)\b/gi,
/\b(deploy|publish)\b/gi,
];
for (const p of [...cnPatterns, ...enPatterns]) {
const matches = goal.match(p);
if (matches) keywords.push(...matches);
}
// 提取路径/文件名(如果目标中包含)
const pathMatches = goal.match(/[\w-]+\.(ts|js|py|json|md|txt|html|css|go|rs|java|cpp|c)/gi);
if (pathMatches) keywords.push(...pathMatches);
// 提取引号中的内容
const quotedMatches = goal.match(/[""']([^""']{3,30})[""']/g);
if (quotedMatches) {
for (const q of quotedMatches) {
keywords.push(q.replace(/[""']/g, ''));
}
}
return [...new Set(keywords)];
}
/** 评估工具调用与用户目标的相关性 */
function assessToolGoalRelevance(toolNames: string[], goalLower: string): { relevant: boolean; reason: string } {
if (toolNames.length === 0) return { relevant: true, reason: '无工具调用,跳过' };
// 文件操作类目标
const isFileTask = /文件|目录|file|directory|path|路径/.test(goalLower);
// 搜索类目标
const isSearchTask = /搜索|查找|查|search|find|grep/.test(goalLower);
// 命令执行类目标
const isCommandTask = /运行|执行|命令|终端|run|execute|command|shell/.test(goalLower);
// 网络类目标
const isWebTask = /网页|网站|url|web|fetch|搜索|search/.test(goalLower);
// Git 类目标
const isGitTask = /git|提交|推送|分支|commit|push|pull/.test(goalLower);
// 浏览器类目标
const isBrowserTask = /浏览器|网页|截图|browser|screenshot/.test(goalLower);
const recentTools = toolNames.slice(-5);
if (isFileTask && recentTools.some(t => /read_file|write_file|edit_file|list_directory|search_files|tree/.test(t))) {
return { relevant: true, reason: '文件操作与文件类目标相关' };
}
if (isSearchTask && recentTools.some(t => /search_files|web_search|grep/.test(t))) {
return { relevant: true, reason: '搜索操作与搜索类目标相关' };
}
if (isCommandTask && recentTools.some(t => /run_command/.test(t))) {
return { relevant: true, reason: '命令执行与命令类目标相关' };
}
if (isWebTask && recentTools.some(t => /web_search|web_fetch|browser_open/.test(t))) {
return { relevant: true, reason: '网络操作与网络类目标相关' };
}
if (isGitTask && recentTools.some(t => /git/.test(t))) {
return { relevant: true, reason: 'Git 操作与 Git 类目标相关' };
}
if (isBrowserTask && recentTools.some(t => /browser_/.test(t))) {
return { relevant: true, reason: '浏览器操作与浏览器类目标相关' };
}
// memory 和 plan_track 始终认为相关
if (recentTools.some(t => /memory|plan_track/.test(t))) {
return { relevant: true, reason: '记忆/计划操作始终相关' };
}
return { relevant: false, reason: '最近工具调用与目标类型不匹配' };
}
// ═══════════════════════════════════════════════════════════════
// R63: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
// ═══════════════════════════════════════════════════════════════
const _toolCallTimestamps = new Map<string, number[]>();
const RATE_LIMIT_WINDOW = 60_000; // 60 秒窗口
const RATE_LIMIT_DEFAULT = 20; // 默认每分钟最多 20 次
/** R63: 工具特定的速率限制配置 */
const TOOL_RATE_LIMITS: Record<string, number> = {
web_search: 10, // 每分钟最多 10 次搜索
web_fetch: 8, // 每分钟最多 8 次抓取
run_command: 15, // 每分钟最多 15 次命令
browser_open: 8, // 每分钟最多 8 次浏览器打开
download_file: 5, // 每分钟最多 5 次下载
};
/** R63: 检查工具调用是否超过速率限制 */
export function checkRateLimit(toolName: string): { allowed: boolean; retryAfterMs: number } {
const limit = TOOL_RATE_LIMITS[toolName] ?? RATE_LIMIT_DEFAULT;
const now = Date.now();
const timestamps = _toolCallTimestamps.get(toolName) || [];
// 清理过期时间戳
const valid = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
if (valid.length >= limit) {
// 计算最早过期时间
const oldest = valid[0];
const retryAfterMs = RATE_LIMIT_WINDOW - (now - oldest);
return { allowed: false, retryAfterMs: Math.max(1000, retryAfterMs) };
}
valid.push(now);
_toolCallTimestamps.set(toolName, valid);
return { allowed: true, retryAfterMs: 0 };
}
// ═══════════════════════════════════════════════════════════════
// R66-R67: 错误分类系统 — 区分瞬态/永久错误,指导重试策略
// ═══════════════════════════════════════════════════════════════
@@ -451,7 +234,12 @@ export function validatePathSandbox(
}
// 检查路径遍历攻击
const relativePath = normalized.toLowerCase().replace(normalizedWs.toLowerCase(), '').replace(/^[\\/]/, '');
// P0 修复:用前缀锚定 replace,避免工作空间路径是另一路径子串时误判
const wsLower = normalizedWs.toLowerCase();
const normLower = normalized.toLowerCase();
const relativePath = normLower.startsWith(wsLower)
? normLower.slice(wsLower.length).replace(/^[\\/]/, '')
: normLower;
if (relativePath.includes('..')) {
// 允许在工作空间内部的相对路径引用
const segments = relativePath.split(/[\\/]/);
@@ -485,45 +273,9 @@ function isAbsolute(p: string): boolean {
}
// ═══════════════════════════════════════════════════════════════
// R87: 工具熔断器 — 连续失败后自动禁用工具,防止雪崩
// R87 已删除:工具熔断器 — 剥夺 AI 试错空间,连续失败可能是参数调试过程
// ═══════════════════════════════════════════════════════════════
/** 工具失败计数器 */
const _toolFailureCount = new Map<string, number>();
const CIRCUIT_BREAKER_THRESHOLD = 5; // 连续失败 5 次后熔断
const CIRCUIT_BREAKER_COOLDOWN = 60_000; // 熔断冷却 60 秒
const _circuitBreakerTripped = new Map<string, number>(); // toolName → trip timestamp
/** R87: 记录工具失败 */
export function recordToolFailure(toolName: string): void {
const count = (_toolFailureCount.get(toolName) || 0) + 1;
_toolFailureCount.set(toolName, count);
if (count >= CIRCUIT_BREAKER_THRESHOLD) {
_circuitBreakerTripped.set(toolName, Date.now());
logWarn(`R87: 熔断器触发 — ${toolName} 连续失败 ${count} 次,禁用 60 秒`);
}
}
/** R87: 记录工具成功(重置失败计数) */
export function recordToolSuccess(toolName: string): void {
_toolFailureCount.delete(toolName);
_circuitBreakerTripped.delete(toolName);
}
/** R87: 检查工具是否被熔断 */
export function isToolCircuitBroken(toolName: string): { broken: boolean; remainingMs: number } {
const tripTime = _circuitBreakerTripped.get(toolName);
if (!tripTime) return { broken: false, remainingMs: 0 };
const elapsed = Date.now() - tripTime;
if (elapsed >= CIRCUIT_BREAKER_COOLDOWN) {
// 冷却期过了,半开状态:允许调用但保持失败计数
_circuitBreakerTripped.delete(toolName);
_toolFailureCount.set(toolName, CIRCUIT_BREAKER_THRESHOLD - 1); // 只给一次机会
return { broken: false, remainingMs: 0 };
}
return { broken: true, remainingMs: CIRCUIT_BREAKER_COOLDOWN - elapsed };
}
// ═══════════════════════════════════════════════════════════════
// R88: 工具结果元数据 — 为模型提供结果大小的上下文提示
// ═══════════════════════════════════════════════════════════════
@@ -552,7 +304,7 @@ export function addResultMetadata(formattedResult: string): string {
// R97: 错误模式学习 — 从重复错误中学习并生成改进建议
// ═══════════════════════════════════════════════════════════════
interface ErrorPatternEntry {
export interface ErrorPatternEntry {
toolName: string;
errorSignature: string;
count: number;
@@ -621,93 +373,9 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
}
// ═══════════════════════════════════════════════════════════════
// R104: 工具结果去重 — 相似结果检测,减少上下文中的冗余
// R104 已删除:工具结果去重 — 误伤轮询类任务(如反复 run_command 检查构建状态)
// ═══════════════════════════════════════════════════════════════
interface ToolResultFingerprint {
toolName: string;
contentHash: string;
timestamp: number;
resultLength: number;
}
const _resultFingerprints: ToolResultFingerprint[] = [];
const MAX_FINGERPRINTS = 50;
/** R104: 计算内容的简化指纹(用于相似度检测) */
function computeResultFingerprint(content: string): string {
// 取内容的前200字符 + 后100字符作为指纹
const head = content.slice(0, 200);
const tail = content.slice(-100);
let hash = 5381;
const combined = head + tail;
for (let i = 0; i < combined.length; i++) {
hash = ((hash << 5) + hash + combined.charCodeAt(i)) & 0x7fffffff;
}
return hash.toString(36);
}
/** R104: 检查工具结果是否与最近的结果高度相似 */
export function isDuplicateToolResult(toolName: string, resultContent: string): { duplicate: boolean; similarTo?: string } {
const hash = computeResultFingerprint(resultContent);
const now = Date.now();
const DEDUP_WINDOW = 60_000; // 60 秒内的结果视为候选
// 查找相同工具+相同指纹的结果
for (const fp of _resultFingerprints) {
if (fp.toolName === toolName && fp.contentHash === hash && (now - fp.timestamp) < DEDUP_WINDOW) {
return { duplicate: true, similarTo: `${fp.timestamp}` };
}
}
// 记录当前结果
_resultFingerprints.push({
toolName, contentHash: hash, timestamp: now, resultLength: resultContent.length,
});
if (_resultFingerprints.length > MAX_FINGERPRINTS) {
_resultFingerprints.shift();
}
return { duplicate: false };
}
// ═══════════════════════════════════════════════════════════════
// R105: 上下文年龄追踪 — 跟踪消息年龄以优化压缩决策
// ═══════════════════════════════════════════════════════════════
const _messageTimestamps = new Map<number, number>(); // message index → creation timestamp
/** R105: 记录消息创建时间 */
export function recordMessageAge(msgIndex: number): void {
_messageTimestamps.set(msgIndex, Date.now());
}
/** R105: 获取消息年龄(毫秒),-1 表示未记录 */
export function getMessageAge(msgIndex: number): number {
const ts = _messageTimestamps.get(msgIndex);
if (!ts) return -1;
return Date.now() - ts;
}
/** R105: 获取年龄超过阈值的消息索引列表 */
export function getAgedMessageIndices(thresholdMs: number): number[] {
const now = Date.now();
const result: number[] = [];
for (const [idx, ts] of _messageTimestamps) {
if (now - ts > thresholdMs) result.push(idx);
}
return result.sort((a, b) => a - b);
}
/** R105: 清理已删除消息的时间戳记录 */
export function cleanupMessageTimestamps(validIndices: Set<number>): void {
for (const key of _messageTimestamps.keys()) {
if (!validIndices.has(key)) {
_messageTimestamps.delete(key);
}
}
}
// ═══════════════════════════════════════════════════════════════
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
// ═══════════════════════════════════════════════════════════════
@@ -755,55 +423,25 @@ export interface AgentDiagnostics {
timestamp: number;
toolResultStoreSize: number;
toolCallHistoryLength: number;
rateLimitActiveTools: string[];
circuitBreakerStatus: Array<{ tool: string; broken: boolean; failures: number }>;
errorPatternCount: number;
topErrorPatterns: Array<{ tool: string; signature: string; count: number }>;
resultFingerprintCount: number;
duplicateResultCount: number;
userGoal: string;
}
/** R112: 收集 Agent 诊断信息 */
export function collectDiagnostics(): AgentDiagnostics {
const rateLimitActive: string[] = [];
const now = Date.now();
for (const [tool, timestamps] of _toolCallTimestamps) {
const valid = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW);
if (valid.length > 0) {
rateLimitActive.push(`${tool}(${valid.length}/${TOOL_RATE_LIMITS[tool] ?? RATE_LIMIT_DEFAULT})`);
}
}
const circuitBreakerStatus = Array.from(_toolFailureCount.entries()).map(([tool, count]) => ({
tool,
broken: _circuitBreakerTripped.has(tool),
failures: count,
}));
const topErrorPatterns = Array.from(_errorPatterns.values())
.sort((a, b) => b.count - a.count)
.slice(0, 5)
.map(e => ({ tool: e.toolName, signature: e.errorSignature, count: e.count }));
const duplicateCount = _resultFingerprints.filter(fp => {
// 同一工具同一指纹出现多次
return _resultFingerprints.some(other =>
other !== fp && other.toolName === fp.toolName && other.contentHash === fp.contentHash
);
}).length;
return {
timestamp: now,
toolResultStoreSize: _toolResultStore.size,
toolCallHistoryLength: _toolCallHistory.length,
rateLimitActiveTools: rateLimitActive,
circuitBreakerStatus,
errorPatternCount: _errorPatterns.size,
topErrorPatterns,
resultFingerprintCount: _resultFingerprints.length,
duplicateResultCount: duplicateCount,
userGoal: _userGoal.slice(0, 80),
};
}
@@ -814,18 +452,11 @@ export function formatDiagnosticsReport(diag: AgentDiagnostics): string {
`${'─'.repeat(50)}`,
`Tool Result Store: ${diag.toolResultStoreSize} entries`,
`Tool Call History: ${diag.toolCallHistoryLength} entries`,
`Rate Limit Active: ${diag.rateLimitActiveTools.length > 0 ? diag.rateLimitActiveTools.join(', ') : 'none'}`,
`Circuit Breakers:`,
`Error Patterns: ${diag.errorPatternCount}`,
];
for (const cb of diag.circuitBreakerStatus) {
lines.push(` ${cb.broken ? '🔴' : '🟢'} ${cb.tool}: ${cb.failures} failures${cb.broken ? ' (BROKEN)' : ''}`);
}
lines.push(`Error Patterns: ${diag.errorPatternCount}`);
for (const ep of diag.topErrorPatterns) {
lines.push(` ⚠️ ${ep.tool}: "${ep.signature}" (${ep.count}x)`);
}
lines.push(`Result Fingerprints: ${diag.resultFingerprintCount} (duplicates: ${diag.duplicateResultCount})`);
lines.push(`User Goal: "${diag.userGoal}"`);
return lines.join('\n');
}
@@ -1044,21 +675,46 @@ export function checkArchivedReferences(messages: Array<{ content?: string }>):
export function resetAllSafetyState(): void {
_toolResultStore.clear();
_toolCallHistory.length = 0;
_userGoal = '';
_toolCallTimestamps.clear();
_toolFailureCount.clear();
_circuitBreakerTripped.clear();
// R97: 清理错误模式
_errorPatterns.clear();
// R104: 清理结果指纹
_resultFingerprints.length = 0;
// R105: 清理消息时间戳
_messageTimestamps.clear();
// R118: 清理性能分析数据
_loopTimings.length = 0;
logInfo('Agent Safety: 所有安全状态已重置');
}
// ═══════════════════════════════════════════════════════════════
// P1 #3: 安全状态快照/恢复 — 供 Sub-Agent 隔离使用
// ═══════════════════════════════════════════════════════════════
export interface SafetyStateSnapshot {
toolResultStore: Map<string, { toolName: string; fullContent: string; timestamp: number }>;
toolCallHistory: string[];
errorPatterns: Map<string, ErrorPatternEntry>;
loopTimings: LoopTiming[];
}
/** P1 #3: 快照当前安全状态(供 Sub-Agent 在隔离环境中使用) */
export function snapshotSafetyState(): SafetyStateSnapshot {
return {
toolResultStore: new Map(_toolResultStore),
toolCallHistory: [..._toolCallHistory],
errorPatterns: new Map(_errorPatterns),
loopTimings: [..._loopTimings],
};
}
/** P1 #3: 从快照恢复安全状态(Sub-Agent 执行完毕后恢复主 Agent 状态) */
export function restoreSafetyState(snapshot: SafetyStateSnapshot): void {
_toolResultStore.clear();
snapshot.toolResultStore.forEach((v, k) => _toolResultStore.set(k, v));
_toolCallHistory.length = 0;
_toolCallHistory.push(...snapshot.toolCallHistory);
_errorPatterns.clear();
snapshot.errorPatterns.forEach((v, k) => _errorPatterns.set(k, v));
_loopTimings.length = 0;
_loopTimings.push(...snapshot.loopTimings);
}
// ═══════════════════════════════════════════════════════════════
// R116: 错误恢复建议增强 — 提供上下文化的错误恢复建议
// ═══════════════════════════════════════════════════════════════
@@ -1195,7 +851,7 @@ export function formatErrorRecovery(suggestion: ErrorRecoverySuggestion): string
// R118: Agent 循环性能分析 — 识别 Agent Loop 瓶颈
// ═══════════════════════════════════════════════════════════════
interface LoopTiming {
export interface LoopTiming {
loop: number;
phase: string;
durationMs: number;
@@ -1355,14 +1011,19 @@ export function inferToolDependencies(
}
// read_file/edit_file/delete_file 消费文件
if (['read_file', 'edit_file', 'delete_file'].includes(call.name) && call.arguments.path) {
const path = String(call.arguments.path);
if (['read_file', 'edit_file', 'delete_file'].includes(call.name)) {
// 支持 path 单个路径和 paths 数组
const pathsToCheck: string[] = [];
if (call.arguments.path) pathsToCheck.push(String(call.arguments.path));
if (Array.isArray(call.arguments.paths)) pathsToCheck.push(...(call.arguments.paths as unknown[]).map(p => String(p)));
for (const path of pathsToCheck) {
dep.consumes.push(path);
const producer = fileProducers.get(path);
if (producer) {
dep.dependsOn.push(producer);
}
}
}
// run_command 可能消费前面产生的文件
if (call.name === 'run_command' && call.arguments.command) {
@@ -1481,86 +1142,9 @@ export function autoTuneMemorySearch(
}
// ═══════════════════════════════════════════════════════════════
// R119: 工具执行队列优先级 — 按重要性排序工具执行
// R119 已删除:工具优先级排序强制重排可能打乱 AI 设计的执行顺序
// ═══════════════════════════════════════════════════════════════
export type ToolPriority = 'critical' | 'high' | 'normal' | 'low';
const TOOL_PRIORITY_MAP: Record<string, ToolPriority> = {
// 关键:用户直接请求的操作
write_file: 'critical',
edit_file: 'critical',
delete_file: 'high',
// 高:信息获取类
read_file: 'high',
list_directory: 'high',
search_files: 'high',
// 普通:辅助工具
run_command: 'normal',
web_search: 'normal',
web_fetch: 'normal',
git: 'normal',
// 低:非关键
memory: 'low',
datetime: 'low',
calculator: 'low',
random: 'low',
uuid: 'low',
};
/** R119: 获取工具优先级 */
export function getToolPriority(toolName: string): ToolPriority {
return TOOL_PRIORITY_MAP[toolName] || 'normal';
}
/** R119: 按优先级排序工具调用 */
export function sortToolsByPriority<T extends { name: string }>(
tools: T[]
): T[] {
const priorityOrder: Record<ToolPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3,
};
return [...tools].sort((a, b) => {
const pa = priorityOrder[getToolPriority(a.name)];
const pb = priorityOrder[getToolPriority(b.name)];
return pa - pb;
});
}
/** R119: 按优先级分批(同优先级可并行) */
export function batchToolsByPriority<T extends { name: string }>(
tools: T[]
): T[][] {
const batches: T[][] = [];
const sorted = sortToolsByPriority(tools);
let currentBatch: T[] = [];
let currentPriority: ToolPriority | null = null;
for (const tool of sorted) {
const priority = getToolPriority(tool.name);
if (currentPriority === null) {
currentPriority = priority;
}
if (priority !== currentPriority) {
if (currentBatch.length > 0) batches.push(currentBatch);
currentBatch = [];
currentPriority = priority;
}
currentBatch.push(tool);
}
if (currentBatch.length > 0) batches.push(currentBatch);
return batches;
}
// ═══════════════════════════════════════════════════════════════
// R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
// ═══════════════════════════════════════════════════════════════
-310
View File
@@ -1,310 +0,0 @@
/**
* Completion Gate
* Harness Engineering: 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: '' };
},
};
/** R83: Plan Mode 完成检查 — 验证所有计划步骤是否已标记完成 */
const planModeCompletionCheck: CompletionCheck = {
name: 'planModeCompletion',
description: 'Plan Mode 下检查所有计划步骤是否已完成',
check: async (ctx: LoopContext) => {
if (ctx.mode !== 'plan') return { passed: true, reason: '' };
// 从 state 获取 Plan Tracker
try {
const { getPlanTracker } = await import('./tool-registry.js');
const tracker = getPlanTracker();
if (!tracker.active || tracker.steps.length === 0) return { passed: true, reason: '' };
if (tracker.done < tracker.total) {
const remaining = tracker.total - tracker.done;
const undoneSteps = tracker.steps.filter(s => !s.done).map(s => s.label).slice(0, 3);
return {
passed: false,
reason: `Plan Mode: 还有 ${remaining} 步未完成(${undoneSteps.join('、')}${remaining > 3 ? '...' : ''})。请继续执行剩余步骤,或说明为什么这些步骤无法完成。`
};
}
return { passed: true, reason: '' };
} catch {
return { passed: true, reason: '' };
}
},
};
// ═══════════════════════════════════════════════════════════════
// 检查项注册与管理
// ═══════════════════════════════════════════════════════════════
/** 默认启用的检查项 */
const DEFAULT_ENABLED_CHECKS = new Set([
'contentQuality',
'toolResultReview',
'notThinking',
'planModeCompletion',
]);
/** 注册表 */
const checkRegistry = new Map<string, CompletionCheck>([
['contentQuality', contentQualityCheck],
['toolResultReview', toolResultReviewCheck],
['notThinking', notThinkingCheck],
['contextEfficiency', contextEfficiencyCheck],
['planModeCompletion', planModeCompletionCheck],
]);
/** 已启用的检查项 */
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;
/** R90: 各项检查的详细结果 */
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
/** R90/R127: 完成门控评分(0-100 */
score: number;
/** R90/R127: 评分明细 */
scoreBreakdown: Array<{ check: string; passed: boolean; points: number }>;
}
/** R127: 各检查项的权重分配 */
const CHECK_WEIGHTS: Record<string, number> = {
contentQuality: 30,
toolResultReview: 25,
notThinking: 20,
contextEfficiency: 15,
planModeCompletion: 10,
};
/**
*
* R90: 返回评分结果
*/
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
const checks = getEnabledChecks();
if (checks.length === 0) {
return { passed: true, reason: '', details: [], score: 100, scoreBreakdown: [] };
}
const details: GateResult['details'] = [];
const scoreBreakdown: GateResult['scoreBreakdown'] = [];
let totalScore = 0;
let maxScore = 0;
for (const check of checks) {
const start = Date.now();
try {
const result = await check.check(ctx);
const durationMs = Date.now() - start;
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({
name: check.name,
passed: result.passed,
reason: result.reason,
durationMs,
});
scoreBreakdown.push({
check: check.name,
passed: result.passed,
points: result.passed ? weight : 0,
});
if (result.passed) {
totalScore += weight;
} else {
logWarn(`Completion Gate: "${check.name}" — ${result.reason}`);
return {
passed: false,
reason: result.reason,
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
}
} catch (err) {
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({
name: check.name,
passed: true,
reason: `检查异常: ${(err as Error).message}`,
durationMs: Date.now() - start,
});
scoreBreakdown.push({
check: check.name,
passed: true,
points: weight,
});
totalScore += weight;
}
}
return {
passed: true,
reason: '',
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
}
/**
* R127: 生成门控报告/使
*/
export function formatGateReport(result: GateResult): string {
const status = result.passed ? '✅ 通过' : '❌ 未通过';
let report = `Completion Gate ${status} (评分: ${result.score}/100)`;
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)`;
}
// R127: 评分明细
if (result.scoreBreakdown.length > 0) {
report += `\n${'─'.repeat(40)}\n评分明细:`;
for (const s of result.scoreBreakdown) {
report += `\n ${s.passed ? '✅' : '❌'} ${s.check}: ${s.points}`;
}
}
return report;
}
+101 -47
View File
@@ -217,16 +217,20 @@ export function getTokenCalibration(): { ratio: number; samples: number } {
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
}
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */
export const AUTO_COMPRESS_THRESHOLD = 0.3;
/** token context window
* P2 #7 0.3 0.5
*/
export const AUTO_COMPRESS_THRESHOLD = 0.5;
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整 */
/** R14:
* P2 #7
*/
export function getAdaptiveCompressThreshold(numCtx: number): number {
// 小上下文模型(<8K):更早触发压缩(40%),留更多余量
// 中等上下文(8K-32K):标准阈值(30%
// 大上下文(>32K):稍晚触发(25%),避免过于频繁压缩
if (numCtx < 8192) return 0.4;
if (numCtx > 32768) return 0.25;
// 小上下文模型(<8K):更早触发压缩(55%),留余量
// 中等上下文(8K-32K):标准阈值(50%
// 大上下文(>32K):稍晚触发(45%),避免过于频繁压缩
if (numCtx < 8192) return 0.55;
if (numCtx > 32768) return 0.45;
return AUTO_COMPRESS_THRESHOLD;
}
@@ -291,19 +295,12 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
/完成|done|✓|success|成功|结果|result/i,
/项目|project|工作空间|workspace|git|repo|仓库/i,
];
// R10: 修复低价值模式误报 — 移除单字“好”(匹配几乎所有中文文本
// 仅匹配明确的短语回复,且仅对短消息(<100字)生效
const lowValuePatterns = [/^(好的|明白|ok|知道了|嗯|哦|好[的呀吧]|收到)$/i,
/^(继续|请继续|帮帮我|可以吗|行吗|好的谢谢)$/i,
/^(谢谢|感谢|不客气|多谢|thanks?)$/i,
];
// 低价值关键词黑名单已删除 — 误伤边界情况(如"好的,我发现了一个 bug"
// AI 应自行判断消息价值,长度加分机制已足够区分短回复
for (const p of highValuePatterns) {
if (p.test(content)) { score += 1; break; }
}
for (const p of lowValuePatterns) {
if (p.test(content) && content.length < 100) { score -= 2; break; }
}
// 工具调用 → 高价值
if (msg.tool_calls?.length) score += 2;
@@ -322,10 +319,6 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
if (/read_file|list_directory|tree/.test(msg.tool_name)) {
score += 1;
}
// 时间/计算类工具结果:低价值(时效性强,很快过期)
if (/datetime|random|uuid|hash/.test(msg.tool_name)) {
score -= 2;
}
}
// 长度加分:长消息通常包含更多信息
@@ -628,6 +621,30 @@ export async function compressWithLLM(
const tail = nonSystemMsgs.slice(-keepTail);
const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail);
// P1-C6 修复:head 中最早的 assistant+tool_calls 组在多次压缩后无法清除,导致 token 膨胀。
// 对 head 中已压缩过的消息(compressed=true),移除其 tool_calls 和后续 tool 消息(保留 content 作为上下文)。
// 这些旧工具调用的结果已不再需要,但 assistant 的文本内容仍有上下文价值。
if (head.length > 0) {
for (let i = 0; i < head.length; i++) {
const m = head[i];
if (m.compressed && m.tool_calls?.length) {
// 移除 tool_calls(降级为纯文本 assistant
const newMsg: OllamaMessage = { ...m };
delete newMsg.tool_calls;
head[i] = newMsg;
}
if (m.compressed && m.role === 'tool') {
// 旧的 tool 消息标记为空(保留位置但内容为空,避免破坏数组结构)
// 后续 mergeConsecutiveMessages 会合并这些空消息
head[i] = { ...m, content: '', compressed: true };
}
}
// 过滤掉 head 中被清空的 tool 消息
const filteredHead = head.filter(m => !(m.compressed && m.role === 'tool' && !m.content));
head.length = 0;
head.push(...filteredHead);
}
// R15: 对话轮次边界保护 — 调整 head/tail 切分点,避免在对话轮次中间切割
// 如果 head 末尾是带 tool_calls 的 assistant,将后续 tool 消息也纳入 head
if (head.length > 0 && head[head.length - 1].tool_calls?.length) {
@@ -647,6 +664,10 @@ export async function compressWithLLM(
if (extendBack >= 0) {
const moved = middle.splice(extendBack);
tail.unshift(...moved);
} else {
// P1-C3 修复:middle 中无 assistant 时,tail[0] 是孤立 tool 消息,
// Ollama 会拒绝或忽略。将其从 tail 移除(它无对应 assistant.tool_calls
tail.shift();
}
}
@@ -761,7 +782,8 @@ export async function compressWithLLM(
// R16: 保留已压缩的中间消息(system 消息单独处理)+ 新摘要
// R12: 标记这批消息为已压缩
markAsCompressed(uncompressedMiddle);
// R16: 已压缩的旧摘要已被合并到新摘要中,不再保留它们
// P0-C1 修复:原代码保留 nonSystemCompressed(旧摘要),但新摘要已通过 mergeSummaries 合并了旧摘要内容,
// 保留旧摘要会导致信息重复 + token 累积浪费。正确做法是不保留旧摘要(system 的已在 mergedSystemContent 中合并)
const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system');
// C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容
@@ -771,12 +793,11 @@ export async function compressWithLLM(
...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''),
].filter(Boolean).join('\n\n');
const nonSystemCompressed = alreadyCompressed.filter(m => m.role !== 'system');
// P0-C1 修复:不再保留 nonSystemCompressed,旧摘要内容已合并到 summaryMsg 中
const result: OllamaMessage[] = [
{ role: 'system', content: mergedSystemContent },
...head,
...nonSystemCompressed,
summaryMsg,
...tail
];
@@ -818,8 +839,9 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O
for (let i = 0; i < messages.length; i += batchSize) {
const batch = messages.slice(i, i + batchSize);
const summary = createQuickSummary(batch);
// 使用 user role 而非 system role — 摘要是对话历史的延续,用 system 会与系统指令语义混淆
summaries.push({
role: 'system',
role: 'user',
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`,
compressed: true
});
@@ -881,14 +903,44 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
const recentMsgs = nonSystemMsgs.slice(-PROTECT_RECENT);
const olderMsgs = nonSystemMsgs.slice(0, -PROTECT_RECENT);
// 计算每条消息的 token + 重要性
const scored = olderMsgs.map(m => ({
msg: m,
tokens: estimateTokens(m.content || '') +
(m.images ? m.images.length * 100 : 0) +
(m.tool_calls ? m.tool_calls.length * 50 : 0),
importance: scoreMessageImportance(m),
}));
// 原子组分组:assistant(带 tool_calls) + 其后续的 tool 消息作为一组
// 避免裁剪时破坏 assistant.tool_calls 与 tool 结果的配对关系
interface MsgGroup {
msgs: OllamaMessage[];
tokens: number;
importance: number;
originalIndex: number;
}
const groups: MsgGroup[] = [];
let i = 0;
while (i < olderMsgs.length) {
const msg = olderMsgs[i];
if (msg.role === 'assistant' && msg.tool_calls?.length) {
// 原子组:assistant(带 tool_calls) + 后续连续的 tool 消息
const groupMsgs: OllamaMessage[] = [msg];
let tokens = estimateTokens(msg.content || '') +
(msg.images ? msg.images.length * 100 : 0) +
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
let maxImportance = scoreMessageImportance(msg);
let j = i + 1;
while (j < olderMsgs.length && olderMsgs[j].role === 'tool') {
const toolMsg = olderMsgs[j];
groupMsgs.push(toolMsg);
tokens += estimateTokens(toolMsg.content || '');
maxImportance = Math.max(maxImportance, scoreMessageImportance(toolMsg));
j++;
}
groups.push({ msgs: groupMsgs, tokens, importance: maxImportance, originalIndex: i });
i = j;
} else {
const tokens = estimateTokens(msg.content || '') +
(msg.images ? msg.images.length * 100 : 0) +
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
groups.push({ msgs: [msg], tokens, importance: scoreMessageImportance(msg), originalIndex: i });
i++;
}
}
// system 消息的 token 消耗
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
@@ -904,27 +956,29 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
}
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
const totalOlder = olderMsgs.length;
scored.forEach((s, idx) => {
const totalGroups = groups.length;
groups.forEach((g, idx) => {
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
const recencyRatio = totalOlder > 1 ? idx / (totalOlder - 1) : 1;
s.importance += Math.round(recencyRatio * 2);
const recencyRatio = totalGroups > 1 ? idx / (totalGroups - 1) : 1;
g.importance += Math.round(recencyRatio * 2);
});
scored.sort((a, b) => b.importance - a.importance);
// 按重要性排序(降序),但保留原始索引用于重建
const sortedGroups = [...groups].sort((a, b) => b.importance - a.importance);
let usedTokens = 0;
const kept = new Set<number>(); // 保留的索引
for (let i = 0; i < scored.length; i++) {
if (usedTokens + scored[i].tokens > availableTokens && kept.size >= 2) break;
usedTokens += scored[i].tokens;
kept.add(i);
const keptOriginalIndices = new Set<number>();
for (let i = 0; i < sortedGroups.length; i++) {
if (usedTokens + sortedGroups[i].tokens > availableTokens && keptOriginalIndices.size >= 2) break;
usedTokens += sortedGroups[i].tokens;
keptOriginalIndices.add(sortedGroups[i].originalIndex);
}
// 按原始顺序重建:system → 按重要性保留的旧消息 → 最近的 protected 消息
// 按原始顺序重建:system → 按重要性保留的旧消息 → 最近的 protected 消息
const result: OllamaMessage[] = [...systemMsgs];
const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg));
for (const m of olderMsgs) {
if (keptSet.has(m)) result.push(m);
for (const g of groups) {
if (keptOriginalIndices.has(g.originalIndex)) {
result.push(...g.msgs);
}
}
result.push(...recentMsgs);
+9 -3
View File
@@ -147,15 +147,21 @@ export const securityCheckHook: HarnessHook = {
}
// 受保护路径不可删除
if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') {
const p = (toolArgs.path as string).toLowerCase();
if (toolName === 'delete_file') {
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
// 检查 path 和 paths 数组中的所有路径
const pathsToCheck: string[] = [];
if (typeof toolArgs?.path === 'string') pathsToCheck.push(toolArgs.path);
if (Array.isArray(toolArgs?.paths)) pathsToCheck.push(...(toolArgs.paths as unknown[]).map(p => String(p)));
for (const p of pathsToCheck) {
const lower = p.toLowerCase();
for (const pp of protectedPaths) {
if (p.includes(pp)) {
if (lower.includes(pp)) {
return { passed: false, message: `受保护路径不可删除: ${p}` };
}
}
}
}
return { passed: true, message: '' };
},
-7
View File
@@ -213,7 +213,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
return { valid: false, error: `文件必须以 "# METONA MEMORY" 开头,当前: "${firstLine?.slice(0, 50) || '(空)'}"`, line: 1 };
}
let entryCount = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
@@ -228,8 +227,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
continue;
}
entryCount++;
// 验证类型
const type = headerMatch[1].toLowerCase();
if (!VALID_TYPES.includes(type as MemoryType)) {
@@ -259,10 +256,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
}
}
if (entryCount === 0 && content.includes('## ')) {
// 有 ## 但没匹配到,格式可能有问题
}
return { valid: true };
}
+9 -32
View File
@@ -9,7 +9,7 @@ import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety } from './agent-safety.js';
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
@@ -100,6 +100,11 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
const subAgentAC = new AbortController();
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
// P1 #3 修复:快照主 Agent 安全状态,子代理在隔离环境中运行
const safetySnapshot = snapshotSafetyState();
// 重置安全状态,让子代理从干净状态开始(不受主 Agent 的熔断器/速率限制影响)
resetAllSafetyState();
// 监听主 Agent 的中止信号,联动中止子代理
const mainAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
const onMainAbort = () => { subAgentAC.abort(); };
@@ -171,18 +176,7 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
// 工具执行前再次检查中止信号
if (subAgentAC.signal.aborted) break;
// R89: 子 Agent 熔断器检查 — 连续失败后自动禁用工具
const circuitBreaker = isToolCircuitBroken(tc.name);
if (circuitBreaker.broken) {
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
logWarn(`R89: 子 Agent 熔断器拦截: ${tc.name},冷却中(还需 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `工具「${tc.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试。` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R89/R82 已删除:子 Agent 熔断器 + 速率限制 — 剥夺 AI 试错空间
// R109: 子 Agent 参数消毒
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
@@ -204,19 +198,6 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
}
}
// R82: 子 Agent 速率限制 — 防止子代理快速连续调用同一工具
const rateLimit = checkRateLimit(tc.name);
if (!rateLimit.allowed) {
const waitSec = Math.ceil(rateLimit.retryAfterMs / 1000);
logWarn(`R82: 子 Agent 速率限制: ${tc.name},等待 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `调用频率过高,等待 ${waitSec}s` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
if (SUB_FILE_TOOLS.has(tc.name)) {
@@ -241,12 +222,6 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
try {
const { executeTool } = await import('./tool-registry.js');
const result = await executeTool(tc.name, tc.arguments);
// R89: 记录工具成功/失败到熔断器
if (result.success) {
recordToolSuccess(tc.name);
} else {
recordToolFailure(tc.name);
}
const resultStr = formatResult(tc.name, result);
messages.push({
role: 'tool',
@@ -271,6 +246,8 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
if (mainAC) {
mainAC.signal.removeEventListener('abort', onMainAbort);
}
// P1 #3 修复:恢复主 Agent 安全状态
restoreSafetyState(safetySnapshot);
}
logWarn('子 Agent 达到最大轮次', `${loopCount}`);
+286 -140
View File
@@ -59,6 +59,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' },
filter_extension: { type: 'string', description: 'Filter files by extension (e.g. ".txt"). Only files matching this extension are returned. Default: no filter.' },
offset: { type: 'integer', description: 'Skip first N entries for pagination. Default: 0.' }
}
}
@@ -77,7 +78,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
query: { type: 'string', description: 'Search query (glob, text, or regex).' },
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
max_results: { type: 'integer', description: 'Max results. Default: 50.' },
max_results: { type: 'integer', description: 'Max results. Default: 0 (no limit).' },
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' },
use_regex: { type: 'boolean', description: 'Treat query as regular expression. Default: false.' }
}
@@ -102,12 +103,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'delete_file',
description: 'Delete a file or directory. Returns deleted item count and total size. Use recursive=true for non-empty directories.',
description: 'Delete a file or directory. Supports batch deletion via paths parameter. At least one of path or paths must be provided. Returns deleted item count and total size. Use recursive=true for non-empty directories.',
parameters: {
type: 'object',
required: ['path'],
properties: {
path: { type: 'string', description: 'Path to delete.' },
path: { type: 'string', description: 'Single path to delete. Use this for deleting one file/directory.' },
paths: { type: 'array', items: { type: 'string' }, description: 'Multiple paths to delete in batch. Use this when deleting multiple files at once.' },
recursive: { type: 'boolean', description: 'Recursive delete for directories. Returns filesDeleted count and total deletedSize.' }
}
}
@@ -117,7 +118,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'run_command',
description: 'Execute a shell command via workspace. No timeout limit — runs until the process exits. Requires user confirmation. Output is streamed in real-time through the workspace process.',
description: 'Execute a shell command via workspace. Timeout configurable (default 300s for general, 600s for long-running). cwd defaults to workspace directory. Requires user confirmation. Output is streamed in real-time through the workspace process.',
parameters: {
type: 'object',
required: ['command'],
@@ -195,20 +196,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
},
{
type: 'function',
function: {
name: 'get_file_info',
description: 'Get detailed file or directory information: size, dates, permissions, type.',
parameters: {
type: 'object',
required: ['path'],
properties: {
path: { type: 'string', description: 'File or directory path.' }
}
}
}
},
{
type: 'function',
function: {
@@ -219,7 +206,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
required: ['path'],
properties: {
path: { type: 'string', description: 'Root directory path.' },
max_depth: { type: 'integer', description: 'Max depth. Default: 3.' },
max_depth: { type: 'integer', description: 'Max depth. Default: 5.' },
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' }
}
}
@@ -240,39 +227,6 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
},
{
type: 'function',
function: {
name: 'diff_files',
description: 'Compare two files and show differences. Returns a unified diff.',
parameters: {
type: 'object',
required: ['file1', 'file2'],
properties: {
file1: { type: 'string', description: 'First file path.' },
file2: { type: 'string', description: 'Second file path.' },
context_lines: { type: 'integer', description: 'Context lines around changes. Default: 3.' }
}
}
}
},
{
type: 'function',
function: {
name: 'replace_in_files',
description: 'Find and replace text across multiple files matching a glob pattern. Requires user confirmation.',
parameters: {
type: 'object',
required: ['path', 'glob', 'old_text', 'new_text'],
properties: {
path: { type: 'string', description: 'Root directory.' },
glob: { type: 'string', description: 'File pattern, e.g. "*.ts", "**/*.js".' },
old_text: { type: 'string', description: 'Text to find.' },
new_text: { type: 'string', description: 'Replacement text.' }
}
}
}
},
{
type: 'function',
function: {
@@ -589,17 +543,269 @@ TIP: Use remove_batch when deleting multiple entries — it's much more efficien
{
type: 'function',
function: {
name: 'datetime',
description: 'Get the precise current system time. Returns ISO timestamp, Unix time (seconds and milliseconds), locale-formatted date/time, timezone, and individual components (year/month/day/hour/minute/second/millisecond/day_of_week). Use format parameter to request specific output: "iso" / "unix" / "date" / "time" / "full" (default: full).',
name: 'calculator',
description: 'Safely evaluate a mathematical expression. Supports + - * / ** % () and floating-point numbers. Uses a pure JS recursive descent parser — no eval(), CSP-safe. Returns the numeric result.',
parameters: {
type: 'object',
required: ['expression'],
properties: {
expression: { type: 'string', description: 'The mathematical expression to evaluate (e.g., "(3 + 5) * 2 ** 3"). Max 500 characters.' }
}
}
}
},
{
type: 'function',
function: {
name: 'web_search',
description: 'Search the web with auto-fetch. Always use this when the user asks for information, news, facts, or current data. Never rely on training data or past conversation results — call this tool every time the user asks to search. Set fetch_top to auto-fetch full page content from the top N results.',
parameters: {
type: 'object',
required: ['query', 'fetch_top'],
properties: {
query: { type: 'string', description: 'The search query. Be specific and concise for best results.' },
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 30, max: 30.' },
time_range: { type: 'string', enum: ['day', 'week', 'month', 'year'], description: 'Filter results by time. Supported by Bing and Google. Use for recent news, latest version, etc.' },
enhance_snippets: { type: 'boolean', description: 'Auto-fetch detailed snippet for results with too-short descriptions. Default: true.' },
fetch_top: { type: 'integer', description: 'Number of top results to auto-fetch full content for. Min: 3. Results shorter than this are useless — snippets lack detail.' }
}
}
}
},
// ══════════════════════════════════════════════
// 记忆工具(统一入口,读写工作空间 MEMORY.md)
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'memory',
description: `Manage agent memories stored in MEMORY.md. Usage by action:
- search: {"action":"search","query":"keywords","limit":8}
- add: {"action":"add","type":"fact","content":"the memory content","importance":8,"tags":["tag1","tag2"]} type and content are REQUIRED for add!
- replace: {"action":"replace","old_text":"unique substring","new_content":"replacement"}
- remove: {"action":"remove","old_text":"unique substring"}
- remove_batch: {"action":"remove_batch","old_texts":["substring1","substring2","substring3"]} Batch delete multiple entries in one call
- read_all: {"action":"read_all"}
CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) and "content" fields. If you omit them, the call will fail.
TIP: Use remove_batch when deleting multiple entries it's much more efficient than calling remove repeatedly.`,
parameters: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['search', 'add', 'replace', 'remove', 'remove_batch', 'read_all'], description: 'Which operation to perform. Note: "add" requires "type"+"content", "replace" requires "old_text"+"new_content", "remove" requires "old_text", "remove_batch" requires "old_texts" (array), "search" requires "query".' },
query: { type: 'string', description: '[search REQUIRED] Keywords to search for.' },
limit: { type: 'integer', description: '[search] Max results. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: '[add REQUIRED] Memory type. fact=user info, preference=user preference, rule=behavior rule.' },
content: { type: 'string', description: '[add REQUIRED, replace] Memory text. Keep it concise (8-100 chars).' },
importance: { type: 'integer', description: '[add] Priority 1-10. Default: 5. Use 8+ for critical info.' },
tags: { type: 'array', items: { type: 'string' }, description: '[add] 2-5 keywords for search matching.' },
old_text: { type: 'string', description: '[replace, remove REQUIRED] Unique substring to identify the entry. Must be ≥5 chars. Use exact text from read_all for best results.' },
old_texts: { type: 'array', items: { type: 'string' }, description: '[remove_batch REQUIRED] Array of substrings to identify multiple entries for batch deletion. Each must be ≥5 chars.' },
new_content: { type: 'string', description: '[replace REQUIRED] New text to replace with.' }
}
}
}
},
// ══════════════════════════════════════════════
// 会话管理工具
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'session_list',
description: 'List previous chat sessions with their titles and timestamps. Useful for referencing past conversations.',
parameters: {
type: 'object',
properties: {
format: { type: 'string', enum: ['full', 'iso', 'unix', 'date', 'time'], description: 'Output format. Default: full (all fields).' },
timezone: { type: 'string', description: 'IANA timezone name (e.g., "Asia/Shanghai", "America/New_York"). Default: system timezone.' }
limit: { type: 'integer', description: 'Max sessions to return. Default: 20.' },
search: { type: 'string', description: 'Filter sessions by title keyword.' }
}
}
}
},
{
type: 'function',
function: {
name: 'session_read',
description: 'Read the messages from a previous chat session. Use when the user references something from a past conversation.',
parameters: {
type: 'object',
required: ['session_id'],
properties: {
session_id: { type: 'string', description: 'The session ID to read.' },
max_messages: { type: 'integer', description: 'Max messages to return. Default: 50.' }
}
}
}
},
// ══════════════════════════════════════════════
// v4.3 新增工具:子代理委派
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'spawn_task',
description: 'Spawn a sub-agent to independently execute a task using read-only tools (file reading, web search, browser viewing, memory/session queries). Use this to parallelize independent research or analysis sub-tasks. The model is configured in Settings and cannot be overridden per call.',
parameters: {
type: 'object',
required: ['task'],
properties: {
task: { type: 'string', description: 'The task description for the sub-agent to execute.' },
context: { type: 'string', description: 'Optional additional context or reference data for the sub-agent.' }
}
}
}
},
// ══════════════════════════════════════════════
// 新增工具: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_undone', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_undone=撤销步骤完成标记, 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.1 Browser 控制工具(增强版)
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'browser_open',
description: 'Open a URL in the agent browser. Waits for page load. Use wait_selector to wait for a specific element (useful for SPA pages). Returns page title and URL.',
parameters: {
type: 'object',
required: ['url'],
properties: {
url: { type: 'string', description: 'The URL to open (http/https).' },
wait_selector: { type: 'string', description: 'CSS selector to wait for after page load. Useful for SPA/dynamic pages. Default: none.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_screenshot',
description: 'Take a screenshot of the current page. Supports viewport (default), full_page (entire scrollable page), or selector (specific element only). Returns base64 PNG.',
parameters: {
type: 'object',
properties: {
full_page: { type: 'boolean', description: 'Capture the entire scrollable page. Default: false.' },
selector: { type: 'string', description: 'CSS selector of element to capture. Overrides full_page.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_evaluate',
description: 'Execute JavaScript in the browser page. Returns the result as JSON string. Use to read DOM, extract data, or interact programmatically.',
parameters: {
type: 'object',
required: ['js'],
properties: {
js: { type: 'string', description: 'JavaScript code to execute in the page context.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_extract',
description: 'Extract text and links from the current page. Use selector to extract only from a specific element (e.g. "main", "#content", "article"). Without selector, extracts full page. Returns title, text, and up to 50 links.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to extract from. Default: entire body.' },
max_chars: { type: 'integer', description: 'Max characters to return. Default: 15000.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_click',
description: 'Click an element by CSS selector. Use wait=true to wait up to 10s for the element to appear first (useful after page navigation). Scrolls element into view before clicking.',
parameters: {
type: 'object',
required: ['selector'],
properties: {
selector: { type: 'string', description: 'CSS selector for the element to click.' },
wait: { type: 'boolean', description: 'Wait for element to appear before clicking (max 10s). Default: false.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_type',
description: 'Type text into an input field. Use clear=false to append instead of replacing. Use submit=true to press Enter or submit the parent form after typing.',
parameters: {
type: 'object',
required: ['selector', 'text'],
properties: {
selector: { type: 'string', description: 'CSS selector for the input element.' },
text: { type: 'string', description: 'Text to type into the input.' },
clear: { type: 'boolean', description: 'Clear existing text first. Default: true.' },
submit: { type: 'boolean', description: 'Submit the form or press Enter after typing. Default: false.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_scroll',
description: 'Scroll the page. Use direction for up/down/top/bottom, or selector to scroll a specific element into view.',
parameters: {
type: 'object',
properties: {
direction: { type: 'string', enum: ['down', 'up', 'top', 'bottom'], description: 'Scroll direction. Default: down.' },
selector: { type: 'string', description: 'CSS selector to scroll into view (overrides direction).' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_wait',
description: 'Wait for a condition. Use selector to wait for an element to appear, or time_ms for a fixed delay. Default: 1 second.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to wait for. Returns found: true/false.' },
time_ms: { type: 'integer', description: 'Fixed wait time in milliseconds. Default: 1000.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_close',
description: 'Close the agent browser and free resources.',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
@@ -613,68 +819,6 @@ TIP: Use remove_batch when deleting multiple entries — it's much more efficien
}
}
}
},
{
type: 'function',
function: {
name: 'random',
description: 'Generate random numbers or pick random items. Supports: int (integer range, default 0-100), float (decimal, default 0-1), pick (select from array of items), string (random alphanumeric, default length 8). Use count for multiple values.',
parameters: {
type: 'object',
properties: {
type: { type: 'string', enum: ['int', 'float', 'pick', 'string'], description: 'Random type. Default: int.' },
min: { type: 'number', description: 'Minimum value (int/float). Default: 0.' },
max: { type: 'number', description: 'Maximum value (int/float). Default: 100 (int) or 1 (float).' },
count: { type: 'integer', description: 'Number of results. Max 100. Default: 1.' },
items: { type: 'array', items: { type: 'string' }, description: 'Item pool for pick type.' },
length: { type: 'integer', description: 'String length for string type. Max 256. Default: 8.' }
}
}
}
},
{
type: 'function',
function: {
name: 'uuid',
description: 'Generate cryptographically random UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000"). Uses Node.js crypto.randomUUID(). Supports batch generation up to 20.',
parameters: {
type: 'object',
properties: {
count: { type: 'integer', description: 'Number of UUIDs. Max 20. Default: 1.' }
}
}
}
},
{
type: 'function',
function: {
name: 'json_format',
description: 'Format and validate a JSON string. Returns pretty-printed JSON with configurable indentation. Optionally sort object keys alphabetically. Also works as a JSON syntax validator — returns error details on invalid input.',
parameters: {
type: 'object',
required: ['json'],
properties: {
json: { type: 'string', description: 'The JSON string to format/validate.' },
indent: { type: 'integer', description: 'Indentation spaces. Default: 2.' },
sort_keys: { type: 'boolean', description: 'Sort object keys alphabetically. Default: false.' }
}
}
}
},
{
type: 'function',
function: {
name: 'hash',
description: 'Compute cryptographic hash of text. Supports MD5, SHA-1, SHA-256, SHA-384, SHA-512. Default: SHA-256. Returns hex-encoded digest.',
parameters: {
type: 'object',
required: ['text'],
properties: {
text: { type: 'string', description: 'The text to hash.' },
algorithm: { type: 'string', enum: ['md5', 'sha1', 'sha256', 'sha384', 'sha512'], description: 'Hash algorithm. Default: sha256.' }
}
}
}
}
];
@@ -683,7 +827,7 @@ TIP: Use remove_batch when deleting multiple entries — it's much more efficien
const MODE_TOOLS = [
'run_command',
'write_file', 'create_directory', 'delete_file',
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
'edit_file', 'move_file', 'copy_file',
'download_file', 'compress',
];
@@ -717,19 +861,25 @@ export function setRunCommandMode(mode: ToolMode): void {
setToolMode('run_command', mode);
}
/** 初始化全局工具模式(从数据库加载) */
export function initGlobalToolMode(mode: ToolMode): void {
for (const toolName of MODE_TOOLS) {
setToolMode(toolName, mode);
}
}
let enabledTools: Set<string> = new Set([
'read_file', 'list_directory', 'search_files',
'write_file', 'create_directory', 'delete_file',
'run_command',
'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'tree', 'download_file',
'read_multiple_files', 'git', 'compress',
'memory',
'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash'
'calculator'
]);
export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -776,11 +926,8 @@ const TOOL_KEYWORDS: Record<string, string[]> = {
web_fetch: ['fetch', 'url', '网页', '抓取', '获取'],
web_search: ['search', 'web', '搜索', '联网', '查', 'google', 'bing'],
edit_file: ['edit', 'replace', '编辑', '替换', '修改'],
get_file_info: ['info', 'stat', '信息', '属性', '详情'],
tree: ['tree', '结构', '树', '目录结构'],
download_file: ['download', '下载'],
diff_files: ['diff', 'compare', '对比', '差异'],
replace_in_files: ['replace', 'batch', '批量替换'],
read_multiple_files: ['read', 'multiple', '批量读取'],
git: ['git', 'commit', 'push', 'pull', 'branch', '仓库'],
compress: ['compress', 'zip', 'tar', '压缩', '解压', 'extract'],
@@ -798,12 +945,7 @@ const TOOL_KEYWORDS: Record<string, string[]> = {
browser_scroll: ['scroll', '滚动'],
browser_wait: ['wait', '等待'],
browser_close: ['close', '关闭浏览器'],
datetime: ['time', 'date', '时间', '日期'],
calculator: ['calculate', 'math', '计算', '算'],
random: ['random', '随机'],
uuid: ['uuid', 'guid', '唯一'],
json_format: ['json', 'format', '格式化'],
hash: ['hash', 'sha', 'md5', '哈希'],
};
/** R55: 根据用户查询语义过滤工具定义,减少 token 占用 */
@@ -885,7 +1027,10 @@ function hasPathTraversal(path: string): boolean {
return false;
}
/** R28: 命令注入检测 — 检查命令中是否包含危险的 shell 注入模式 */
/** R28: 危险命令关键词 — 命令替换中包含这些词才拦截 */
const DANGEROUS_CMD_KEYWORDS = 'rm|del|format|mkfs|dd|fdisk|shred|umount|chmod\\s+777|chown|shutdown|reboot|halt';
/** R28: 命令注入检测 — 仅拦截命令替换中包含危险命令的情况,避免误伤 $(date) 等合法用法 */
function hasCommandInjection(command: string): boolean {
if (!command) return false;
const dangerousPatterns = [
@@ -893,8 +1038,9 @@ function hasCommandInjection(command: string): boolean {
/\|\s*(rm|del|format|mkfs)\s/i, // 管道到删除操作
/&&\s*(rm|del|format|mkfs)\s/i, // AND链中的删除操作
/\$\{.*IFS.*\}/i, // IFS 变量注入
/\$\([^)]*\)/, // 命令替换 $(...)
/`[^`]*`/, // 反引号命令替换
// 仅拦截命令替换中包含危险命令的情况,放行 $(date) 等合法用法
new RegExp(`\\$\\([^)]*\\b(?:${DANGEROUS_CMD_KEYWORDS})\\b[^)]*\\)`, 'i'),
new RegExp('`[^`]*\\b(?:' + DANGEROUS_CMD_KEYWORDS + ')\\b[^`]*`', 'i'),
/\x00/, // null 字节注入
];
return dangerousPatterns.some(p => p.test(command));
@@ -1599,8 +1745,8 @@ export function getToolIcon(name: string): string {
read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
move_file: '📦', copy_file: '📋', web_fetch: '🌐',
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
edit_file: '✂️', tree: '🌳', download_file: '⬇️',
read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory: '🧠',
session_list: '📋', session_read: '📖', spawn_task: '🤖',
@@ -1608,7 +1754,7 @@ export function getToolIcon(name: string): string {
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌',
browser_wait: '⏳',
datetime: '🕐', calculator: '🔢', random: '🎲', uuid: '🔑', json_format: '📝', hash: '#️⃣'
calculator: '🔢'
};
return icons[name] || '🔧';
}
@@ -1679,8 +1825,8 @@ export function formatToolName(name: string): string {
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件',
run_command: '执行命令', move_file: '移动文件', copy_file: '复制文件',
web_fetch: '网页抓取', edit_file: '编辑文件',
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
tree: '目录树', download_file: '下载文件',
read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory: '记忆管理',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
@@ -1688,7 +1834,7 @@ export function formatToolName(name: string): string {
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器',
browser_wait: '等待',
datetime: '日期时间', calculator: '计算器', random: '随机数', uuid: '生成UUID', json_format: 'JSON格式化', hash: '哈希计算'
calculator: '计算器'
};
return names[name] || name;
}
+1 -2
View File
@@ -111,8 +111,7 @@ class AppState {
private _notify(key: string, value: unknown, old: unknown): void {
if (!this._listeners.has(key)) return;
for (const cb of this._listeners.get(key)!) {
try { cb(value, old); } catch (e) {
}
try { cb(value, old); } catch { /* 回调异常不影响其他监听器 */ }
}
}
+6 -11
View File
@@ -97,6 +97,7 @@ export interface ChatFile {
}
export interface FileContent {
name: string;
language: string;
content: string;
}
@@ -287,9 +288,7 @@ export type StateKey =
| 'loopWatchdogMs'
| '_planTracker'
| '_planResumeData'
| '_lastPlanStatus'
| '_planLastInjectedDone'
| 'toolModes'
| 'globalToolMode'
| 'planAutoConfirm';
// ═══════════════════════════════════════════════════════════
@@ -410,6 +409,10 @@ export interface LoopContext {
prompt_eval_count?: number;
total_duration?: number;
};
/** R8 紧急压缩重试计数(防止溢出→压缩→重试无限循环) */
emergencyCompressCount: number;
/** 上下文压缩标记 — 防止同一轮重复触发压缩 */
compressedThisCycle: boolean;
}
/** Agent 运行模式 */
@@ -454,19 +457,11 @@ export interface HookData {
[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;
+4 -3
View File
@@ -42,9 +42,10 @@ export function formatSize(bytes: number): string {
return `${size.toFixed(1)} ${units[i]}`;
}
/** HTML 转义 */
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
/** HTML 转义(兼容 unknown 输入,null/undefined 返回空串) */
export function escapeHtml(str: unknown): string {
if (str == null) return '';
return String(str).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };