v0.16.19: 修复日志面板清空、URL误传文件工具、网络工具超时、结果截断

This commit is contained in:
2026-08-02 19:09:29 +08:00
parent e140e735de
commit e4f83bd09a
13 changed files with 295 additions and 196 deletions
+108 -108
View File
@@ -5,7 +5,7 @@
<h1 align="center">Metona Ollama Desktop</h1>
<p align="center">
<strong>🤖 本地 AI 桌é¢å®¢æˆ·ç«?· Local AI Desktop Client</strong>
<strong>🤖 本地 AI 桌面客户?· Local AI Desktop Client</strong>
</p>
<p align="center">
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.16.18-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.16.19-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">
@@ -22,65 +22,65 @@
</p>
<p align="center">
<em>所æœ?AI 推ç†åœ¨æœ¬åœ°å®Œæˆï¼Œæ•°æ®ä¸ç¦»å¼€æœ¬æœºã€?/em><br>
<em>All AI inference runs locally �data never leaves your machine.</em>
<em>?AI 推理在本地完成,数据不离开本机?/em><br>
<em>All AI inference runs locally ?data never leaves your machine.</em>
</p>
---
# 🇨🇳 中文
## �特�
## ?特?
| | 功能 | 说明 |
|:---:|:---|:---|
| 🤖 | **ReAct Agent Loop** | 始终开å¯çš„å”¯ä¸€å¯¹è¯æ¨¡å¼ã€? çŠ¶æ€æœºï¼ˆINIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最å¤?85 轮(å¯é…置),智能é‡è¯•(永久错误立å³è¿”回ã€çž¬æ€é”™è¯¯æŒ‡æ•°é€€é¿ï¼‰ï¼Œå·¥å…·åŽ»é‡ï¼Œæ™ºèƒ½è·¯å¾„ä¾èµ–ä¸²è¡ŒåŒ–ï¼Œçœ‹é—¨ç‹—è¶…æ—¶ä¿æŠ?|
| 🛡ï¸?| **æç¤ºè¯åŠ å›?* | å‚è€ƒæ•°æ®æ ‡è®°ï¼ˆ<<<REFERENCE_DATA_START>>>ï¼? 工具结果仅为数æ®éžæŒ‡ä»¤çš„安全规则注入 |
| 📋 | **Plan Mode** | 开关切æ¢ï¼ŒAI é¦–å…ˆç”Ÿæˆæ‰§è¡Œè®¡åˆ’(Markdown æ¸²æŸ“ç¡®è®¤å¼¹çª—ï¼‰ï¼Œæ‰¹å‡†åŽæŒ‰æ­¥éª¤è¿½è¸ªæ‰§è¡Œï¼Œplan_track 工具标记完æˆçжæ€ï¼Œ**æ”¯æŒæ–­ç‚¹ç»­ä¼ **(中止åŽå¯æ¢å¤æœªå®Œæˆè®¡åˆ’ï¼?|
| 🔧 | **32 个内置工å…?* | 文件系统ï¼?3个) · 命令执行 · è”网æœç´¢ · æµè§ˆå™¨æŽ§åˆ¶ï¼ˆ9个) · Git · 记忆 · ä¼šè¯ Â· å­ä»£ç?· 系统工具 · Plan Mode 追踪 |
| 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空é—?MEMORY.md 文件,å—è·¯å¾„ä¿æŠ¤ä»?memory 工具å¯è®¿é—®ï¼Œå†™å…¥å‰å®‰å…¨æ‰«æï¼Œå®¹é‡ 500 æ¡ï¼Œå¯¹è¯ç»“æŸè‡ªåЍæå–ï¼Œä¸¥æ ¼æ ¼å¼æ ¡éª?|
| 📋 | **自定义文ä»?* | SOUL.md(人格,ä¸å¯åŽ‹ç¼©ï¼? AGENT.md(行为准则,内置 fallbackï¼? USER.md(用户画åƒï¼Œä»…工作空间读å–,ä¸å­˜åœ¨åˆ™ä¸æ³¨å…¥ï¼‰ |
| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式? 状态机(INITTHINKINGPARSINGEXECUTINGOBSERVINGREFLECTINGCOMPRESSINGTERMINATED),最?85 轮(可配置),智能重试(永久错误立即返回、瞬态错误指数退避),工具去重,智能路径依赖串行化,看门狗超时保?|
| 🛡?| **提示词加?* | 参考数据标记(<<<REFERENCE_DATA_START>>>? 工具结果仅为数据非指令的安全规则注入 |
| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态,**支持断点续传**(中止后可恢复未完成计划?|
| 🔧 | **32 个内置工?* | 文件系统?3个) · 命令执行 · 联网搜索 · 浏览器控制(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 防护 |
| 🔠| **è”网æœç´¢ï¼ˆåŒæ¨¡å¼ï¼?* | SearXNG å…ƒæœç´¢å¼•æ“?JSON APIï¼?0+引擎èšåˆï¼? 四引æ“?HTML è§£æžï¼ˆBing+百度+æœç‹—+360ï¼‰ï¼ŒåŒæ¨¡å¼å¯åˆ‡æ¢ï¼›web_fetch 支æŒå爬+UA切æ¢+æµè§ˆå™¨å›žé€€ |
| 🌠| **æµè§ˆå™¨æŽ§åˆ?* | 打开网页 · 截图 · 执行 JS · æå–内容 · 点击 · 输入 · 滚动 · 关闭 |
| 🖥ï¸?| **å·¥ä½œç©ºé—´é¢æ¿** | ç»ˆç«¯ï¼ˆå¢žé‡æµå¼è¾“出)+ 文件æµè§ˆå™¨ï¼Œå‘½ä»¤å®‰å…¨æ£€æŸ?|
| 🔢 | **上下文长度手动控åˆ?* | è®¾ç½®é¢æ¿ä¸‹æ‹‰é€‰æ‹©ï¼?28K / 256K / 512K / 1M),默认 128Kï¼Œæ¨¡åž‹æ æ˜¾ç¤ºå½“å‰é…置值,下拉框中显示æ¯ä¸ªæ¨¡åž‹è‡ªèº«çš„上下文长度 |
| 🗜ï¸?| **智能上下文管ç?* | æ»‘åŠ¨çª—å£ + Token 自动校准 + 消æ¯é‡è¦æ€§è¯„åˆ?+ LLM 结构åŒ?JSON 压缩,智能触å‘(120 æ¡å¢žé‡åŽ‹ç¼?+ 300 æ¡ç¡¬ä¸Šé™ï¼?|
| â±ï¸ | **æ™ºèƒ½è¶…æ—¶ä¿æŠ¤** | Agent Loop 看门狗(å¯é…,默è®?30minï¼? æµå¼æ€»è¶…时(å¯é…,默è®?300sï¼? 工具 HTTP/MCP è¶…æ—¶å¯é… |
| 🪠| **Hook 系统** | 4 阶段生命周期钩å­ï¼ˆpre_tool / post_tool / post_iteration / pre_completionï¼‰ï¼Œå†…ç½®å®‰å…¨æ£€æŸ¥ã€æ–‡ä»¶åŽ»é‡ã€è‡ªåŠ¨è®¡åˆ’è¿½è¸ªã€å˜æ›´å®¡è®¡ã€ç»“果校éª?|
| 👥 | **å­ä»£ç†å§”æ´?* | spawn_task 工具,独立上下文 + è¶…æ—¶ä¿æŠ¤ |
| 📈 | **Token 仪表ç›?* | 全局 + 会è¯ç»Ÿè®¡ï¼Œæ¶ˆè€—趋势柱状图ï¼? 秒刷新,输入/输出分色 |
| 📋 | **系统æç¤ºè¯å¡ç‰?* | æ¯æ¡ AI 回å¤é¡¶éƒ¨æŠ˜å å¡ç‰‡ï¼Œç‚¹å‡»æŸ¥çœ‹å®žé™…å‘é€ç»™æ¨¡åž‹çš„完整上下文 |
| 🎨 | **暖色è°?UI** | 奶白 `#FAF7F2` + çŠç‘šæ©?`#E8734A`,长时间使用ä¸ç–²åŠ³ï¼Œæµå¼æ¸²æŸ“优化 |
| 🔍 | **联网搜索(双模式?* | SearXNG 元搜索引?JSON API?0+引擎聚合? 四引?HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 |
| 🌏 | **浏览器控?* | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 |
| 🖥?| **工作空间面板** | 终端(增量流式输出)+ 文件浏览器,命令安全检?|
| 🔢 | **上下文长度手动控?* | 设置面板下拉选择?28K / 256K / 512K / 1M),默认 128K,模型栏显示当前配置值,下拉框中显示每个模型自身的上下文长度 |
| 🗜?| **智能上下文管?* | 滑动窗口 + Token 自动校准 + 消息重要性评?+ LLM 结构?JSON 压缩,智能触发(120 条增量压?+ 300 条硬上限?|
| ⏱️ | **智能超时保护** | Agent Loop 看门狗(可配,默?30min? 流式总超时(可配,默?300s? 工具 HTTP/MCP 超时可配 |
| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置安全检查、文件去重、自动计划追踪、变更审计、结果校?|
| 👥 | **子代理委?* | spawn_task 工具,独立上下文 + 超时保护 |
| 📈 | **Token 仪表?* | 全局 + 会话统计,消耗趋势柱状图? 秒刷新,输入/输出分色 |
| 📋 | **系统提示词卡?* | 每条 AI 回复顶部折叠卡片,点击查看实际发送给模型的完整上下文 |
| 🎨 | **暖色?UI** | 奶白 `#FAF7F2` + 珊瑚?`#E8734A`,长时间使用不疲劳,流式渲染优化 |
| 🔔 | **系统托盘** | 原生托盘集成 |
| 🔐 | **AES-256-GCM** | 数据加密支持 |
| 🧪 | **å¯è§‚测æ€?* | 执行轨迹缓冲批é‡å†™å…¥ SQLite + Agent Metrics JSON/Prometheus åŒæ ¼å¼å¯¼å‡?|
| 🧪 | **可观测?* | 执行轨迹缓冲批量写入 SQLite + Agent Metrics JSON/Prometheus 双格式导?|
## 🔧 工具清单
<details>
<summary><strong>📠文件系统�3 个)</strong></summary>
<summary><strong>📁 文件系统?3 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `read_file` | è¯»å–æ–‡ä»¶ï¼ˆæ–‡æœ?binaryï¼?000行默认) |
| `read_file` | 读取文件(文?binary?000行默认) |
| `write_file` | 写入/追加文件(支持base64二进制) |
| `list_directory` | 列出目录内容 |
| `search_files` | æœç´¢æ–‡ä»¶ï¼ˆæ­£åˆ?通é…符) |
| `search_files` | 搜索文件(正?通配符) |
| `create_directory` | 创建目录 |
| `delete_file` | 删除文件/目录(返回大小) |
| `move_file` | 移动/é‡å‘½åæ–‡ä»?|
| `move_file` | 移动/重命名文?|
| `copy_file` | 复制文件 |
| `edit_file` | 编辑文件(支持正则替换) |
| `tree` | 目录树结构(默认5层深度) |
| `download_file` | 下载文件 |
| `read_multiple_files` | 批é‡è¯»å–ï¼?0文件/10KBï¼?|
| `read_multiple_files` | 批量读取?0文件/10KB?|
| `compress` | 压缩文件/目录 |
</details>
<details>
<summary><strong>�命令执行� 个)</strong></summary>
<summary><strong>?命令执行? 个)</strong></summary>
| 工具 | 功能 |
|------|------|
@@ -89,11 +89,11 @@
</details>
<details>
<summary><strong>🔠è”网æœç´¢ï¼? 个)</strong></summary>
<summary><strong>🔍 联网搜索? 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `web_search` | SearXNG JSON API / 四引擎并行,默认 30 æ¡ç»“æž?|
| `web_search` | SearXNG JSON API / 四引擎并行,默认 30 条结?|
| `web_fetch` | 网页内容抓取,自动重试(指数退避),移动端UA切换,SPA页面自动升级到浏览器渲染 |
</details>
@@ -111,27 +111,27 @@
| `browser_type` | 输入文本 |
| `browser_scroll` | 页面滚动 |
| `browser_wait` | 等待元素/延时 |
| `browser_close` | 关闭æµè§ˆå™?|
| `browser_close` | 关闭浏览?|
</details>
<details>
<summary><strong>📦 Gitï¼? 个,17 个å­å‘½ä»¤ï¼?/strong></summary>
<summary><strong>📦 Git? 个,17 个子命令?/strong></summary>
| å­å‘½ä»?| 功能 |
| 子命?| 功能 |
|--------|------|
| `init` `clone` `add` `commit` `push` `pull` | 基础操作 |
| `diff` `log` `status` `branch` `checkout` | 查看与切æ?|
| `diff` `log` `status` `branch` `checkout` | 查看与切?|
| `merge` `stash` `reset` `tag` `remote` | 高级操作 |
</details>
<details>
<summary><strong>🧠 记忆管ç†ï¼? 个)</strong></summary>
<summary><strong>🧠 记忆管理? 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `memory` | 统一记忆管ç†ï¼? ä¸?action:search æœç´¢ / add 添加 / replace æ›¿æ¢ / remove 删除 / read_all 读å–全部)。工作空é—?MEMORY.md å—è·¯å¾„ä¿æŠ¤ï¼Œä»…æ­¤å·¥å…·å¯è®¿é—?|
| `memory` | 统一记忆管理? ?actionsearch 搜索 / add 添加 / replace 替换 / remove 删除 / read_all 读取全部)。工作空?MEMORY.md 受路径保护,仅此工具可访?|
</details>
@@ -142,21 +142,21 @@
|------|------|
| `session_list` | 列出历史会话 |
| `session_read` | 读取会话内容 |
| `spawn_task` | 委派å­ä»£ç†ï¼ˆç‹¬ç«‹ä¸Šä¸‹æ–?+ è¶…æ—¶ä¿æŠ¤ï¼?|
| `spawn_task` | 委派子代理(独立上下?+ 超时保护?|
</details>
<details>
<summary><strong>🕠系统工具� 个)</strong></summary>
<summary><strong>🕐 系统工具? 个)</strong></summary>
| 工具 | 功能 |
|------|------|
| `calculator` | 安全数学计算ï¼? - * / ** % (),递归下é™è§£æžå™¨ï¼‰ |
| `calculator` | 安全数学计算? - * / ** % (),递归下降解析器) |
</details>
<details>
<summary><strong>📋 Plan Modeï¼? 个,ä»?Plan æ¨¡å¼æ¿€æ´»ï¼‰</strong></summary>
<summary><strong>📋 Plan Mode? 个,?Plan 模式激活)</strong></summary>
| 工具 | 功能 |
|------|------|
@@ -164,51 +164,51 @@
</details>
## ðŸ—ï¸?æž¶æž„
## 🏗?架构
```
ç”¨æˆ·æ¶ˆæ¯ â†?扫æå·¥ä½œç©ºé—´ SOUL.md(ä¸å¯åŽ‹ç¼©å±‚ï¼‰â†’ AGENT.md â†?USER.md(工作空间,ä¸å­˜åœ¨åˆ™è·³è¿‡ï¼?
�
记忆检ç´?(MEMORY.md å…³é”®è¯æœç´? â†?上下文注å…?
�
Agent Engine (8 çŠ¶æ€æœº ReAct Loop, â‰?5 è½? 智能é‡è¯•, 路径ä¾èµ–串行åŒ? 看门ç‹?分级超时)
�
æç¤ºè¯åŠ å›ºï¼ˆå‚è€ƒæ•°æ®æ ‡è®?+ 工具结果安全规则ï¼?
�
用户消息 ?扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md ?USER.md(工作空间,不存在则跳过?
?
记忆检?(MEMORY.md 关键词搜? ?上下文注?
?
Agent Engine (8 状态机 ReAct Loop, ?5 ? 智能重试, 路径依赖串行? 看门?分级超时)
?
提示词加固(参考数据标?+ 工具结果安全规则?
?
Ollama API (流式响应,num_ctx 用户可配 128K/256K/512K/1M)
�
Tool Registry (32 内置 + MCP 动�+ Plan Mode plan_track)
�
Hook 系统 (pre/post tool/iteration/completion) â†?观察结果 â†?忀?â†?循环 / 最终回ç­?
?
Tool Registry (32 内置 + MCP ?+ Plan Mode plan_track)
?
Hook 系统 (pre/post tool/iteration/completion) ?观察结果 ?反??循环 / 最终回?
```
### 🧩 UI 组件(原�DOM�
### 🧩 UI 组件(原?DOM?
```
main.ts (入口)
├── header.ts # 顶部导航�
├── model-bar.ts # 模型选择�
├── header.ts # 顶部导航?
├── model-bar.ts # 模型选择?
├── chat-area.ts # 聊天消息区域
├── input-area.ts # 输入�+ 文件上传 + Plan Mode 开�
├── workspace-panel.ts # 终端 + 文件æµè§ˆå™?+ 工具å¡ç‰‡
├── input-area.ts # 输入?+ 文件上传 + Plan Mode ?
├── workspace-panel.ts # 终端 + 文件浏览?+ 工具卡片
├── settings-modal.ts # 设置面板(含看门狗超时配置)
├── history-modal.ts # 会话历史
├── memory-modal.ts # 记忆管理
├── tools-modal.ts # 工具列表
├── token-dashboard.ts # Token 消耗仪表盘
├── tool-confirm-modal.ts # 工具执行确认
├── prompt-modal.ts # 系统æç¤ºè¯æŸ¥çœ?+ Plan 确认弹窗
├── prompt-modal.ts # 系统提示词查?+ Plan 确认弹窗
├── toast.ts # Toast 通知
└── lightbox.ts # 图片灯箱
```
### 🗄ï¸?æ•°æ®åº?
### 🗄?数据?
SQLite (sql.js WASM)ï¼? 张表,WAL 模å¼ï¼?
SQLite (sql.js WASM)? 张表,WAL 模式?
| �| 用�|
| ?| ?|
|---|---|
| `sessions` | 会è¯ï¼ˆparent_id 父å­å…³ç³»ï¼?|
| `sessions` | 会话(parent_id 父子关系?|
| `messages` | 消息(外键级联删除) |
| `tool_calls` | 工具调用记录 |
| `settings` | 设置(JSON 序列化) |
@@ -218,17 +218,17 @@ SQLite (sql.js WASM)
| 层级 | 措施 |
|------|------|
| 📠文件系统 | `checkPathAllowed()` â€?路径黑åå•(33 个系ç»?æ•æ„Ÿç›®å½•ï¼Œå« Linux + Windowsï¼?|
| âš?命令执行 | `checkCommandAllowed()` â€?命令黑åå•(30 æ¡å±é™©å‘½ä»¤ï¼Œå?POSIX + Windowsï¼? ä¸‰ç§æ¨¡å¼ |
| 🖥ï¸?å‰ç«¯æ¸²æŸ“ | HTML 净化器(白å啿 ‡ç­¾ + URI å议检查) |
| 🔒 Electron | `contextIsolation: true` + IPC 白åå?+ IPC fs è·¯å¾„éªŒè¯ |
| 📁 文件系统 | `checkPathAllowed()` ?路径黑名单(33 个系?敏感目录,含 Linux + Windows?|
| ?命令执行 | `checkCommandAllowed()` ?命令黑名单(30 条危险命令,?POSIX + Windows? 三种模式 |
| 🖥?前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) |
| 🔒 Electron | `contextIsolation: true` + IPC 白名?+ IPC fs 路径验证 |
| 🔐 数据加密 | AES-256-GCM |
| 🧠 记忆安全 | 写入å‰?Prompt Injection / æ•æ„Ÿä¿¡æ¯æ£€æµ‹ï¼Œå¤šå±‚级去é‡è¿‡æ»?|
| 🌠MCP 安全 | Shadowing 防护 + åŒä¸‹åˆ’线分隔防歧ä¹?|
| 🌠网络安全 | `web_fetch` æµå¼ä½“积é™åˆ¶ï¼?0MBï¼? æ—?content-length 时防 OOM |
| âš?进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大å°å†™ä¸æ•感校验 |
| 🧠 记忆安全 | 写入?Prompt Injection / 敏感信息检测,多层级去重过?|
| 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧?|
| 🌐 网络安全 | `web_fetch` 流式体积限制?0MB? ?content-length 时防 OOM |
| ?进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 |
## 🚀 快速开�
## 🚀 快速开?
```bash
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
@@ -238,32 +238,32 @@ npm install
npm start
```
## 📦 构建 Windows 安装�
## 📦 构建 Windows 安装?
```bash
# 环境:Ubuntu 24.04 + Node.js v22 + Wine 9.0+
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
产出:`release/Metona Ollama Setup v0.16.18.exe`
产出:`release/Metona Ollama Setup v0.16.19.exe`
## 🛠�常用命令
## 🛠?常用命令
```bash
npm start # 构建并è¿è¡?
npm start # 构建并运?
npm run dev:renderer # Vite watch(渲染进程)
npm run dev:main # tsc watch(主进程�
npm run dev:main # tsc watch(主进程?
npm run build # 完整构建
npm run dist # 构建 Windows 安装�
npm run dist # 构建 Windows 安装?
```
## 💻 系统要求
- 🖥ï¸?Windows 10/11ï¼?4 ä½ï¼‰
- 🤖 Ollama 本地è¿è¡Œä¸­ï¼ˆé»˜è®¤ `http://localhost:11434`ï¼?
- 🖥?Windows 10/11?4 位)
- 🤖 Ollama 本地运行中(默认 `http://localhost:11434`?
- 💾 建议 8GB+ 内存
## 📄 许å¯è¯?
## 📄 许可?
[MIT](LICENSE)
@@ -271,27 +271,27 @@ npm run dist # 构建 Windows 安装
# 🇬🇧 English
## �Features
## ?Features
| | Feature | Description |
|:---:|:---|:---|
| 🤖 | **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 |
| 🛡�| **Prompt Hardening** | Reference data markers (<<<REFERENCE_DATA_START>>>) + tool results are data-not-instructions safety rules |
| 🛡?| **Prompt Hardening** | Reference data markers (<<<REFERENCE_DATA_START>>>) + tool results are data-not-instructions safety rules |
| 📋 | **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) |
| 🔧 | **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 |
| 🧠 | **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 |
| 🔍 | **Web Search (dual-mode)** | SearXNG meta-search JSON API (70+ engines) / quad-engine HTML parsing (Bing+Baidu+Sogou+360), switchable; web_fetch with auto-retry+mobile UA+SPA browser fallback |
| 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close |
| 🖥�| **Workspace Panel** | Terminal (incremental streaming) + file browser, command security checks |
| 🖥?| **Workspace Panel** | Terminal (incremental streaming) + file browser, command security checks |
| 🔢 | **Manual Context Length** | Settings dropdown (128K / 256K / 512K / 1M), default 128K. Model bar shows configured value, dropdown shows per-model context length |
| 🗜�| **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression, smart triggers (120 msg incremental + 300 msg hard limit) |
| 🗜?| **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression, smart triggers (120 msg incremental + 300 msg hard limit) |
| ⏱️ | **Smart Timeout Protection** | Agent Loop watchdog (configurable, default 30min) + stream total timeout (configurable, default 300s) + configurable HTTP/MCP timeouts |
| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in security check, file dedup, auto plan tracking, change audit, result validation |
| 👥 | **Sub-agent Delegation** | spawn_task tool, isolated context + timeout protection |
| 📈 | **Token Dashboard** | Global + session stats, consumption trend bar chart, 2s refresh, input/output color-coded |
| 📋 | **System Prompt Card** | Collapsible card atop each AI reply �click to inspect the full context sent to the model |
| 📋 | **System Prompt Card** | Collapsible card atop each AI reply ?click to inspect the full context sent to the model |
| 🎨 | **Warm-tone UI** | Cream `#FAF7F2` + coral `#E8734A`, fatigue-free for extended use, streaming render optimized |
| 🔔 | **System Tray** | Native tray integration |
| 🔐 | **AES-256-GCM** | Data encryption support |
@@ -321,7 +321,7 @@ npm run dist # 构建 Windows 安装
</details>
<details>
<summary><strong>âš?Command Execution (1)</strong></summary>
<summary><strong>?Command Execution (1)</strong></summary>
| Tool | Function |
|------|------|
@@ -404,22 +404,22 @@ npm run dist # 构建 Windows 安装
</details>
## ðŸ—ï¸?Architecture
## 🏗?Architecture
```
User message �workspace SOUL.md (never compressed) �AGENT.md �USER.md (workspace, skipped if absent)
�
Memory Retrieval (MEMORY.md keyword search) �Context Injection
�
Agent Engine (8-state ReAct Loop, �5 iter, smart retry, path-aware serialization, watchdog+tiered timeout)
�
User message ?workspace SOUL.md (never compressed) ?AGENT.md ?USER.md (workspace, skipped if absent)
?
Memory Retrieval (MEMORY.md keyword search) ?Context Injection
?
Agent Engine (8-state ReAct Loop, ?5 iter, smart retry, path-aware serialization, watchdog+tiered timeout)
?
Prompt Hardening (reference data markers + tool result safety rules)
�
?
Ollama API (Streaming Response, num_ctx user-configurable 128K/256K/512K/1M)
�
?
Tool Registry (32 Built-in + MCP Dynamic + Plan Mode plan_track)
�
Hook System (pre/post tool/iteration/completion) �Observation �Reflection �Loop / Final Answer
?
Hook System (pre/post tool/iteration/completion) ?Observation ?Reflection ?Loop / Final Answer
```
### 🧩 UI Components (Native DOM)
@@ -442,7 +442,7 @@ main.ts (Entry)
└── lightbox.ts # Image lightbox
```
### 🗄�Database
### 🗄?Database
SQLite (sql.js WASM), 5 tables, WAL mode:
@@ -458,15 +458,15 @@ SQLite (sql.js WASM), 5 tables, WAL mode:
| Layer | Measure |
|------|------|
| 📠File System | `checkPathAllowed()` �path blacklist (33 system/sensitive dirs, Linux + Windows) |
| �Command Execution | `checkCommandAllowed()` �command blacklist (30 dangerous commands, POSIX + Windows) + three modes |
| 🖥�Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) |
| 📁 File System | `checkPathAllowed()` ?path blacklist (33 system/sensitive dirs, Linux + Windows) |
| ?Command Execution | `checkCommandAllowed()` ?command blacklist (30 dangerous commands, POSIX + Windows) + three modes |
| 🖥?Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) |
| 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation |
| 🔐 Data Encryption | AES-256-GCM |
| 🧠 Memory Security | Pre-write prompt injection / sensitive info detection, multi-layer dedup filtering |
| 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation |
| 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length |
| âš?Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation |
| ?Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation |
## 🚀 Quick Start
@@ -485,9 +485,9 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
Output: `release/Metona Ollama Setup v0.16.18.exe`
Output: `release/Metona Ollama Setup v0.16.19.exe`
## 🛠�Common Commands
## 🛠?Common Commands
```bash
npm start # Build and run
@@ -499,7 +499,7 @@ npm run dist # Build Windows installer
## 💻 System Requirements
- 🖥�Windows 10/11 (64-bit)
- 🖥?Windows 10/11 (64-bit)
- 🤖 Ollama running locally (default `http://localhost:11434`)
- 💾 8GB+ RAM recommended
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "0.16.18",
"version": "0.16.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "0.16.18",
"version": "0.16.19",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.16.18",
"version": "0.16.19",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+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.18',
message: 'Metona Ollama Desktop v0.16.19',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+13 -1
View File
@@ -6,11 +6,15 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { sendLog, resolvePath, isUrl, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise<ToolResult> {
try {
// URL 检测:read_file 仅支持本地文件,URL 请使用 web_fetch
if (isUrl(params.path)) {
return { success: false, error: `read_file 仅支持本地文件路径,不支持 URL。请改用 web_fetch 工具读取网页内容。收到的 URL: ${params.path}` };
}
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
@@ -637,6 +641,14 @@ export async function handleTree(params: { path: string; max_depth?: number; inc
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try {
// URL 检测:read_multiple_files 仅支持本地文件,URL 请使用 web_fetch
const urlPaths = (params.paths || []).filter(p => isUrl(p));
if (urlPaths.length > 0) {
return {
success: false,
error: `read_multiple_files 仅支持本地文件路径,不支持 URL。以下路径是 URL,请改用 web_fetch 工具逐个抓取:\n${urlPaths.join('\n')}`
};
}
const maxChars = params.max_chars_per_file || 0; // 0 = 不截断
// 并行读取所有文件(最多50个)
+7
View File
@@ -11,6 +11,13 @@ export function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug',
mainWindow?.webContents.send('main:log', { level, message, detail });
}
/** 检测字符串是否为 URLhttp:// 或 https:// */
export function isUrl(str: string): boolean {
if (typeof str !== 'string' || str.length < 8) return false;
const lower = str.toLowerCase().trim();
return lower.startsWith('http://') || lower.startsWith('https://');
}
/** 解析路径:相对路径基于工作空间目录 */
export function resolvePath(inputPath: string): string {
if (path.isAbsolute(inputPath)) return path.resolve(inputPath);
+19
View File
@@ -583,6 +583,25 @@ export function appendAssistantPlaceholder(): void {
scrollToBottom();
}
/**
* 确保 loading 占位符存在 — 如果当前没有 loading 占位符,创建一个。
* 用于中间轮次 THINKING 开始前,确保用户看到"正在思考..."状态。
*/
export function ensureLoadingPlaceholder(): void {
if (currentPlaceholder && currentPlaceholder.classList.contains('loading') && currentPlaceholder.isConnected) {
return;
}
// 当前 placeholder 不存在或不是 loading 状态 → 清理旧 placeholder 并创建新的
if (currentPlaceholder && !currentPlaceholder.classList.contains('loading') && currentPlaceholder.isConnected) {
// 上一轮的 placeholder 已经被 updateLastAssistantMessage 转为正常消息但未通过 renderMessages 固化
// 不需要移除,直接追加新的 loading
}
if (currentPlaceholder && !currentPlaceholder.isConnected) {
currentPlaceholder = null;
}
appendAssistantPlaceholder();
}
// R34: 加载提示旋转动画
const LOADING_HINTS = [
'正在思考...',
+7 -1
View File
@@ -9,7 +9,7 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
resetCurrentPlaceholder, removeCurrentPlaceholder
resetCurrentPlaceholder, removeCurrentPlaceholder, ensureLoadingPlaceholder
} from './chat-area.js';
import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
@@ -590,6 +590,9 @@ async function handleRetry(): Promise<void> {
retryContent = '';
retryIterations++;
},
onThinkingStart: () => {
ensureLoadingPlaceholder();
},
onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); },
onContent: (content) => { retryContent = content; updateLastAssistantMessage(content, retryThinkContent || null, null, getSelectedModel()); },
onToolCallStart: (call) => {
@@ -1289,6 +1292,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
assistantContent = '';
thinkContent = '';
},
onThinkingStart: () => {
ensureLoadingPlaceholder();
},
onThinking: (thinking) => {
thinkContent = thinking;
updateLastAssistantMessage(assistantContent, thinkContent || null, null, getSelectedModel());
+1 -1
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.18</span>
<span class="app-version">v0.16.19</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"/>
+3
View File
@@ -460,6 +460,9 @@ async function init(): Promise<void> {
// 应用流式超时到 stateagent-engine 读取,0=禁用)
state.set('streamTimeout', streamTimeout >= 0 ? streamTimeout * 1000 : -1);
// 存入 state 供 agent-engine 工具级超时读取(网络工具需要跟随用户配置)
state.set('httpTimeout', httpTimeout >= 0 ? httpTimeout * 1000 : 900_000);
state.set('mcpTimeout', mcpTimeout >= 0 ? mcpTimeout * 1000 : 60_000);
// 应用 HTTP 和 MCP 超时到主进程
const bridge = window.metonaDesktop;
+25 -6
View File
@@ -212,19 +212,19 @@ const TOOL_TIMEOUT_MAP: Record<string, { base: number; grade: 'fast' | 'medium'
move_file: { base: 15_000, grade: 'medium', description: '移动文件' },
tree: { base: 15_000, grade: 'medium', description: '目录树' },
session_read: { base: 10_000, grade: 'medium', description: '会话读取' },
web_search: { base: 30_000, grade: 'medium', description: '网页搜索' },
web_search: { base: 900_000, grade: 'slow', description: '网页搜索' },
git: { base: 30_000, grade: 'medium', description: 'Git 操作' },
// slow (15-60秒)
read_file: { base: 30_000, grade: 'slow', description: '读取文件' },
copy_file: { base: 30_000, grade: 'slow', description: '复制文件' },
read_file: { base: 60_000, grade: 'slow', description: '读取文件' },
copy_file: { base: 60_000, grade: 'slow', description: '复制文件' },
search_files: { base: 60_000, grade: 'slow', description: '搜索文件' },
read_multiple_files: { base: 60_000, grade: 'slow', description: '批量读取' },
web_fetch: { base: 60_000, grade: 'slow', description: '网页抓取' },
web_fetch: { base: 900_000, grade: 'long', description: '网页抓取' },
compress: { base: 60_000, grade: 'slow', description: '压缩' },
// long (>60秒)
download_file: { base: 120_000, grade: 'long', description: '下载文件' },
download_file: { base: 900_000, grade: 'long', description: '下载文件' },
default: { base: 60_000, grade: 'medium', description: '默认工具' },
};
@@ -233,6 +233,18 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
const config = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
let timeout = config.base;
// 网络工具超时跟随用户配置的 HTTP 超时(取 base 和用户配置中的较大值)
const NETWORK_TOOLS = ['web_search', 'web_fetch', 'download_file'];
if (NETWORK_TOOLS.includes(toolName)) {
const userHttpTimeout = state.get<number>('httpTimeout', 900_000);
// userHttpTimeout=0 表示禁用超时,否则取 max(base, userConfig)
if (userHttpTimeout === 0) {
timeout = 0; // 不超时
} else {
timeout = Math.max(timeout, userHttpTimeout);
}
}
// run_command 特殊处理:根据命令类型调整
if (toolName === 'run_command' && args.command) {
const cmd = String(args.command);
@@ -251,7 +263,7 @@ function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>)
if (toolName === 'web_fetch' && args.url) {
const url = String(args.url);
if (url.includes('youtube') || url.includes('video') || url.includes('mp4')) {
timeout = 90_000;
timeout = Math.max(timeout, 90_000);
}
}
@@ -1024,6 +1036,8 @@ export interface AgentCallbacks {
onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number; ctx_tokens?: number }) => void;
onConfirmTool: (call: ToolCall) => Promise<boolean>;
onNewIteration?: (toolCalls?: ToolCall[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void;
/** 每轮 THINKING 开始前调用,UI 确保 loading 占位符存在 */
onThinkingStart?: () => void;
/** Plan Mode: 计划已生成,等待用户确认 */
onPlanReady?: (plan: string, steps: string[]) => Promise<boolean>;
}
@@ -1598,6 +1612,11 @@ async function handleThinking(
resetStreamProgress();
// 确保 loading 占位符存在 — 中间轮次中 onNewIteration 可能因各种原因未能正确创建 loading
if (callbacks.onThinkingStart) {
callbacks.onThinkingStart();
}
if (progressTimer) clearInterval(progressTimer as unknown as number);
progressTimer = setInterval(() => {
const elapsed = Date.now() - streamStartTime;
+33 -48
View File
@@ -19,9 +19,7 @@ export interface LogEntry {
let logBodyEl: HTMLElement | null = null;
let logPanelEl: HTMLElement | null = null;
const MAX_LOGS = 500;
// R46: 日志轮转 — 旧日志自动清理
const LOG_MAX_AGE_MS = 3600000; // 1 小时
const MAX_LOGS = 2000;
let logs: LogEntry[] = [];
let autoScroll = true;
let idCounter = 0;
@@ -31,26 +29,6 @@ let panelInitialized = false;
/** initLogPanel 前的日志暂存区 */
const earlyBuffer: LogEntry[] = [];
// R46: 定期清理旧日志
let _logCleanupTimer: ReturnType<typeof setInterval> | null = null;
function startLogRotation(): void {
if (_logCleanupTimer) clearInterval(_logCleanupTimer);
_logCleanupTimer = setInterval(() => {
const now = Date.now();
const before = logs.length;
const oldLogs = logs.filter(l => now - l.time >= LOG_MAX_AGE_MS);
logs = logs.filter(l => now - l.time < LOG_MAX_AGE_MS);
const removed = before - logs.length;
if (removed > 0 && logBodyEl) {
// 从 DOM 中移除旧日志条目
for (const old of oldLogs) {
const el = logBodyEl.querySelector(`#${old.id}`);
if (el) el.remove();
}
}
}, 60000); // 每分钟检查一次
}
const LEVEL_ICONS: Record<LogLevel, string> = {
info: '️',
success: '✅',
@@ -78,8 +56,6 @@ export function initLogPanel(): void {
document.querySelector('#btnExportLog')?.addEventListener('click', exportLog);
panelInitialized = true;
// R46: 启动日志轮转
startLogRotation();
if (earlyBuffer.length > 0 && logBodyEl) {
const frag = document.createDocumentFragment();
for (const entry of earlyBuffer) {
@@ -200,42 +176,51 @@ export function logThink(thinking: string): void {
export function logStream(msg: string): void { addLog('stream', msg); }
/** 本轮流式开始:同步创建新进度条目到日志底部(不删旧,日志就是完整记录 */
/** 当前流式进度条目 id(用于原地更新 */
let _streamProgressId: string | null = null;
/** 本轮流式开始:通过 addLog 创建进度条目(纳入 logs 数组统一管理) */
export function resetStreamProgress(): void {
if (!logBodyEl) return;
const entry = document.createElement('div');
entry.className = 'log-entry log-stream log-stream-progress';
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">📡</span><span class="log-msg">⏳ 等待模型响应… 0s</span>`;
logBodyEl.appendChild(entry);
_streamProgressId = genId();
addLog('stream', '⏳ 等待模型响应… 0s', undefined, '📡', _streamProgressId);
}
/** 更新**最新**一条流式进度日志(原地更新消息,不改变 DOM 位置和时间戳 */
/** 更新当前流式进度日志(原地更新 DOM,同步 logs 数组 */
export function logStreamProgress(msg: string): void {
const all = logBodyEl?.querySelectorAll?.('.log-stream-progress');
if (all && all.length > 0) {
const latest = all[all.length - 1] as HTMLElement;
const msgSpan = latest.querySelector('.log-msg');
if (!_streamProgressId) return;
// 更新 DOM
const el = logBodyEl?.querySelector(`#${_streamProgressId}`);
if (el) {
const msgSpan = el.querySelector('.log-msg');
if (msgSpan) msgSpan.textContent = msg;
}
// 同步 logs 数组(确保导出时进度也是最新的)
const entry = logs.find(l => l.id === _streamProgressId);
if (entry) entry.message = msg;
}
/** 视频提取进度:创建初始条目 */
/** 当前视频提取进度条目 id */
let _videoProgressId: string | null = null;
/** 视频提取进度:通过 addLog 创建条目(纳入 logs 数组统一管理) */
export function resetVideoProgress(fileName: string): void {
if (!logBodyEl) return;
const entry = document.createElement('div');
entry.className = 'log-entry log-info log-video-progress';
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">🎬</span><span class="log-msg">开始提取视频帧: ${escapeHtml(fileName)}</span><pre class="log-detail">0 帧</pre>`;
logBodyEl.appendChild(entry);
_videoProgressId = genId();
addLog('info', `开始提取视频帧: ${fileName}`, '0 帧', '🎬', _videoProgressId);
}
/** 更新视频提取进度(原地更新,不新增条目 */
/** 更新视频提取进度(原地更新 DOM 和 logs 数组 */
export function updateVideoProgress(current: number): void {
const all = logBodyEl?.querySelectorAll?.('.log-video-progress');
if (all && all.length > 0) {
const latest = all[all.length - 1] as HTMLElement;
const detail = latest.querySelector('.log-detail');
if (detail) detail.textContent = `${current}`;
if (!_videoProgressId) return;
const text = `${current}`;
// 更新 DOM
const el = logBodyEl?.querySelector(`#${_videoProgressId}`);
if (el) {
const detail = el.querySelector('.log-detail');
if (detail) detail.textContent = text;
}
// 同步 logs 数组
const entry = logs.find(l => l.id === _videoProgressId);
if (entry) entry.detail = text;
}
export function logAgentLoop(iteration: number, maxLoops: number): void { addLog('info', `Loop #${iteration}/${maxLoops}`); }
+74 -26
View File
@@ -13,12 +13,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from the local filesystem. Supports text mode (utf-8/latin1, line-based pagination) and binary mode (base64, byte-based pagination). Files up to 5MB (text) or 50MB (binary). When truncated, returns remaining_lines/remaining_bytes and a hint to continue reading. Use start_line/end_line for text files, offset_bytes/limit_bytes for binary files or raw byte access.',
description: 'Read a LOCAL file from the filesystem. Does NOT support URLs — use web_fetch for web pages. Supports text mode (utf-8/latin1, line-based pagination) and binary mode (base64, byte-based pagination). Files up to 5MB (text) or 50MB (binary). When truncated, returns remaining_lines/remaining_bytes and a hint to continue reading. Use start_line/end_line for text files, offset_bytes/limit_bytes for binary files or raw byte access.',
parameters: {
type: 'object',
required: ['path'],
properties: {
path: { type: 'string', description: 'The file path to read. Absolute or relative to workspace.' },
path: { type: 'string', description: 'Local file path only (NOT a URL). Absolute or relative to workspace. For web URLs, use web_fetch instead.' },
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8. Use base64 for binary content.' },
start_line: { type: 'integer', description: 'Start line (1-indexed) for text files. Use with end_line for pagination.' },
end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 2000.' },
@@ -231,12 +231,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'read_multiple_files',
description: 'Read up to 50 files at once in parallel. Returns content of all requested files (10KB each by default).',
description: 'Read up to 50 LOCAL files at once in parallel. Does NOT support URLs — use web_fetch for web pages. Returns content of all requested files (10KB each by default).',
parameters: {
type: 'object',
required: ['paths'],
properties: {
paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to read.' },
paths: { type: 'array', items: { type: 'string' }, description: 'Array of LOCAL file paths to read (NOT URLs). For web URLs, use web_fetch instead.' },
max_chars_per_file: { type: 'integer', description: 'Max chars per file. Default: 10000.' }
}
}
@@ -801,6 +801,31 @@ function hasCommandInjection(command: string): boolean {
/** R28: 工具安全验证 — 在执行前检查安全风险 */
export function validateToolSecurity(toolName: string, args: Record<string, unknown>): string | null {
// R28: URL 检测 — 本地文件工具不接受 URL
const localFileTools = ['read_file', 'read_multiple_files', 'write_file', 'edit_file', 'list_directory', 'search_files', 'create_directory', 'delete_file', 'move_file', 'copy_file', 'tree'];
if (localFileTools.includes(toolName)) {
const pathFields = ['path', 'source', 'destination', 'file1', 'file2'];
for (const field of pathFields) {
if (typeof args[field] === 'string') {
const val = (args[field] as string).toLowerCase().trim();
if (val.startsWith('http://') || val.startsWith('https://')) {
return `参数 "${field}" 是 URL,不是本地路径。请改用 web_fetch 工具读取网页内容: ${args[field]}`;
}
}
}
// paths 数组检查(read_multiple_files
if (Array.isArray(args.paths)) {
const urls = (args.paths as string[]).filter(p => {
if (typeof p !== 'string') return false;
const lower = p.toLowerCase().trim();
return lower.startsWith('http://') || lower.startsWith('https://');
});
if (urls.length > 0) {
return `paths 数组中包含 ${urls.length} 个 URLread_multiple_files 仅支持本地文件。请改用 web_fetch 工具逐个抓取以下 URL:\n${urls.join('\n')}`;
}
}
}
// 路径遍历检查
const pathFields = ['path', 'source', 'destination', 'file1', 'file2', 'cwd'];
for (const field of pathFields) {
@@ -978,44 +1003,67 @@ export function validateToolArgs(toolName: string, args: Record<string, unknown>
// ── R22: 工具结果截断 ──
/** R22: 工具结果最大字符数 */
const MAX_TOOL_RESULT_CHARS = 30000;
const MAX_TOOL_RESULT_CHARS = 100000;
/** R22: 截断单个大字符串,保留首尾并添加截断标记 */
function truncateStringField(str: string, headKeep = 8000, tailKeep = 3000): string {
if (str.length <= headKeep + tailKeep) return str;
const head = str.slice(0, headKeep);
const tail = str.slice(-tailKeep);
const omitted = str.length - headKeep - tailKeep;
return `${head}\n\n... [已截断 ${omitted} 字符,共 ${str.length} 字符] ...\n\n${tail}`;
}
/** R22: 截断大输出结果,保留首尾并添加截断标记 */
export function truncateToolResult(result: ToolResult, toolName: string): ToolResult {
const jsonStr = JSON.stringify(result);
if (jsonStr.length <= MAX_TOOL_RESULT_CHARS) return result;
// 不同工具有不同的截断策略
const truncated = { ...result };
// 截断 stdout/content 类的大字段
const largeFields = ['stdout', 'content', 'text', 'result', 'output', 'data'];
// 1. 截断顶层大字符串字段
const largeFields = ['stdout', 'content', 'text', 'result', 'output', 'data', 'formatted', 'snippet', 'error', 'stderr'];
for (const field of largeFields) {
if (typeof truncated[field] === 'string' && (truncated[field] as string).length > 10000) {
const original = truncated[field] as string;
const head = original.slice(0, 8000);
const tail = original.slice(-3000);
const omitted = original.length - 11000;
truncated[field] = `${head}\n\n... [已截断 ${omitted} 字符,共 ${original.length} 字符] ...\n\n${tail}`;
truncated[field] = truncateStringField(truncated[field] as string);
}
}
// 截断数组类结果
if (Array.isArray(truncated.entries) && truncated.entries.length > 100) {
const total = truncated.entries.length;
truncated.entries = truncated.entries.slice(0, 50);
truncated._truncated = true;
truncated._totalEntries = total;
truncated._truncatedMessage = `结果已截断:显示前 50 条,共 ${total}`;
// 2. 截断数组内每条记录的大字符串字段(如 _fetched[].content, results[].snippet
const arrayFields = ['results', 'entries', '_fetched', 'structured', 'files', 'matches'];
for (const field of arrayFields) {
if (Array.isArray(truncated[field])) {
const arr = truncated[field] as unknown[];
// 数组本身过长时先裁剪条数
if (arr.length > 50) {
(truncated as any)[`_original_${field}_count`] = arr.length;
(truncated as any)[field] = arr.slice(0, 50);
}
// 逐条截断内部大字段
const innerLargeKeys = ['content', 'snippet', 'text', 'stdout', 'output', 'formatted', 'data', 'result', 'stderr'];
for (const item of (truncated as any)[field]) {
if (item && typeof item === 'object' && !Array.isArray(item)) {
for (const key of innerLargeKeys) {
if (typeof item[key] === 'string' && item[key].length > 8000) {
item[key] = truncateStringField(item[key], 6000, 2000);
}
}
}
}
}
if (Array.isArray(truncated.results) && truncated.results.length > 100) {
const total = truncated.results.length;
truncated.results = truncated.results.slice(0, 50);
truncated._truncated = true;
truncated._totalResults = total;
}
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${JSON.stringify(truncated).length} 字符)`);
// 3. 兜底:如果经过上述截断后仍然超限,对整个 JSON 暴力截断
let finalStr = JSON.stringify(truncated);
if (finalStr.length > MAX_TOOL_RESULT_CHARS) {
const head = finalStr.slice(0, MAX_TOOL_RESULT_CHARS - 500);
const tail = finalStr.slice(-300);
const omitted = finalStr.length - (MAX_TOOL_RESULT_CHARS - 200);
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${jsonStr.length} → ~${MAX_TOOL_RESULT_CHARS} 字符)`);
return JSON.parse(head + `"... [已暴力截断 ${omitted} 字符] ..."}` + tail) as ToolResult;
}
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${finalStr.length} 字符)`);
return truncated;
}