feat: 工具调用扩展,新增 5 个实用工具(共 12 个)

新增工具:
- move_file: 移动/重命名文件(需确认)
- copy_file: 复制文件/目录(需确认)
- web_fetch: 抓取网页内容,自动提取文本(自动)
- append_file: 追加内容到文件末尾(需确认)
- edit_file: 文件内查找替换,支持全部替换(需确认)

同步更新:
- 主进程 IPC 分发
- 工具安全检查(路径白名单)
- 设置面板工具列表 UI
- 帮助文档工具说明
This commit is contained in:
OpenClaw Agent
2026-04-16 09:38:52 +08:00
parent 21990adf09
commit 4099b5180f
4 changed files with 254 additions and 6 deletions
+11 -1
View File
@@ -20,7 +20,12 @@ import {
handleCreateDir, handleCreateDir,
handleDeleteFile, handleDeleteFile,
handleRunCommand, handleRunCommand,
killToolProcess killToolProcess,
handleMoveFile,
handleCopyFile,
handleWebFetch,
handleAppendFile,
handleEditFile
} from './tool-handlers.js'; } from './tool-handlers.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
@@ -108,6 +113,11 @@ export function setupIPC(): void {
case 'create_directory': return await handleCreateDir(args as { path: string }); case 'create_directory': return await handleCreateDir(args as { path: string });
case 'delete_file': return await handleDeleteFile(args as { path: string; recursive?: boolean }); case 'delete_file': return await handleDeleteFile(args as { path: string; recursive?: boolean });
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number });
case 'move_file': return await handleMoveFile(args as { source: string; destination: string });
case 'copy_file': return await handleCopyFile(args as { source: string; destination: string; recursive?: boolean });
case 'web_fetch': return await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string });
case 'append_file': return await handleAppendFile(args as { path: string; content: string; newline?: boolean });
case 'edit_file': return await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean });
default: return { success: false, error: `未知工具: ${toolName}` }; default: return { success: false, error: `未知工具: ${toolName}` };
} }
} catch (err) { } catch (err) {
+142
View File
@@ -380,3 +380,145 @@ export function killToolProcess(): boolean {
sendLog('info', `🔧 cmd:kill`, '工具命令已终止'); sendLog('info', `🔧 cmd:kill`, '工具命令已终止');
return true; return true;
} }
export async function handleMoveFile(params: { source: string; destination: string }): Promise<ToolResult> {
try {
const src = resolvePath(params.source);
const dest = resolvePath(params.destination);
const srcCheck = checkPathAllowed(src, 'read');
if (!srcCheck.ok) return { success: false, error: srcCheck.reason };
const destCheck = checkPathAllowed(dest, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
await fs.rename(src, dest);
return { success: true, source: src, destination: dest };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleCopyFile(params: { source: string; destination: string; recursive?: boolean }): Promise<ToolResult> {
try {
const src = resolvePath(params.source);
const dest = resolvePath(params.destination);
const srcCheck = checkPathAllowed(src, 'read');
if (!srcCheck.ok) return { success: false, error: srcCheck.reason };
const destCheck = checkPathAllowed(dest, 'write');
if (!destCheck.ok) return { success: false, error: destCheck.reason };
const stat = await fs.stat(src);
if (stat.isDirectory()) {
await fs.cp(src, dest, { recursive: params.recursive !== false });
} else {
const destDir = path.dirname(dest);
await fs.mkdir(destDir, { recursive: true });
await fs.copyFile(src, dest);
}
return { success: true, source: src, destination: dest, isDirectory: stat.isDirectory() };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise<ToolResult> {
try {
const url = params.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { success: false, error: '仅支持 http/https 协议' };
}
const maxChars = params.max_chars || 10000;
const resp = await fetch(url, {
headers: { 'User-Agent': 'MetonaOllama/1.0' },
signal: AbortSignal.timeout(15000)
});
if (!resp.ok) {
return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` };
}
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('text') && !contentType.includes('html') && !contentType.includes('json') && !contentType.includes('xml')) {
return { success: false, error: `不支持的内容类型: ${contentType}` };
}
let text = await resp.text();
// 简单 HTML → 文本提取
if (contentType.includes('html')) {
text = text
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
const truncated = text.length > maxChars;
if (truncated) text = text.slice(0, maxChars);
return {
success: true,
url,
content: text,
content_type: contentType,
status: resp.status,
truncated,
length: text.length
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleAppendFile(params: { path: string; content: string; newline?: boolean }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
const prefix = params.newline !== false ? '\n' : '';
await fs.appendFile(filePath, prefix + params.content, 'utf-8');
const stat = await fs.stat(filePath);
return { success: true, path: filePath, newSize: stat.size };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleEditFile(params: { path: string; old_text: string; new_text: string; all?: boolean }): Promise<ToolResult> {
try {
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
const content = await fs.readFile(filePath, 'utf-8');
if (!content.includes(params.old_text)) {
return { success: false, error: '未找到要替换的文本' };
}
let newContent: string;
let replaceCount: number;
if (params.all) {
const parts = content.split(params.old_text);
replaceCount = parts.length - 1;
newContent = parts.join(params.new_text);
} else {
replaceCount = 1;
newContent = content.replace(params.old_text, params.new_text);
}
await fs.writeFile(filePath, newContent, 'utf-8');
return { success: true, path: filePath, replaceCount, newSize: newContent.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
+6 -1
View File
@@ -278,6 +278,11 @@
<div class="tool-item"><span>🔍 搜索文件</span><span class="text-muted">自动</span></div> <div class="tool-item"><span>🔍 搜索文件</span><span class="text-muted">自动</span></div>
<div class="tool-item"><span>📂 创建目录</span><span class="text-muted">需确认</span></div> <div class="tool-item"><span>📂 创建目录</span><span class="text-muted">需确认</span></div>
<div class="tool-item"><span>🗑️ 删除文件</span><span class="text-muted">需确认</span></div> <div class="tool-item"><span>🗑️ 删除文件</span><span class="text-muted">需确认</span></div>
<div class="tool-item"><span>📦 移动文件</span><span class="text-muted">需确认</span></div>
<div class="tool-item"><span>📋 复制文件</span><span class="text-muted">需确认</span></div>
<div class="tool-item"><span>🌐 网页抓取</span><span class="text-muted">自动</span></div>
<div class="tool-item"><span> 追加文件</span><span class="text-muted">需确认</span></div>
<div class="tool-item"><span>✂️ 编辑文件</span><span class="text-muted">需确认</span></div>
<div class="tool-item"> <div class="tool-item">
<span>💻 执行命令</span> <span>💻 执行命令</span>
<label class="toggle-label" style="margin:0;"> <label class="toggle-label" style="margin:0;">
@@ -373,7 +378,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div> <div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 设置中开启,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,内容以代码块发送给模型</li><li><strong>上下文长度</strong> — 设置中调整 <code>num_ctx</code>,越大记忆越长,消耗显存越多</li></ul></div> <div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 设置中开启,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,内容以代码块发送给模型</li><li><strong>上下文长度</strong> — 设置中调整 <code>num_ctx</code>,越大记忆越长,消耗显存越多</li></ul></div>
<div class="help-section"><h4>🔧 Tool CallingAI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>7 个工具</strong><code>read_file</code> / <code>write_file</code> / <code>list_directory</code> / <code>search_files</code> / <code>create_directory</code> / <code>delete_file</code> / <code>run_command</code></li><li>写入/删除/命令执行等高风险操作会弹出<strong>确认对话框</strong>,你可以批准或拒绝</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error</li><li>最多自动循环 <strong>10 轮</strong>工具调用,也可随时中断</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral</li></ul></div> <div class="help-section"><h4>🔧 Tool CallingAI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>12 个工具</strong><code>read_file</code> / <code>write_file</code> / <code>list_directory</code> / <code>search_files</code> / <code>create_directory</code> / <code>delete_file</code> / <code>move_file</code> / <code>copy_file</code> / <code>web_fetch</code> / <code>append_file</code> / <code>edit_file</code> / <code>run_command</code></li><li>写入/删除/命令执行等高风险操作会弹出<strong>确认对话框</strong>,你可以批准或拒绝</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error</li><li>最多自动循环 <strong>10 轮</strong>工具调用,也可随时中断</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>记忆存储在本地 IndexedDB,不会上传到任何服务器</li></ul></div> <div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>记忆存储在本地 IndexedDB,不会上传到任何服务器</li></ul></div>
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),多终端 Tab</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li></ul></li><li>工作空间面板宽度可通过拖拽左边缘调整(300–700px)</li><li>工作空间目录可在设置中修改</li></ul></div> <div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),多终端 Tab</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li></ul></li><li>工作空间面板宽度可通过拖拽左边缘调整(300–700px)</li><li>工作空间目录可在设置中修改</li></ul></div>
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 IndexedDB</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 Markdown / HTML / TXT / <code>.metona</code> 加密备份</li></ul></div> <div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 IndexedDB</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 Markdown / HTML / TXT / <code>.metona</code> 加密备份</li></ul></div>
+95 -4
View File
@@ -118,10 +118,90 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
} }
} }
} }
},
{
type: 'function',
function: {
name: 'move_file',
description: 'Move or rename a file/directory. Requires user confirmation.',
parameters: {
type: 'object',
required: ['source', 'destination'],
properties: {
source: { type: 'string', description: 'Source path.' },
destination: { type: 'string', description: 'Destination path.' }
}
}
}
},
{
type: 'function',
function: {
name: 'copy_file',
description: 'Copy a file or directory to a new location. Requires user confirmation.',
parameters: {
type: 'object',
required: ['source', 'destination'],
properties: {
source: { type: 'string', description: 'Source path.' },
destination: { type: 'string', description: 'Destination path.' },
recursive: { type: 'boolean', description: 'Copy directories recursively. Default: true.' }
}
}
}
},
{
type: 'function',
function: {
name: 'web_fetch',
description: 'Fetch content from a URL. Returns the page text content (HTML stripped). Supports HTTP/HTTPS.',
parameters: {
type: 'object',
required: ['url'],
properties: {
url: { type: 'string', description: 'URL to fetch (http/https).' },
max_chars: { type: 'integer', description: 'Max characters to return. Default: 10000.' },
extract_mode: { type: 'string', enum: ['text', 'markdown'], description: 'Extraction mode. Default: text.' }
}
}
}
},
{
type: 'function',
function: {
name: 'append_file',
description: 'Append content to the end of a file. Creates the file if it doesn\'t exist. Requires user confirmation.',
parameters: {
type: 'object',
required: ['path', 'content'],
properties: {
path: { type: 'string', description: 'File path to append to.' },
content: { type: 'string', description: 'Content to append.' },
newline: { type: 'boolean', description: 'Add a newline before appending. Default: true.' }
}
}
}
},
{
type: 'function',
function: {
name: 'edit_file',
description: 'Find and replace text in a file. More efficient than rewriting the whole file for small changes. Requires user confirmation.',
parameters: {
type: 'object',
required: ['path', 'old_text', 'new_text'],
properties: {
path: { type: 'string', description: 'File path to edit.' },
old_text: { type: 'string', description: 'Exact text to find.' },
new_text: { type: 'string', description: 'Replacement text.' },
all: { type: 'boolean', description: 'Replace all occurrences. Default: false (replace first only).' }
}
}
}
} }
]; ];
const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory']; const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory', 'move_file', 'copy_file', 'append_file', 'edit_file'];
export function needsConfirmation(toolName: string): boolean { export function needsConfirmation(toolName: string): boolean {
return CONFIRM_TOOLS.includes(toolName); return CONFIRM_TOOLS.includes(toolName);
@@ -130,7 +210,8 @@ export function needsConfirmation(toolName: string): boolean {
let enabledTools: Set<string> = new Set([ let enabledTools: Set<string> = new Set([
'read_file', 'list_directory', 'search_files', 'read_file', 'list_directory', 'search_files',
'write_file', 'create_directory', 'delete_file', 'write_file', 'create_directory', 'delete_file',
'run_command' // 默认启用,需要用户确认 'run_command',
'move_file', 'copy_file', 'web_fetch', 'append_file', 'edit_file'
]); ]);
export function setToolEnabled(toolName: string, enabled: boolean): void { export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -203,7 +284,12 @@ export function getToolIcon(name: string): string {
search_files: '🔍', search_files: '🔍',
create_directory: '📂', create_directory: '📂',
delete_file: '🗑️', delete_file: '🗑️',
run_command: '💻' run_command: '💻',
move_file: '📦',
copy_file: '📋',
web_fetch: '🌐',
append_file: '',
edit_file: '✂️'
}; };
return icons[name] || '🔧'; return icons[name] || '🔧';
} }
@@ -216,7 +302,12 @@ export function formatToolName(name: string): string {
search_files: '搜索文件', search_files: '搜索文件',
create_directory: '创建目录', create_directory: '创建目录',
delete_file: '删除文件', delete_file: '删除文件',
run_command: '执行命令' run_command: '执行命令',
move_file: '移动文件',
copy_file: '复制文件',
web_fetch: '网页抓取',
append_file: '追加文件',
edit_file: '编辑文件'
}; };
return names[name] || name; return names[name] || name;
} }