From 2119e51660d1a867cf26cd78dd9c116a36831a1c Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 6 Apr 2026 03:47:15 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20v3.0=20Tool=20Call?= =?UTF-8?q?ing=20=E6=8A=80=E6=9C=AF=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8875KB=20=E8=AF=A6=E7=BB=86=E8=AE=BE=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/V3-TOOL-CALLING.md | 2870 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 2870 insertions(+) create mode 100644 docs/V3-TOOL-CALLING.md diff --git a/docs/V3-TOOL-CALLING.md b/docs/V3-TOOL-CALLING.md new file mode 100644 index 0000000..9d85c6e --- /dev/null +++ b/docs/V3-TOOL-CALLING.md @@ -0,0 +1,2870 @@ +# Metona Ollama Desktop v3.0 技术开发文档 + +## Tool Calling — AI 本地文件操作系统 + +--- + +## 目录 + +1. [版本概述](#1-版本概述) +2. [核心概念](#2-核心概念) +3. [Ollama Tool Calling 协议详解](#3-ollama-tool-calling-协议详解) +4. [架构设计](#4-架构设计) +5. [工具定义规范](#5-工具定义规范) +6. [工具实现:主进程](#6-工具实现主进程) +7. [工具实现:渲染进程](#7-工具实现渲染进程) +8. [Agent Loop 引擎](#8-agent-loop-引擎) +9. [UI/UX 设计](#9-uiux-设计) +10. [安全模型](#10-安全模型) +11. [类型定义](#11-类型定义) +12. [文件变更清单](#12-文件变更清单) +13. [测试方案](#13-测试方案) +14. [兼容性与降级](#14-兼容性与降级) +15. [未来扩展](#15-未来扩展) +16. [附录](#16-附录) + +--- + +## 1. 版本概述 + +### 1.1 目标 + +在 Metona Ollama v2.0(TypeScript + Electron)基础上,实现 **AI Tool Calling** 功能,使 AI 模型能够在对话中主动调用本地工具来完成任务,核心能力是**本地文件系统操作**。 + +### 1.2 核心能力 + +- AI 可以读取、写入、搜索、列出本地文件 +- AI 可以创建、删除文件和目录 +- AI 可以执行 shell 命令(可选,高风险,默认关闭) +- 所有工具调用在用户可视化监督下执行 +- 高风险操作需要用户确认 + +### 1.3 适用模型 + +Tool Calling 需要模型本身支持。以下模型已验证支持: + +| 模型系列 | Tool Calling 支持 | 推荐 | +|----------|-------------------|------| +| Qwen3 (all sizes) | ✅ 完整支持 | ⭐ 推荐 | +| Llama 3.1 / 3.2 / 3.3 | ✅ 完整支持 | ⭐ 推荐 | +| Mistral / Mixtral | ✅ 完整支持 | | +| Command R+ | ✅ 完整支持 | | +| Phi-4 | ✅ 支持 | | +| Gemma 2 / 3 | ⚠️ 部分支持 | | +| DeepSeek-V3 / R1 | ✅ 支持 | | + +### 1.4 技术栈不变 + +- TypeScript 5.7 +- Electron 33 +- Vite 5 +- Ollama REST API(原生 fetch,不使用 ollama-js SDK) + +--- + +## 2. 核心概念 + +### 2.1 Tool Calling 是什么 + +Tool Calling(也叫 Function Calling)是一种让大语言模型(LLM)在生成回答时,能够声明"我需要调用某个工具"的能力。模型本身**不会执行**工具,而是返回一个结构化的调用请求,由客户端执行后把结果返回给模型。 + +### 2.2 为什么需要 Tool Calling + +LLM 的知识截止于训练数据,且无法直接访问外部世界。Tool Calling 让模型能够: +- 获取实时信息(文件内容、系统状态) +- 执行动作(写文件、运行命令) +- 突破上下文窗口限制(按需读取文件而非全部塞入提示词) + +### 2.3 Agent Loop(代理循环) + +传统的单轮对话:用户 → 模型 → 回答。 + +Agent Loop 是多轮工具调用循环: + +``` +用户请求 + ↓ +模型返回 tool_calls [read_file("config.json")] + ↓ +客户端执行 read_file → 返回内容 + ↓ +模型返回 tool_calls [write_file("config.json", newContent)] + ↓ +客户端执行 write_file → 返回 "写入成功" + ↓ +模型返回最终回答:"已更新配置文件,修改了..." + ↓ +循环结束(tool_calls 为空) +``` + +**关键**:循环终止条件是模型不再返回 `tool_calls`。 + +### 2.4 流式 + Tool Calling + +在流式模式下,tool_calls 的 `arguments` 可能被分成多个 chunk 到达。必须累积所有 chunk 后再解析 JSON: + +```typescript +// ❌ 错误:逐 chunk 解析 arguments +for (const chunk of stream) { + const args = JSON.parse(chunk.tool_calls[0].function.arguments); // 可能不完整! +} + +// ✅ 正确:累积后解析 +let accumulatedArgs = ''; +for (const chunk of stream) { + accumulatedArgs += chunk.tool_calls[0].function.arguments; +} +const args = JSON.parse(accumulatedArgs); // 完整 JSON +``` + +--- + +## 3. Ollama Tool Calling 协议详解 + +### 3.1 请求格式 + +```json +{ + "model": "qwen3:8b", + "messages": [ + {"role": "user", "content": "帮我读一下 package.json 的版本号"} + ], + "stream": true, + "think": true, + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read the contents of a local file", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative file path" + } + } + } + } + } + ] +} +``` + +**字段说明**: + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `model` | string | ✅ | 模型名称 | +| `messages` | array | ✅ | 对话历史 | +| `stream` | boolean | ❌ | 是否流式,默认 false | +| `think` | boolean | ❌ | 是否启用推理(需模型支持) | +| `tools` | array | ❌ | 工具定义列表 | + +### 3.2 工具定义格式 + +```json +{ + "type": "function", + "function": { + "name": "函数名", + "description": "函数描述(模型据此决定是否调用)", + "parameters": { + "type": "object", + "required": ["必填参数1", "必填参数2"], + "properties": { + "参数名": { + "type": "string | number | integer | boolean | array | object", + "description": "参数描述", + "enum": ["可选值1", "可选值2"], + "items": { "type": "string" } // array 类型时需要 + } + } + } + } +} +``` + +### 3.3 模型响应格式 + +**普通回答**(无工具调用): + +```json +{ + "model": "qwen3:8b", + "message": { + "role": "assistant", + "content": "这是一个普通的文本回答", + "thinking": "模型的推理过程(think=true 时)" + }, + "done": true +} +``` + +**工具调用**: + +```json +{ + "model": "qwen3:8b", + "message": { + "role": "assistant", + "content": "", + "thinking": "用户想读取文件,我需要调用 read_file 工具...", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "read_file", + "arguments": { + "path": "package.json" + } + } + } + ] + }, + "done": true +} +``` + +### 3.4 流式响应中的 tool_calls + +流式模式下,tool_calls 按 chunk 到达: + +```json +// chunk 1 +{"message": {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "pack"}}}]}} + +// chunk 2 +{"message": {"tool_calls": [{"function": {"arguments": {"age.json"}}}]}} +``` + +**注意**:Ollama 在流式模式下,`arguments` 对象会被增量发送。需要按 `name` 匹配同一工具调用,累积 `arguments`。 + +实际上 Ollama 的流式行为是:第一次 chunk 包含 `name` 和部分 `arguments`,后续 chunk 只有 `arguments` 的增量部分。但由于 arguments 是对象,在非流式下已经完整;流式下 Ollama 通常会在一个 chunk 内返回完整的 tool_calls。 + +### 3.5 回传工具结果 + +将工具执行结果以 `role: "tool"` 的消息格式添加到 messages 数组: + +```json +{ + "role": "tool", + "tool_name": "read_file", + "content": "{\"name\": \"metona-ollama-desktop\", \"version\": \"2.0.0\", ...}" +} +``` + +**关键规则**: +- `role` 必须是 `"tool"` +- `tool_name` 必须与 tool_calls 中的 `name` 一致 +- `content` 必须是**字符串**(复杂对象需要 JSON.stringify) +- 每个 tool_call 需要对应的 tool 消息,顺序保持一致 + +### 3.6 并行工具调用 + +模型可能一次返回多个 tool_calls: + +```json +{ + "tool_calls": [ + {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}}, + {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}} + ] +} +``` + +客户端需要**全部执行**后,逐个回传结果: + +```json +messages: [ + ..., + {"role": "assistant", "tool_calls": [...]}, + {"role": "tool", "tool_name": "read_file", "content": "a.txt 的内容"}, + {"role": "tool", "tool_name": "read_file", "content": "b.txt 的内容"} +] +``` + +### 3.7 Agent Loop 完整流程 + +```typescript +async function agentLoop(userMessage: string, tools: ToolDefinition[]) { + const messages: OllamaMessage[] = [{ role: 'user', content: userMessage }]; + + while (true) { + // 1. 发送请求 + const response = await ollama.chat({ + model: selectedModel, + messages, + tools, + stream: false, + think: true + }); + + // 2. 添加助手消息 + messages.push(response.message); + + // 3. 检查是否有工具调用 + if (!response.message.tool_calls?.length) { + // 无工具调用 → 循环结束,返回最终内容 + return response.message.content; + } + + // 4. 执行每个工具调用 + for (const call of response.message.tool_calls) { + const result = await executeTool(call.function.name, call.function.arguments); + messages.push({ + role: 'tool', + tool_name: call.function.name, + content: JSON.stringify(result) + }); + } + // 5. 回到步骤 1,继续循环 + } +} +``` + +--- + +## 4. 架构设计 + +### 4.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 渲染进程 (Renderer) │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌─────────────────┐ │ +│ │ chat-area.ts │ │ tool-call-ui │ │ tool-panel.ts │ │ +│ │ (消息渲染) │ │ (工具调用卡片) │ │ (工具面板) │ │ +│ └──────┬───────┘ └───────┬───────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ ┌──────┴──────────────────┴────────────────────┴────────┐ │ +│ │ agent-engine.ts │ │ +│ │ (Agent Loop 核心引擎) │ │ +│ │ - 流式消息发送 │ │ +│ │ - tool_calls 解析 │ │ +│ │ - 循环控制 │ │ +│ │ - 消息累积 │ │ +│ └────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴──────────────────────────────┐ │ +│ │ tool-registry.ts │ │ +│ │ (工具注册与调度中心) │ │ +│ │ - 工具注册/注销 │ │ +│ │ - 参数校验 │ │ +│ │ - 执行调度 │ │ +│ │ - 权限检查 │ │ +│ └────────────────────────┬──────────────────────────────┘ │ +│ │ IPC (invoke) │ +└───────────────────────────┼─────────────────────────────────┘ + │ +┌───────────────────────────┼─────────────────────────────────┐ +│ 主进程 (Main) │ +│ │ │ +│ ┌────────────────────────┴──────────────────────────────┐ │ +│ │ tool-handlers.ts │ │ +│ │ (工具执行器 - 文件操作) │ │ +│ │ - handleReadFile() │ │ +│ │ - handleWriteFile() │ │ +│ │ - handleListDir() │ │ +│ │ - handleSearchFiles() │ │ +│ │ - handleDeleteFile() │ │ +│ │ - handleCreateDir() │ │ +│ │ - handleRunCommand() │ │ +│ └────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴──────────────────────────────┐ │ +│ │ tool-security.ts │ │ +│ │ (安全检查与沙箱) │ │ +│ │ - 路径白名单/黑名单 │ │ +│ │ - 路径遍历检测 (../) │ │ +│ │ - 文件大小限制 │ │ +│ │ - 命令黑名单 │ │ +│ │ - 操作日志 │ │ +│ └────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ Node.js fs │ │ +│ │ child_ │ │ +│ │ process │ │ +│ └─────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 数据流 + +``` +用户输入 "帮我读取 src/main/main.ts" + │ + ▼ +┌─────────────────┐ +│ input-area.ts │ 捕获用户输入 +└────────┬────────┘ + │ 触发发送 + ▼ +┌─────────────────┐ +│ agent-engine.ts │ 1. 构建 messages + tools +│ │ 2. 调用 ollama.chat() 流式 +└────────┬────────┘ + │ 流式 chunk 到达 + ▼ +┌─────────────────┐ +│ chat-area.ts │ 3. 实时渲染 thinking + content +└────────┬────────┘ + │ 检测到 tool_calls + ▼ +┌─────────────────┐ +│tool-call-ui.ts │ 4. 显示工具调用卡片(参数、执行中状态) +└────────┬────────┘ + │ 调用执行 + ▼ +┌─────────────────┐ +│tool-registry.ts │ 5. 分发到对应工具处理器 +└────────┬────────┘ + │ IPC invoke + ▼ +┌─────────────────┐ +│tool-handlers.ts │ 6. 执行文件操作(主进程,Node.js fs) +│ + security.ts │ 7. 安全检查 +└────────┬────────┘ + │ 返回结果 + ▼ +┌─────────────────┐ +│tool-call-ui.ts │ 8. 更新卡片状态为"完成",显示结果 +└────────┬────────┘ + │ 结果回传 messages + ▼ +┌─────────────────┐ +│ agent-engine.ts │ 9. 追加 tool message,再次调用 ollama +│ │ 10. 循环直到无 tool_calls +└────────┬────────┘ + │ 最终回答 + ▼ +┌─────────────────┐ +│ chat-area.ts │ 11. 渲染最终回答 +└─────────────────┘ +``` + +### 4.3 模块依赖关系 + +``` +agent-engine.ts + ├── ollama.ts (API 调用) + ├── tool-registry.ts (工具执行) + ├── state.ts (状态管理) + └── chat-area.ts (消息渲染) + +tool-registry.ts + ├── types.d.ts (工具类型定义) + └── window.toolBridge (IPC 桥接) + +tool-call-ui.ts + ├── types.d.ts (UI 类型) + └── DOM 操作 (卡片渲染) + +ipc.ts (主进程) + ├── tool-handlers.ts (工具实现) + └── tool-security.ts (安全检查) +``` + +--- + +## 5. 工具定义规范 + +### 5.1 工具清单 + +#### 5.1.1 read_file — 读取文件 + +```json +{ + "type": "function", + "function": { + "name": "read_file", + "description": "Read the contents of a local file. Returns the file content as a string. Supports text files up to 1MB. For binary files, returns an error message.", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "The file path to read. Can be absolute or relative to the current working directory." + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "latin1", "base64"], + "description": "File encoding. Default: utf-8" + }, + "start_line": { + "type": "integer", + "description": "Start reading from this line number (1-indexed). Useful for large files." + }, + "end_line": { + "type": "integer", + "description": "Stop reading at this line number (inclusive). If omitted, reads to end of file or max 500 lines from start_line." + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "path": "/absolute/path/to/file.txt", + "content": "文件内容...", + "encoding": "utf-8", + "size": 1024, + "lines": 42, + "truncated": false, + "line_range": [1, 42] +} +``` + +**限制**: +- 最大文件大小:1MB +- 最大返回行数:500 行(无 start_line/end_line 时) +- 二进制文件检测后拒绝 + +#### 5.1.2 write_file — 写入文件 + +```json +{ + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to a local file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories automatically.", + "parameters": { + "type": "object", + "required": ["path", "content"], + "properties": { + "path": { + "type": "string", + "description": "The file path to write to." + }, + "content": { + "type": "string", + "description": "The content to write to the file." + }, + "encoding": { + "type": "string", + "enum": ["utf-8"], + "description": "File encoding. Default: utf-8" + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "path": "/absolute/path/to/file.txt", + "bytesWritten": 1024, + "created": true +} +``` + +**安全限制**: +- 内容最大大小:5MB +- 需要用户确认(可配置自动确认) +- 覆盖已存在文件时需要确认 + +#### 5.1.3 list_directory — 列出目录 + +```json +{ + "type": "function", + "function": { + "name": "list_directory", + "description": "List the contents of a directory. Returns file names, types, sizes, and modification times.", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "The directory path to list." + }, + "recursive": { + "type": "boolean", + "description": "If true, list contents recursively. Default: false." + }, + "max_depth": { + "type": "integer", + "description": "Maximum recursion depth when recursive=true. Default: 3." + }, + "include_hidden": { + "type": "boolean", + "description": "If true, include hidden files (starting with .). Default: false." + }, + "filter_extension": { + "type": "string", + "description": "Filter by file extension, e.g. '.ts'. Only works for non-recursive." + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "path": "/absolute/path/to/dir", + "entries": [ + {"name": "main.ts", "type": "file", "size": 4096, "modified": "2026-04-06T03:00:00Z"}, + {"name": "components", "type": "directory", "size": null, "modified": "2026-04-06T02:00:00Z"} + ], + "total": 2, + "truncated": false +} +``` + +**限制**: +- 最大返回条目数:500(超出返回 truncated: true) +- 递归时遵守 max_depth 限制 + +#### 5.1.4 search_files — 搜索文件 + +```json +{ + "type": "function", + "function": { + "name": "search_files", + "description": "Search for files by name pattern or search for text content within files.", + "parameters": { + "type": "object", + "required": ["path", "query"], + "properties": { + "path": { + "type": "string", + "description": "The root directory to search in." + }, + "query": { + "type": "string", + "description": "The search query. Can be a filename glob pattern (e.g. '*.ts') or text to search inside files." + }, + "search_type": { + "type": "string", + "enum": ["filename", "content", "both"], + "description": "What to search: filename only, file content only, or both. Default: both." + }, + "case_sensitive": { + "type": "boolean", + "description": "Whether the search is case-sensitive. Default: false." + }, + "max_results": { + "type": "integer", + "description": "Maximum number of results to return. Default: 50." + }, + "file_extensions": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter search to specific file extensions, e.g. ['.ts', '.js']" + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "query": "import electron", + "search_type": "content", + "results": [ + { + "path": "src/main/main.ts", + "matches": [ + {"line": 3, "text": "import { app, BrowserWindow } from 'electron';", "column": 8} + ] + } + ], + "total_files": 5, + "total_matches": 12, + "truncated": false +} +``` + +**限制**: +- 内容搜索最大扫描文件数:1000 +- 单文件最大搜索大小:500KB +- 每文件最多返回 10 个匹配行 + +#### 5.1.5 create_directory — 创建目录 + +```json +{ + "type": "function", + "function": { + "name": "create_directory", + "description": "Create a new directory. Creates parent directories if they don't exist.", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "The directory path to create." + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "path": "/absolute/path/to/new-dir", + "created": true +} +``` + +#### 5.1.6 delete_file — 删除文件/目录 + +```json +{ + "type": "function", + "function": { + "name": "delete_file", + "description": "Delete a file or empty directory. For safety, requires user confirmation in the UI.", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "The file or directory path to delete." + }, + "recursive": { + "type": "boolean", + "description": "If true, delete directories recursively. Default: false. USE WITH EXTREME CAUTION." + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "path": "/absolute/path/to/file.txt", + "deleted": true +} +``` + +**安全限制**: +- **必须用户确认**,不可自动执行 +- 递归删除时显示目录树预览 +- 禁止删除系统关键路径 + +#### 5.1.7 run_command — 执行命令(可选,默认禁用) + +```json +{ + "type": "function", + "function": { + "name": "run_command", + "description": "Execute a shell command and return stdout/stderr. DANGEROUS: disabled by default, must be enabled in settings.", + "parameters": { + "type": "object", + "required": ["command"], + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute." + }, + "cwd": { + "type": "string", + "description": "Working directory for the command. Default: user home." + }, + "timeout": { + "type": "integer", + "description": "Timeout in milliseconds. Default: 30000 (30s). Max: 120000 (2min)." + } + } + } + } +} +``` + +**返回值**: + +```json +{ + "success": true, + "stdout": "命令输出...", + "stderr": "", + "exitCode": 0, + "duration": 1234 +} +``` + +**安全限制**: +- 默认禁用,需在设置中手动开启 +- 每次执行必须用户确认 +- 命令黑名单:`rm -rf /`, `mkfs`, `dd`, `shutdown`, `reboot` 等 +- 超时限制:最长 2 分钟 +- 输出大小限制:100KB + +### 5.2 工具启用机制 + +在设置面板中新增"工具调用"设置组: + +``` +┌─ 工具调用 ──────────────────────────────────┐ +│ │ +│ 工具调用总开关 [━━━●] 开启 │ +│ │ +│ ┌─ 可用工具 ─────────────────────────────┐ │ +│ │ ☑ read_file 读取文件 │ │ +│ │ ☑ write_file 写入文件(需确认) │ │ +│ │ ☑ list_directory 列出目录 │ │ +│ │ ☑ search_files 搜索文件 │ │ +│ │ ☑ create_directory 创建目录(需确认) │ │ +│ │ ☑ delete_file 删除文件(需确认) │ │ +│ │ ☐ run_command 执行命令⚠️(禁用) │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 安全设置 │ +│ ┌─ 路径限制 ─────────────────────────────┐ │ +│ │ 允许访问的工作目录: │ │ +│ │ [/home/user/projects ] │ │ +│ │ [+ 添加目录] │ │ +│ │ │ │ +│ │ 禁止访问的路径: │ │ +│ │ /etc, /sys, /proc, C:\Windows │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 操作确认 │ +│ ○ 所有写操作都需确认 │ +│ ● 仅删除操作需确认 │ +│ ○ 完全自动(不推荐) │ +│ │ +└─────────────────────────────────────────────┘ +``` + +--- + +## 6. 工具实现:主进程 + +### 6.1 新增文件 + +#### `src/main/tool-handlers.ts` + +```typescript +/** + * Tool Handlers - 主进程工具执行器 + * 所有文件系统操作在此执行,通过 IPC 被渲染进程调用 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; + +const execAsync = promisify(exec); + +// ── 接口定义 ── + +interface ToolResult { + success: boolean; + [key: string]: unknown; +} + +interface ReadFileParams { + path: string; + encoding?: 'utf-8' | 'latin1' | 'base64'; + start_line?: number; + end_line?: number; +} + +interface WriteFileParams { + path: string; + content: string; + encoding?: 'utf-8'; +} + +interface ListDirParams { + path: string; + recursive?: boolean; + max_depth?: number; + include_hidden?: boolean; + filter_extension?: string; +} + +interface SearchFilesParams { + path: string; + query: string; + search_type?: 'filename' | 'content' | 'both'; + case_sensitive?: boolean; + max_results?: number; + file_extensions?: string[]; +} + +interface DeleteParams { + path: string; + recursive?: boolean; +} + +interface RunCommandParams { + command: string; + cwd?: string; + timeout?: number; +} + +// ── 工具实现 ── + +export async function handleReadFile(params: ReadFileParams): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + // 检查文件大小 + const stat = await fs.stat(filePath); + if (stat.size > 1024 * 1024) { + return { success: false, error: `文件过大 (${(stat.size/1024/1024).toFixed(1)}MB),最大支持 1MB` }; + } + + const encoding = params.encoding || 'utf-8'; + const content = await fs.readFile(filePath, encoding); + const lines = content.split('\n'); + + // 行范围过滤 + let resultContent = content; + let lineRange: [number, number] = [1, lines.length]; + let truncated = false; + + if (params.start_line || params.end_line) { + const start = Math.max(1, params.start_line || 1) - 1; + const end = Math.min(lines.length, params.end_line || Math.min(start + 500, lines.length)); + resultContent = lines.slice(start, end).join('\n'); + lineRange = [start + 1, end]; + truncated = end < lines.length; + } else if (lines.length > 500) { + resultContent = lines.slice(0, 500).join('\n'); + lineRange = [1, 500]; + truncated = true; + } + + return { + success: true, + path: filePath, + content: resultContent, + encoding, + size: stat.size, + lines: lines.length, + truncated, + line_range: lineRange + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleWriteFile(params: WriteFileParams): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + // 内容大小检查 + if (params.content.length > 5 * 1024 * 1024) { + return { success: false, error: '内容过大,最大支持 5MB' }; + } + + // 检查是否已存在 + let created = false; + try { + await fs.stat(filePath); + } catch { + created = true; + } + + // 自动创建父目录 + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + + await fs.writeFile(filePath, params.content, params.encoding || 'utf-8'); + + return { + success: true, + path: filePath, + bytesWritten: Buffer.byteLength(params.content, params.encoding || 'utf-8'), + created + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleListDir(params: ListDirParams): Promise { + try { + const dirPath = path.resolve(params.path); + const allowed = checkPathAllowed(dirPath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const maxEntries = 500; + const entries: Array<{name: string; type: string; size: number | null; modified: string}> = []; + let truncated = false; + + async function scanDir(dir: string, depth: number): Promise { + if (entries.length >= maxEntries) { + truncated = true; + 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 (entries.length >= maxEntries) { + truncated = true; + 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); + const stat = await fs.stat(fullPath); + + entries.push({ + name: params.recursive ? path.relative(dirPath, fullPath) : item.name, + type: item.isDirectory() ? 'directory' : 'file', + size: item.isFile() ? stat.size : null, + modified: stat.mtime.toISOString() + }); + + if (params.recursive && item.isDirectory()) { + await scanDir(fullPath, depth + 1); + } + } + } + + await scanDir(dirPath, 1); + + return { + success: true, + path: dirPath, + entries, + total: entries.length, + truncated + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleSearchFiles(params: SearchFilesParams): Promise { + try { + const rootPath = path.resolve(params.path); + const allowed = checkPathAllowed(rootPath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const searchType = params.search_type || 'both'; + const caseSensitive = params.case_sensitive || false; + const maxResults = params.max_results || 50; + const query = caseSensitive ? params.query : params.query.toLowerCase(); + + const results: Array<{ + path: string; + matches: Array<{line: number; text: string; column?: number}>; + }> = []; + let totalMatches = 0; + let filesScanned = 0; + const maxScanFiles = 1000; + + // 收集所有需要搜索的文件 + async function collectFiles(dir: string): Promise { + const files: string[] = []; + const items = await fs.readdir(dir, { withFileTypes: true }); + for (const item of items) { + if (filesScanned >= maxScanFiles) return files; + if (item.name.startsWith('.')) continue; + const fullPath = path.join(dir, item.name); + if (item.isDirectory()) { + files.push(...(await collectFiles(fullPath))); + } else { + if (params.file_extensions?.length) { + const ext = path.extname(item.name); + if (!params.file_extensions.includes(ext)) continue; + } + files.push(fullPath); + filesScanned++; + } + } + return files; + } + + const allFiles = await collectFiles(rootPath); + + for (const filePath of allFiles) { + if (totalMatches >= maxResults) break; + + // 文件名搜索 + if (searchType === 'filename' || searchType === 'both') { + const fileName = path.basename(filePath); + const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase(); + if (nameToCheck.includes(query)) { + results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] }); + totalMatches++; + continue; // 文件名匹配就不搜内容了 + } + } + + // 内容搜索 + if (searchType === 'content' || searchType === 'both') { + const stat = await fs.stat(filePath); + if (stat.size > 500 * 1024) continue; // 跳过 >500KB 的文件 + + const content = await fs.readFile(filePath, 'utf-8'); + const lines = content.split('\n'); + const fileMatches: Array<{line: number; text: string; column: number}> = []; + + for (let i = 0; i < lines.length && fileMatches.length < 10; i++) { + const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase(); + const col = lineToCheck.indexOf(query); + if (col !== -1) { + fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 }); + totalMatches++; + } + } + + if (fileMatches.length > 0) { + results.push({ path: filePath, matches: fileMatches }); + } + } + } + + return { + success: true, + query: params.query, + search_type: searchType, + results, + total_files: allFiles.length, + total_matches: totalMatches, + truncated: totalMatches >= maxResults + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleCreateDir(params: {path: string}): Promise { + try { + const dirPath = path.resolve(params.path); + const allowed = checkPathAllowed(dirPath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + await fs.mkdir(dirPath, { recursive: true }); + + return { success: true, path: dirPath, created: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleDeleteFile(params: DeleteParams): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const stat = await fs.stat(filePath); + + if (stat.isDirectory()) { + if (params.recursive) { + await fs.rm(filePath, { recursive: true, force: true }); + } else { + await fs.rmdir(filePath); // 只能删空目录 + } + } else { + await fs.unlink(filePath); + } + + return { success: true, path: filePath, deleted: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleRunCommand(params: RunCommandParams): Promise { + try { + // 命令安全检查 + const cmdCheck = checkCommandAllowed(params.command); + if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason }; + + const timeout = Math.min(params.timeout || 30000, 120000); + const cwd = params.cwd ? path.resolve(params.cwd) : process.env.HOME || '/'; + + const cwdAllowed = checkPathAllowed(cwd, 'read'); + if (!cwdAllowed.ok) return { success: false, error: cwdAllowed.reason }; + + const start = Date.now(); + const { stdout, stderr } = await execAsync(params.command, { + cwd, + timeout, + maxBuffer: 100 * 1024 // 100KB + }); + + return { + success: true, + stdout: stdout.slice(0, 100 * 1024), + stderr: stderr.slice(0, 100 * 1024), + exitCode: 0, + duration: Date.now() - start + }; + } catch (err) { + const error = err as {stdout?: string; stderr?: string; code?: number; message: string}; + return { + success: false, + stdout: error.stdout?.slice(0, 100 * 1024) || '', + stderr: error.stderr?.slice(0, 100 * 1024) || error.message, + exitCode: error.code || 1, + error: error.message + }; + } +} +``` + +#### `src/main/tool-security.ts` + +```typescript +/** + * Tool Security - 安全检查模块 + * 路径白名单/黑名单、命令过滤、路径遍历检测 + */ + +import * as path from 'path'; +import * as os from 'os'; + +// ── 配置 ── + +const HOME = os.homedir(); + +/** 默认允许的目录(可通过设置覆盖) */ +let allowedDirs: string[] = [ + HOME, + path.join(HOME, 'Desktop'), + path.join(HOME, 'Documents'), + path.join(HOME, 'Downloads'), + path.join(HOME, 'Projects'), + path.join(HOME, 'projects'), + '/tmp', +]; + +/** 永久禁止的目录 */ +const BLOCKED_DIRS = [ + '/etc', '/sys', '/proc', '/dev', '/boot', '/root', + 'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData', + path.join(HOME, '.ssh'), + path.join(HOME, '.gnupg'), + path.join(HOME, '.aws'), + path.join(HOME, '.config/openclaw'), // 保护 OpenClaw 配置 +]; + +/** 命令黑名单 */ +const BLOCKED_COMMANDS = [ + 'rm -rf /', 'rm -rf /*', ':(){ :|:& };:', + 'mkfs', 'dd if=', 'wipefs', 'shred', + 'shutdown', 'reboot', 'poweroff', 'halt', + 'useradd', 'usermod', 'userdel', 'passwd', + 'chmod 777', 'chown root', + 'crontab -e', + 'systemctl enable', 'systemctl disable', + 'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash', + 'eval', 'exec >', +]; + +// ── 检查函数 ── + +interface CheckResult { + ok: boolean; + reason?: string; +} + +export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult { + const resolved = path.resolve(targetPath); + + // 检查路径遍历(多余的防御,path.resolve 已经处理) + // 但仍检查是否路径中包含大量 .. + if (targetPath.split(path.sep).filter(s => s === '..').length > 5) { + return { ok: false, reason: '路径遍历深度过大' }; + } + + // 检查黑名单 + for (const blocked of BLOCKED_DIRS) { + if (resolved === blocked || resolved.startsWith(blocked + path.sep)) { + return { ok: false, reason: `禁止访问受保护路径: ${blocked}` }; + } + } + + // 检查白名单(仅写操作严格检查) + if (operation === 'write') { + const inAllowedDir = allowedDirs.some(dir => + resolved === dir || resolved.startsWith(dir + path.sep) + ); + if (!inAllowedDir) { + return { ok: false, reason: `写操作被限制在允许的目录内。当前路径: ${resolved}` }; + } + } + + return { ok: true }; +} + +export function checkCommandAllowed(command: string): CheckResult { + const lowerCmd = command.toLowerCase().trim(); + + for (const blocked of BLOCKED_COMMANDS) { + if (lowerCmd.includes(blocked.toLowerCase())) { + return { ok: false, reason: `命令包含被禁止的操作: ${blocked}` }; + } + } + + // 禁止管道到 shell + if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(lowerCmd)) { + return { ok: false, reason: '禁止通过管道执行 shell 命令' }; + } + + // 禁止反弹 shell + if (/\/dev\/tcp\//i.test(lowerCmd) || /bash\s+-i\s+>&/i.test(lowerCmd)) { + return { ok: false, reason: '检测到疑似反弹 shell 操作' }; + } + + return { ok: true }; +} + +export function setAllowedDirs(dirs: string[]): void { + allowedDirs = dirs.map(d => path.resolve(d)); +} + +export function getAllowedDirs(): string[] { + return [...allowedDirs]; +} + +export function getBlockedDirs(): string[] { + return [...BLOCKED_DIRS]; +} +``` + +### 6.2 修改 `src/main/ipc.ts` + +新增工具调用的 IPC handler: + +```typescript +// 在 setupIPC() 函数中添加: + +import { + handleReadFile, + handleWriteFile, + handleListDir, + handleSearchFiles, + handleCreateDir, + handleDeleteFile, + handleRunCommand +} from './tool-handlers.js'; + +// 工具调用 IPC +ipcMain.handle('tool:execute', async (_, toolName: string, args: Record) => { + switch (toolName) { + case 'read_file': return handleReadFile(args as any); + case 'write_file': return handleWriteFile(args as any); + case 'list_directory': return handleListDir(args as any); + case 'search_files': return handleSearchFiles(args as any); + case 'create_directory': return handleCreateDir(args as any); + case 'delete_file': return handleDeleteFile(args as any); + case 'run_command': return handleRunCommand(args as any); + default: return { success: false, error: `未知工具: ${toolName}` }; + } +}); + +// 获取工具权限配置 +ipcMain.handle('tool:getConfig', () => ({ + allowedDirs: getAllowedDirs(), + blockedDirs: getBlockedDirs() +})); + +// 更新允许的目录 +ipcMain.handle('tool:setAllowedDirs', (_, dirs: string[]) => { + setAllowedDirs(dirs); +}); +``` + +### 6.3 修改 `src/main/preload.ts` + +新增工具调用 API 暴露: + +```typescript +// 在 contextBridge.exposeInMainWorld('metonaDesktop', { ... }) 中添加: + +tool: { + execute: (toolName: string, args: Record) => + ipcRenderer.invoke('tool:execute', toolName, args), + getConfig: () => + ipcRenderer.invoke('tool:getConfig'), + setAllowedDirs: (dirs: string[]) => + ipcRenderer.invoke('tool:setAllowedDirs', dirs) +} +``` + +--- + +## 7. 工具实现:渲染进程 + +### 7.1 新增文件 + +#### `src/renderer/services/tool-registry.ts` + +```typescript +/** + * Tool Registry - 工具注册与调度中心 + * 管理所有可用工具的定义,负责执行调度 + */ + +import type { ToolDefinition, ToolResult, ToolCall } from '../types.js'; + +// ── 工具定义 ── + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read the contents of a local file. Returns the file content as a string. Supports text files up to 1MB. Use start_line/end_line for large files.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'The file path to read. Absolute or relative.' }, + encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8' }, + start_line: { type: 'integer', description: 'Start line (1-indexed). For reading specific sections.' }, + end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 500.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'write_file', + description: 'Write content to a local file. Creates parent directories automatically. Overwrites existing files. Max 5MB content.', + parameters: { + type: 'object', + required: ['path', 'content'], + properties: { + path: { type: 'string', description: 'The file path to write to.' }, + content: { type: 'string', description: 'The content to write.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'list_directory', + description: 'List directory contents. Returns file names, types, sizes, and modification times.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'Directory path.' }, + 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.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'search_files', + description: 'Search files by name pattern or text content within files.', + parameters: { + type: 'object', + required: ['path', 'query'], + properties: { + path: { type: 'string', description: 'Root directory to search.' }, + query: { type: 'string', description: 'Search query (glob or text).' }, + 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.' }, + file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' } + } + } + } + }, + { + type: 'function', + function: { + name: 'create_directory', + description: 'Create a new directory. Creates parents automatically.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'Directory path to create.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'delete_file', + description: 'Delete a file or directory. Requires user confirmation.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'Path to delete.' }, + recursive: { type: 'boolean', description: 'Recursive delete for directories. DANGEROUS.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'run_command', + description: 'Execute a shell command. DANGEROUS: disabled by default.', + parameters: { + type: 'object', + required: ['command'], + properties: { + command: { type: 'string', description: 'Shell command to execute.' }, + cwd: { type: 'string', description: 'Working directory.' }, + timeout: { type: 'integer', description: 'Timeout ms. Max 120000.' } + } + } + } + } +]; + +// ── 需要用户确认的工具 ── + +const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory']; + +export function needsConfirmation(toolName: string): boolean { + return CONFIRM_TOOLS.includes(toolName); +} + +// ── 工具启用状态 ── + +let enabledTools: Set = new Set([ + 'read_file', 'list_directory', 'search_files', + 'write_file', 'create_directory', 'delete_file' + // 'run_command' 默认禁用 +]); + +export function setToolEnabled(toolName: string, enabled: boolean): void { + if (enabled) enabledTools.add(toolName); + else enabledTools.delete(toolName); +} + +export function isToolEnabled(toolName: string): boolean { + return enabledTools.has(toolName); +} + +export function getEnabledToolDefinitions(): ToolDefinition[] { + return TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name)); +} + +// ── 工具执行 ── + +export async function executeTool(toolName: string, args: Record): Promise { + if (!isToolEnabled(toolName)) { + return { success: false, error: `工具 ${toolName} 未启用` }; + } + + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) { + return { success: false, error: '工具调用仅支持桌面版' }; + } + + try { + const result = await bridge.tool.execute(toolName, args); + return result; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} +``` + +#### `src/renderer/services/agent-engine.ts` + +```typescript +/** + * Agent Engine - Agent Loop 核心引擎 + * 管理带工具调用的流式对话循环 + */ + +import { OllamaAPI } from '../api/ollama.js'; +import { state, KEYS } from '../state/state.js'; +import { + executeTool, + getEnabledToolDefinitions, + needsConfirmation +} from './tool-registry.js'; +import { showToast } from '../components/toast.js'; +import type { + OllamaMessage, + OllamaStreamChunk, + ToolCall, + ToolResult, + ChatMessage +} from '../types.js'; + +// ── 回调接口 ── + +export interface AgentCallbacks { + onThinking: (text: string) => void; // 推理内容更新 + onContent: (text: string) => void; // 回答内容更新 + onToolCallStart: (call: ToolCall) => void; // 工具调用开始 + onToolCallResult: (name: string, result: ToolResult, call: ToolCall) => void; // 工具结果 + onToolCallError: (name: string, error: string, call: ToolCall) => void; // 工具错误 + onDone: (finalContent: string) => void; // 循环结束 + onConfirmTool: (call: ToolCall) => Promise; // 用户确认回调 +} + +// ── Agent Loop ── + +export async function runAgentLoop( + userContent: string, + images: string[], + callbacks: AgentCallbacks +): Promise { + const api = state.get(KEYS.API); + const currentSession = state.get(KEYS.CURRENT_SESSION); + const model = currentSession?.model || state.get('_defaultModel', ''); + + if (!api || !model) { + showToast('请先选择模型', 'error'); + return; + } + + // 构建消息历史 + const messages: OllamaMessage[] = []; + + // 注入系统提示词(如果启用) + if (state.get(KEYS.SYSTEM_PROMPT_ENABLED)) { + const systemPrompt = state.get(KEYS.SYSTEM_PROMPT, ''); + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + } + + // 添加历史消息(最近 N 条) + const historyMessages = currentSession?.messages || []; + const maxHistory = 20; // 最近 20 条 + const recentHistory = historyMessages.slice(-maxHistory); + + for (const msg of recentHistory) { + messages.push({ + role: msg.role, + content: msg.content, + ...(msg.images?.length && { images: msg.images }) + }); + } + + // 添加当前用户消息 + const userMsg: OllamaMessage = { role: 'user', content: userContent }; + if (images?.length) userMsg.images = images; + messages.push(userMsg); + + // 获取启用的工具定义 + const tools = getEnabledToolDefinitions(); + const useTools = tools.length > 0; + + // Agent Loop + let loopCount = 0; + const maxLoops = 10; // 防止无限循环 + + while (loopCount < maxLoops) { + loopCount++; + + // 流式请求 + let thinking = ''; + let content = ''; + const toolCalls: ToolCall[] = []; + + const abortController = new AbortController(); + state.set(KEYS.ABORT_CONTROLLER, abortController); + + try { + await api.chatStream( + { + model, + messages, + stream: true, + think: state.get('thinkEnabled', false), + options: { + num_ctx: state.get(KEYS.NUM_CTX, 24576), + temperature: state.get('temperature', 0.7) + }, + ...(useTools && { tools }) + }, + (chunk: OllamaStreamChunk) => { + // 累积 thinking + if (chunk.message?.thinking) { + thinking += chunk.message.thinking; + callbacks.onThinking(thinking); + } + + // 累积 content + if (chunk.message?.content) { + content += chunk.message.content; + callbacks.onContent(content); + } + + // 累积 tool_calls + if (chunk.message?.tool_calls?.length) { + for (const tc of chunk.message.tool_calls) { + // Ollama 流式下,第一次 chunk 有 name,后续只有 arguments 增量 + if (tc.function?.name) { + // 新的工具调用 + toolCalls.push({ + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments || {} + } + }); + } else if (toolCalls.length > 0) { + // 增量更新最后一个 tool_call 的 arguments + const last = toolCalls[toolCalls.length - 1]; + if (tc.function?.arguments) { + // 合并 arguments(Ollama 流式下 arguments 是对象) + if (typeof tc.function.arguments === 'object') { + Object.assign(last.function.arguments, tc.function.arguments); + } + } + } + } + } + }, + abortController + ); + } catch (err) { + if (abortController.signal.aborted) { + // 用户手动停止 + if (content || thinking) { + messages.push({ + role: 'assistant', + content, + ...(thinking && { thinking }) + }); + } + callbacks.onDone(content); + return; + } + throw err; + } + + // 将 assistant 消息加入历史 + const assistantMsg: OllamaMessage = { + role: 'assistant', + content, + ...(thinking && { thinking }) + }; + if (toolCalls.length > 0) { + (assistantMsg as any).tool_calls = toolCalls; + } + messages.push(assistantMsg); + + // 检查是否有工具调用 + if (toolCalls.length === 0) { + // 没有工具调用 → 循环结束 + callbacks.onDone(content); + return; + } + + // 执行工具调用 + for (const call of toolCalls) { + callbacks.onToolCallStart(call); + + // 确认检查 + if (needsConfirmation(call.function.name)) { + const confirmed = await callbacks.onConfirmTool(call); + if (!confirmed) { + messages.push({ + role: 'tool', + tool_name: call.function.name, + content: JSON.stringify({ success: false, error: '用户取消了操作' }) + } as any); + callbacks.onToolCallError(call.function.name, '用户取消', call); + continue; + } + } + + // 执行工具 + try { + const result = await executeTool(call.function.name, call.function.arguments); + messages.push({ + role: 'tool', + tool_name: call.function.name, + content: JSON.stringify(result) + } as any); + callbacks.onToolCallResult(call.function.name, result, call); + } catch (err) { + const errorMsg = (err as Error).message; + messages.push({ + role: 'tool', + tool_name: call.function.name, + content: JSON.stringify({ success: false, error: errorMsg }) + } as any); + callbacks.onToolCallError(call.function.name, errorMsg, call); + } + } + // 回到循环顶部,继续请求模型 + } + + // 达到最大循环次数 + callbacks.onDone(content || '(达到最大工具调用次数限制)'); +} +``` + +### 7.2 修改 `src/renderer/api/ollama.ts` + +现有的 `chatStream` 方法需要支持 `tools` 参数。检查当前实现,确保以下功能: + +```typescript +// chatStream 方法签名需支持 tools +async chatStream( + params: { + model: string; + messages: OllamaMessage[]; + stream?: boolean; + think?: boolean; + system?: string; + tools?: ToolDefinition[]; // ← 新增 + options?: { num_ctx?: number; temperature?: number; [key: string]: unknown }; + }, + onChunk: (chunk: OllamaStreamChunk) => void, + abortController?: AbortController +): Promise +``` + +请求 body 中需要加入 `tools`: + +```typescript +const body = { + model: params.model, + messages: params.messages, + stream: true, + ...(params.think !== undefined && { think: params.think }), + ...(params.system && { system: params.system }), + ...(params.tools?.length && { tools: params.tools }), + ...(params.options && { options: params.options }) +}; +``` + +### 7.3 修改 `src/renderer/types.d.ts` + +新增类型定义: + +```typescript +// ── Tool Calling 类型 ── + +export interface ToolParameterProperty { + type: string; + description?: string; + enum?: string[]; + items?: { type: string }; +} + +export interface ToolParameters { + type: 'object'; + required?: string[]; + properties: Record; +} + +export interface ToolFunction { + name: string; + description: string; + parameters: ToolParameters; +} + +export interface ToolDefinition { + type: 'function'; + function: ToolFunction; +} + +export interface ToolCall { + type: 'function'; + function: { + name: string; + arguments: Record; + }; +} + +export interface ToolResult { + success: boolean; + error?: string; + [key: string]: unknown; +} + +// 扩展 OllamaStreamChunk +export interface OllamaStreamChunk { + model?: string; + message?: { + role: string; + content?: string; + thinking?: string; + reasoning_content?: string; + tool_calls?: ToolCall[]; // ← 新增 + }; + done?: boolean; + eval_count?: number; + total_duration?: number; +} + +// 扩展 OllamaMessage +export interface OllamaMessage { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + images?: string[]; + thinking?: string; + tool_calls?: ToolCall[]; // ← 新增 + tool_name?: string; // ← 新增(role=tool 时) +} + +// 扩展 ChatMessage(本地存储的消息) +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; + model?: string; + think?: string; + // ... 已有字段 + toolCalls?: Array<{ // ← 新增 + name: string; + arguments: Record; + result: ToolResult; + }>; +} + +// 扩展 Window 接口 +declare global { + interface Window { + metonaDesktop?: MetonaDesktopAPI & { + tool: { + execute: (toolName: string, args: Record) => Promise; + getConfig: () => Promise<{ allowedDirs: string[]; blockedDirs: string[] }>; + setAllowedDirs: (dirs: string[]) => Promise; + }; + }; + } +} +``` + +--- + +## 8. Agent Loop 引擎 + +### 8.1 循环状态机 + +``` + ┌──────────┐ + │ IDLE │ 等待用户输入 + └────┬─────┘ + │ 用户发送消息 + ▼ + ┌──────────┐ + │ SENDING │ 流式请求 Ollama + └────┬─────┘ + │ chunk 到达 + ▼ + ┌───────────────┐ + │ ACCUMULATING │ 累积 thinking/content/tool_calls + └───────┬───────┘ + │ 流结束 + ▼ + ┌───────────────┐ + │ HAS_TOOLS? │ 有 tool_calls? + └──┬─────────┬──┘ + │ │ + 是 │ │ 否 + │ ▼ + │ ┌─────────┐ + │ │ DONE │ 返回最终 content + │ └─────────┘ + ▼ + ┌──────────────┐ + │ EXECUTING │ 执行工具 + └──────┬───────┘ + │ 需要确认? + ▼ + ┌──────────────┐ + │ CONFIRMING │ 弹出确认对话框 + └──────┬───────┘ + │ 用户确认/取消 + ▼ + ┌──────────────┐ + │ COLLECTING │ 收集所有工具结果 + └──────┬───────┘ + │ 全部执行完 + ▼ + 回到 SENDING(下一轮循环) +``` + +### 8.2 流式工具调用的 arguments 累积策略 + +Ollama 的流式工具调用中,arguments 的到达方式有几种情况: + +**情况 A:单 chunk 完整返回** +```json +{"message": {"tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "test.txt"}}}]}} +``` + +**情况 B:跨多个 chunk(arguments 增量)** +```json +// chunk 1 +{"message": {"tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "te"}}}]}} +// chunk 2 +{"message": {"tool_calls": [{"function": {"arguments": {"path": "st.txt"}}}]}} +``` + +**处理策略**: + +```typescript +const pendingToolCalls: Map}> = new Map(); + +function handleToolCallChunk(toolCalls: any[]) { + for (const tc of toolCalls) { + if (tc.function?.name) { + // 新工具调用 + const index = pendingToolCalls.size; + pendingToolCalls.set(index, { + name: tc.function.name, + args: tc.function.arguments || {} + }); + } else if (pendingToolCalls.size > 0) { + // 增量更新最后一个 + const lastIndex = pendingToolCalls.size - 1; + const pending = pendingToolCalls.get(lastIndex)!; + if (tc.function?.arguments && typeof tc.function.arguments === 'object') { + Object.assign(pending.args, tc.function.arguments); + } + } + } +} +``` + +### 8.3 消息历史管理 + +Agent Loop 会产生大量消息(用户 + assistant + tool),需要注意: + +1. **上下文窗口限制**:`num_ctx` 控制总 token 数,过多 tool 消息可能导致溢出 +2. **截断策略**:保留系统提示词 + 最近 N 轮对话 + 当前工具调用链 +3. **tool 消息压缩**:如果工具结果过长,可以截断或摘要 + +```typescript +function trimMessagesForContext(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] { + // 简单策略:保留 system + 最后 N 条 + const systemMsg = messages.find(m => m.role === 'system'); + const nonSystem = messages.filter(m => m.role !== 'system'); + + // 估计 token 数(粗略:1 中文字 ≈ 2 token,1 英文词 ≈ 1.3 token) + let estimatedTokens = 0; + const kept: OllamaMessage[] = []; + + for (let i = nonSystem.length - 1; i >= 0; i--) { + const msg = nonSystem[i]; + const msgTokens = Math.ceil(msg.content.length * 1.5); + if (estimatedTokens + msgTokens > maxTokens * 0.8) break; // 留 20% 余量 + estimatedTokens += msgTokens; + kept.unshift(msg); + } + + if (systemMsg) kept.unshift(systemMsg); + return kept; +} +``` + +--- + +## 9. UI/UX 设计 + +### 9.1 工具调用卡片 + +当 AI 调用工具时,在聊天区域显示一个可视化的工具调用卡片: + +``` +┌─ 📁 read_file ──────────────────────────────────────┐ +│ 路径: src/main/main.ts │ +│ 状态: ✅ 完成 │ +│ ─────────────────────────────────────────────────── │ +│ 1 │ import { app, BrowserWindow } from 'electron'; │ +│ 2 │ import * as path from 'path'; │ +│ 3 │ import * as fs from 'fs'; │ +│ │ ... (共 156 行) │ +│ ─────────────────────────────────────────────────── │ +│ 📄 4.2KB · 156 行 · utf-8 │ +└─────────────────────────────────────────────────────┘ + +┌─ ✏️ write_file ─────────────────────────────────────┐ +│ 路径: src/main/config.ts │ +│ 状态: ⏳ 等待确认 │ +│ ─────────────────────────────────────────────────── │ +│ 内容预览 (前 5 行): │ +│ export const config = { │ +│ port: 3000, │ +│ debug: true, │ +│ }; │ +│ ─────────────────────────────────────────────────── │ +│ [✅ 确认写入] [❌ 取消] │ +└─────────────────────────────────────────────────────┘ + +┌─ 🔍 search_files ──────────────────────────────────┐ +│ 查询: "import electron" │ +│ 路径: src/ │ +│ 状态: ✅ 完成 │ +│ ─────────────────────────────────────────────────── │ +│ 📄 src/main/main.ts:3 import { app, Browser... │ +│ 📄 src/main/menu.ts:4 import { Menu, dialog... │ +│ 📄 src/main/tray.ts:4 import { Tray, Menu... │ +│ ... 共 5 个文件,12 处匹配 │ +└─────────────────────────────────────────────────────┘ + +┌─ 💻 run_command ────────────────────────────────────┐ +│ 命令: npm run build │ +│ 工作目录: /home/user/metona-ollama │ +│ 状态: ✅ 完成 (耗时 3.2s) │ +│ ─────────────────────────────────────────────────── │ +│ > vite v5.4.21 building for production... │ +│ > ✓ 25 modules transformed. │ +│ > ✓ built in 349ms │ +│ exit code: 0 │ +└─────────────────────────────────────────────────────┘ +``` + +### 9.2 卡片状态 + +| 状态 | 图标 | 颜色 | 说明 | +|------|------|------|------| +| 等待确认 | ⏳ | 橙色 | 等待用户确认执行 | +| 执行中 | 🔄 | 蓝色 | 正在执行,带 spinner | +| 完成 | ✅ | 绿色 | 执行成功 | +| 失败 | ❌ | 红色 | 执行失败,显示错误信息 | +| 已取消 | 🚫 | 灰色 | 用户取消了操作 | + +### 9.3 确认对话框 + +对于写操作/删除/命令执行,弹出模态确认框: + +``` +┌───────────────────────────────────────────────────┐ +│ ⚠️ 确认操作 │ +├───────────────────────────────────────────────────┤ +│ │ +│ AI 请求执行以下操作: │ +│ │ +│ 操作类型:写入文件 │ +│ 文件路径:src/main/config.ts │ +│ 内容大小:256 bytes │ +│ │ +│ ┌─ 内容预览 ──────────────────────────────────┐ │ +│ │ export const config = { │ │ +│ │ port: 3000, │ │ +│ │ debug: true, │ │ +│ │ }; │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ☐ 对此类操作不再询问(本次会话) │ +│ │ +│ [❌ 取消] [✅ 确认执行] │ +└───────────────────────────────────────────────────┘ +``` + +### 9.4 修改 `src/renderer/components/chat-area.ts` + +需要修改消息渲染逻辑,识别包含工具调用的消息: + +```typescript +function renderMessage(msg: ChatMessage): HTMLElement { + const el = document.createElement('div'); + el.className = `message message-${msg.role}`; + + // 普通内容 + if (msg.content) { + const contentEl = document.createElement('div'); + contentEl.className = 'message-content'; + contentEl.innerHTML = renderMarkdown(msg.content); + el.appendChild(contentEl); + } + + // Think 推理 + if (msg.think) { + const thinkEl = createThinkBlock(msg.think); + el.appendChild(thinkEl); + } + + // Tool Calls(新增) + if (msg.toolCalls?.length) { + for (const tc of msg.toolCalls) { + const toolCard = createToolCallCard(tc); + el.appendChild(toolCard); + } + } + + return el; +} + +function createToolCallCard(tc: ToolCallRecord): HTMLElement { + const card = document.createElement('div'); + card.className = `tool-call-card tool-call-${tc.status}`; + + const icon = getToolIcon(tc.name); + card.innerHTML = ` +
+ ${icon} + ${formatToolName(tc.name)} + ${getStatusBadge(tc.status)} +
+
${formatParams(tc.arguments)}
+ ${tc.result ? `
${formatResult(tc.name, tc.result)}
` : ''} + `; + + // 确认按钮 + if (tc.status === 'pending') { + const actions = document.createElement('div'); + actions.className = 'tool-call-actions'; + actions.innerHTML = ` + + + `; + card.appendChild(actions); + } + + return card; +} +``` + +### 9.5 CSS 样式 + +```css +/* ── 工具调用卡片 ── */ + +.tool-call-card { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin: 8px 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); +} + +.tool-call-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 13px; +} + +.tool-call-icon { font-size: 16px; } +.tool-call-name { font-weight: 600; color: var(--accent-cyan); } +.tool-call-status { margin-left: auto; font-size: 12px; } + +.tool-call-params { + padding: 8px 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + font-family: 'Cascadia Code', 'Consolas', monospace; +} + +.tool-call-result { + padding: 8px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 12px; + font-family: 'Cascadia Code', 'Consolas', monospace; + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* 状态颜色 */ +.tool-call-pending { border-color: rgba(255, 165, 0, 0.4); } +.tool-call-running { border-color: rgba(0, 245, 212, 0.4); } +.tool-call-success { border-color: rgba(0, 200, 83, 0.4); } +.tool-call-error { border-color: rgba(255, 82, 82, 0.4); } +.tool-call-cancelled { border-color: rgba(150, 150, 150, 0.4); } + +/* 确认按钮区域 */ +.tool-call-actions { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +/* 执行中 spinner */ +.tool-call-running .tool-call-header::after { + content: ''; + width: 14px; + height: 14px; + border: 2px solid rgba(0, 245, 212, 0.3); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +``` + +--- + +## 10. 安全模型 + +### 10.1 威胁分析 + +| 威胁 | 风险等级 | 缓解措施 | +|------|----------|----------| +| AI 读取敏感文件(.ssh, .env) | 🔴 高 | 路径黑名单 + 白名单目录 | +| AI 写入恶意代码到项目 | 🟡 中 | 用户确认 + 内容预览 | +| AI 删除重要文件 | 🔴 高 | 用户确认 + 回收站优先 | +| AI 执行破坏性命令 | 🔴 高 | 默认禁用 + 命令黑名单 + 确认 | +| 路径遍历攻击(../../etc/passwd) | 🟡 中 | path.resolve() + 黑名单 | +| AI 泄露文件内容到外部 | 🟢 低 | 工具仅限本地操作,无网络工具 | +| 提示词注入诱导 AI 操作文件 | 🟡 中 | 系统提示词中加入安全指令 | + +### 10.2 系统提示词安全注入 + +当启用工具调用时,在 system prompt 中追加安全指令: + +```typescript +function buildSystemPromptWithTools(basePrompt: string): string { + const toolSafetyPrompt = ` +[工具使用安全规则] +1. 你可以在本地文件系统中读取和操作文件。 +2. 在执行任何写操作(写文件、删除文件)之前,先向用户说明你要做什么以及为什么。 +3. 不要读取或修改任何包含密码、密钥、令牌等敏感信息的文件(如 .env, .ssh/*, credentials)。 +4. 不要执行任何可能破坏系统的命令。 +5. 如果用户要求你执行看起来有风险的操作,请先警告用户。 +6. 文件操作完成后,向用户汇报操作结果。 +7. 每次工具调用都要考虑是否真的需要,不要做不必要的文件操作。 +`; + + return basePrompt ? `${basePrompt}\n\n${toolSafetyPrompt}` : toolSafetyPrompt; +} +``` + +### 10.3 安全检查流程 + +``` +AI 返回 tool_calls + │ + ▼ + ┌─────────────┐ + │ 参数校验 │ JSON Schema 验证 + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 路径安全检查 │ path.resolve() → 黑名单/白名单 + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 权限检查 │ 该工具是否启用? + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 用户确认 │ 高风险操作弹确认框 + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 执行工具 │ 主进程 Node.js fs + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 结果过滤 │ 截断过长输出、脱敏 + └──────┴──────┘ +``` + +### 10.4 路径安全详细规则 + +```typescript +// 1. 解析为绝对路径(消除 ../ 和符号链接) +const resolved = path.resolve(inputPath); + +// 2. 检查黑名单 +for (const blocked of BLOCKED_DIRS) { + if (resolved.startsWith(blocked)) { + throw new Error(`禁止访问: ${blocked}`); + } +} + +// 3. 写操作额外检查白名单 +if (operation === 'write') { + const inWhitelist = allowedDirs.some(dir => resolved.startsWith(dir)); + if (!inWhitelist) { + throw new Error(`写操作仅限允许的目录`); + } +} + +// 4. 检查文件名模式 +const dangerousPatterns = [ + /\.env$/i, // 环境变量文件 + /\.pem$/i, // 证书文件 + /\.key$/i, // 密钥文件 + /id_rsa/i, // SSH 密钥 + /\.gnupg/i, // GPG 目录 + /credentials/i, // 凭证文件 + /password/i, // 密码文件 +]; +for (const pattern of dangerousPatterns) { + if (pattern.test(resolved)) { + throw new Error(`疑似敏感文件,已阻止访问`); + } +} +``` + +--- + +## 11. 类型定义 + +### 11.1 完整类型(追加到 `types.d.ts`) + +```typescript +// ═══════════════════════════════════════════════════════════ +// Tool Calling 类型 +// ═══════════════════════════════════════════════════════════ + +export interface ToolParameterProperty { + type: string; + description?: string; + enum?: string[]; + items?: { type: string }; +} + +export interface ToolParameters { + type: 'object'; + required?: string[]; + properties: Record; +} + +export interface ToolFunctionDefinition { + name: string; + description: string; + parameters: ToolParameters; +} + +export interface ToolDefinition { + type: 'function'; + function: ToolFunctionDefinition; +} + +export interface ToolCall { + type: 'function'; + function: { + name: string; + arguments: Record; + }; +} + +export interface ToolResult { + success: boolean; + error?: string; + [key: string]: unknown; +} + +// 聊天消息中记录的工具调用 +export interface ToolCallRecord { + name: string; + arguments: Record; + result: ToolResult | null; + status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'; + confirmed?: boolean; + timestamp: number; +} + +// Agent Loop 状态 +export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done'; + +// 扩展 ChatMessage +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; + model?: string; + think?: string; + eval_count?: number; + total_duration?: number; + images?: string[]; + files?: ChatFile[]; + _fileContents?: FileContent[]; + ragSources?: RagSource[]; + stopped?: boolean; + toolCalls?: ToolCallRecord[]; // ← 新增 +} + +// 扩展 OllamaMessage +export interface OllamaMessage { + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + images?: string[]; + thinking?: string; + tool_calls?: ToolCall[]; + tool_name?: string; +} + +// 扩展 OllamaChatParams +export interface OllamaChatParams { + model: string; + messages: OllamaMessage[]; + stream?: boolean; + think?: boolean; + system?: string; + tools?: ToolDefinition[]; // ← 新增 + keep_alive?: number | string; + options?: { + num_ctx?: number; + temperature?: number; + [key: string]: unknown; + }; +} + +// 工具配置 +export interface ToolConfig { + enabled: boolean; + enabledTools: string[]; + allowedDirs: string[]; + blockedDirs: string[]; + confirmationMode: 'all' | 'write-only' | 'none'; + runCommandEnabled: boolean; +} + +// 扩展 MetonaDesktopAPI +export interface MetonaDesktopAPI { + // ... 已有字段 + tool: { + execute: (toolName: string, args: Record) => Promise; + getConfig: () => Promise; + setAllowedDirs: (dirs: string[]) => Promise; + setToolEnabled: (toolName: string, enabled: boolean) => Promise; + }; +} +``` + +--- + +## 12. 文件变更清单 + +### 12.1 新增文件 + +| 文件路径 | 说明 | +|----------|------| +| `src/main/tool-handlers.ts` | 主进程:工具执行器(7 个工具实现) | +| `src/main/tool-security.ts` | 主进程:安全检查(路径/命令过滤) | +| `src/renderer/services/tool-registry.ts` | 渲染进程:工具注册与调度 | +| `src/renderer/services/agent-engine.ts` | 渲染进程:Agent Loop 引擎 | +| `src/renderer/components/tool-confirm-modal.ts` | 渲染进程:确认对话框组件 | + +### 12.2 修改文件 + +| 文件路径 | 变更内容 | +|----------|----------| +| `src/main/ipc.ts` | 新增 `tool:execute`, `tool:getConfig`, `tool:setAllowedDirs` IPC handler | +| `src/main/preload.ts` | 暴露 `tool` API 到渲染进程 | +| `src/renderer/types.d.ts` | 新增 Tool Calling 相关类型定义 | +| `src/renderer/api/ollama.ts` | `chatStream` 支持 `tools` 参数 | +| `src/renderer/components/chat-area.ts` | 渲染工具调用卡片 | +| `src/renderer/components/input-area.ts` | Agent Loop 触发逻辑替代直接 chat | +| `src/renderer/components/settings-modal.ts` | 新增"工具调用"设置组 | +| `src/renderer/styles/style.css` | 工具调用卡片、确认对话框样式 | +| `src/renderer/main.ts` | 初始化工具配置 | + +### 12.3 不变的文件 + +| 文件路径 | 说明 | +|----------|------| +| `src/main/main.ts` | 无需修改 | +| `src/main/menu.ts` | 无需修改 | +| `src/main/tray.ts` | 无需修改 | +| `src/main/utils.ts` | 无需修改 | +| `src/renderer/db/chat-db.ts` | 无需修改(toolCalls 可存入 messages 数组) | +| `src/renderer/state/state.ts` | 无需修改 | +| `src/renderer/services/rag.ts` | 无需修改 | +| `src/renderer/services/vector-store.ts` | 无需修改 | + +--- + +## 13. 测试方案 + +### 13.1 单元测试用例 + +| 编号 | 测试场景 | 输入 | 预期输出 | +|------|----------|------|----------| +| T01 | 读取存在的文件 | `read_file("package.json")` | 返回文件内容,success=true | +| T02 | 读取不存在的文件 | `read_file("nonexistent.txt")` | success=false, error=文件不存在 | +| T03 | 读取超大文件 | `read_file("large.log")` (2MB) | success=false, error=文件过大 | +| T04 | 写入新文件 | `write_file("/tmp/test.txt", "hello")` | success=true, created=true | +| T05 | 覆盖已有文件 | `write_file("/tmp/test.txt", "world")` | success=true, created=false | +| T06 | 写入禁止目录 | `write_file("/etc/test", "x")` | success=false, error=禁止访问 | +| T07 | 路径遍历 | `read_file("../../etc/passwd")` | success=false, error=禁止访问 | +| T08 | 列出目录 | `list_directory("src/")` | 返回文件列表 | +| T09 | 递归列出 | `list_directory("src/", recursive=true, max_depth=2)` | 返回嵌套结构 | +| T10 | 搜索文件名 | `search_files("src/", "*.ts", "filename")` | 返回匹配文件 | +| T11 | 搜索内容 | `search_files("src/", "import electron", "content")` | 返回匹配行 | +| T12 | 创建目录 | `create_directory("/tmp/newdir/sub")` | success=true | +| T13 | 删除文件 | `delete_file("/tmp/test.txt")` | success=true | +| T14 | 删除禁止路径 | `delete_file("/etc/passwd")` | success=false | +| T15 | 执行安全命令 | `run_command("ls -la")` | 返回 stdout | +| T16 | 执行危险命令 | `run_command("rm -rf /")` | success=false, error=命令被禁止 | +| T17 | 禁用工具执行 | 禁用 read_file 后调用 | success=false, error=工具未启用 | +| T18 | Agent Loop 终止 | 模型不返回 tool_calls | 循环正常结束 | +| T19 | Agent Loop 最大次数 | 连续返回 tool_calls | 第 10 次后强制终止 | +| T20 | 并行工具调用 | 返回 2 个 tool_calls | 全部执行,结果正确 | + +### 13.2 集成测试场景 + +``` +场景 1: 项目代码审查 +用户: "帮我检查 src/main/ 目录下有没有未使用的 import" +预期: AI 调用 list_directory → 调用 read_file(逐个) → 给出分析结果 + +场景 2: 创建新文件 +用户: "帮我创建一个 utils/math.ts,包含 add 和 multiply 函数" +预期: AI 调用 write_file → 返回最终回答 + +场景 3: 搜索并修改 +用户: "找到所有 .ts 文件中使用 var 声明的地方,改成 const" +预期: AI 调用 search_files → 调用 read_file → 调用 write_file + +场景 4: 项目初始化 +用户: "帮我创建一个新的 Node.js 项目结构" +预期: AI 调用 create_directory(多个)→ 调用 write_file(多个) +``` + +### 13.3 安全测试 + +``` +安全 1: 尝试读取 ~/.ssh/id_rsa +预期: 被路径黑名单阻止 + +安全 2: 尝试写入 /etc/crontab +预期: 被路径黑名单阻止 + +安全 3: 尝试路径遍历 ../../etc/passwd +预期: path.resolve 后被黑名单捕获 + +安全 4: 尝试执行 curl | bash +预期: 被命令黑名单捕获 + +安全 5: 尝试执行反弹 shell +预期: 被命令模式检测捕获 +``` + +--- + +## 14. 兼容性与降级 + +### 14.1 不支持 Tool Calling 的模型 + +如果用户选择的模型不支持 Tool Calling: +- 不传 `tools` 参数,行为与 v2.0 完全一致 +- 工具调用 UI 不显示 +- Agent Loop 退化为普通流式对话 + +### 14.2 非桌面环境 + +如果未来恢复 Web 版支持: +- 工具调用功能自动禁用(`window.metonaDesktop.tool` 不存在) +- 提示用户"工具调用仅支持桌面版" + +### 14.3 设置迁移 + +v2.0 → v3.0 设置兼容: +- 新增的工具设置有默认值 +- 不影响已有设置 + +--- + +## 15. 未来扩展 + +### 15.1 更多工具 + +| 工具 | 说明 | 风险 | +|------|------|------| +| `web_search` | 网络搜索 | 🟢 低 | +| `web_fetch` | 获取网页内容 | 🟢 低 | +| `git_status` | Git 状态 | 🟢 低 | +| `git_diff` | Git 差异 | 🟢 低 | +| `git_commit` | Git 提交 | 🟡 中 | +| `process_list` | 列出进程 | 🟢 低 | +| `clipboard_read` | 读取剪贴板 | 🟡 中 | +| `clipboard_write` | 写入剪贴板 | 🟢 低 | + +### 15.2 工具扩展机制 + +设计插件化的工具注册系统: + +```typescript +// 允许第三方注册自定义工具 +toolRegistry.register({ + definition: { type: 'function', function: { name: 'my_tool', ... } }, + execute: async (args) => { ... }, + needsConfirmation: true, + category: 'custom' +}); +``` + +### 15.3 操作历史与回滚 + +- 记录所有工具操作的完整日志 +- 支持"撤销最后一次写操作"(备份机制) +- 操作历史面板(类似 IDE 的 Local History) + +### 15.4 MCP (Model Context Protocol) 集成 + +考虑支持 MCP 协议,可以使用社区的 MCP 工具服务器: +- 文件系统 MCP +- Git MCP +- 数据库 MCP +- Docker MCP + +--- + +## 16. 附录 + +### 16.1 Ollama Tool Calling 请求/响应完整示例 + +**请求**: + +```bash +curl -s http://localhost:11434/api/chat -d '{ + "model": "qwen3:8b", + "messages": [{"role": "user", "content": "读取 package.json 并告诉我版本号"}], + "stream": false, + "think": true, + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read file contents", + "parameters": { + "type": "object", + "required": ["path"], + "properties": { + "path": {"type": "string", "description": "File path"} + } + } + } + } + ] +}' +``` + +**响应**: + +```json +{ + "model": "qwen3:8b", + "created_at": "2026-04-06T03:00:00Z", + "message": { + "role": "assistant", + "content": "", + "thinking": "用户想要读取 package.json 文件并获取版本号。我需要使用 read_file 工具来读取文件内容。", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "read_file", + "arguments": { + "path": "package.json" + } + } + } + ] + }, + "done": true, + "total_duration": 1234567890, + "eval_count": 42 +} +``` + +**回传工具结果**: + +```bash +curl -s http://localhost:11434/api/chat -d '{ + "model": "qwen3:8b", + "messages": [ + {"role": "user", "content": "读取 package.json 并告诉我版本号"}, + { + "role": "assistant", + "thinking": "用户想要读取 package.json 文件...", + "tool_calls": [{"type": "function", "function": {"name": "read_file", "arguments": {"path": "package.json"}}}] + }, + {"role": "tool", "tool_name": "read_file", "content": "{\"name\":\"metona-ollama\",\"version\":\"2.0.0\"}"} + ], + "stream": false, + "think": true +}' +``` + +**最终响应**: + +```json +{ + "message": { + "role": "assistant", + "content": "项目的版本号是 **2.0.0**。这是 Metona Ollama Desktop 的 TypeScript + Electron 重构版本。", + "thinking": "工具返回了 package.json 的内容,我可以看到 version 字段是 2.0.0。" + }, + "done": true +} +``` + +### 16.2 流式 Tool Calling 完整示例 + +``` +chunk 1: {"message": {"role": "assistant", "thinking": "用户要读文件...", "content": ""}} +chunk 2: {"message": {"thinking": "我需要调用 read_file"}} +chunk 3: {"message": {"thinking": ""}} +chunk 4: {"message": {"tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "package.json"}}}]}} +chunk 5: {"message": {"done": true}} +``` + +### 16.3 关键参考 + +- Ollama Tool Calling 文档: https://docs.ollama.com/capabilities/tool-calling +- Ollama API 文档: https://docs.ollama.com/api +- OpenAI Function Calling 规范(Ollama 兼容): https://platform.openai.com/docs/guides/function-calling +- Electron Security Best Practices: https://www.electronjs.org/docs/latest/tutorial/security + +### 16.4 版本号 + +- 文档版本:1.0 +- 目标版本:Metona Ollama Desktop v3.0.0 +- 基于:v2.0.0 (TypeScript + Electron) +- 创建日期:2026-04-06