v0.16.19: 修复日志面板清空、URL误传文件工具、网络工具超时、结果截断
This commit is contained in:
@@ -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** | 始终开启的唯一对话模式�? 状态机(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(用户画像,仅工作空间读取,不存在则不注入) |
|
||||
| 🌐 | **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` | 统一记忆管理�? �?action:search 搜索 / 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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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个)
|
||||
|
||||
@@ -11,6 +11,13 @@ export function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug',
|
||||
mainWindow?.webContents.send('main:log', { level, message, detail });
|
||||
}
|
||||
|
||||
/** 检测字符串是否为 URL(http:// 或 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);
|
||||
|
||||
@@ -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 = [
|
||||
'正在思考...',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -460,6 +460,9 @@ async function init(): Promise<void> {
|
||||
|
||||
// 应用流式超时到 state(agent-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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`); }
|
||||
|
||||
@@ -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} 个 URL,read_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} 条`;
|
||||
}
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user