# 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