feat: v3.0 Tool Calling — AI 本地文件操作系统

新增文件:
- src/main/tool-security.ts: 路径白名单/黑名单、命令安全检查
- src/main/tool-handlers.ts: 7个工具实现(read/write/list/search/create/delete/run)
- src/renderer/services/tool-registry.ts: 工具注册调度中心
- src/renderer/services/agent-engine.ts: Agent Loop 流式多轮工具调用引擎
- src/renderer/components/tool-confirm-modal.ts: 高风险操作确认对话框

修改文件:
- types.d.ts: 新增 ToolCall/ToolResult/ToolCallRecord 等类型
- ollama.ts: chatStream 支持 tools 参数
- ipc.ts: 新增 tool:execute/getConfig/setAllowedDirs IPC
- preload.ts: 暴露 tool API
- chat-area.ts: 渲染工具调用卡片
- input-area.ts: 集成 Agent Loop 引擎
- settings-modal.ts: 工具调用开关设置
- index.html: 工具调用设置面板 + 确认对话框 HTML
- style.css: 工具调用卡片、确认对话框样式
- main.ts: 初始化工具调用配置
This commit is contained in:
thzxx
2026-04-06 13:29:43 +08:00
parent 0a1397771e
commit 5bfb137a8a
15 changed files with 1609 additions and 6 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* 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,
ToolCallRecord
} 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, toolRecords?: ToolCallRecord[]) => void;
onConfirmTool: (call: ToolCall) => Promise<boolean>;
}
export async function runAgentLoop(
userContent: string,
images: string[],
historyMessages: Array<{ role: string; content: string; images?: string[] }>,
callbacks: AgentCallbacks
): Promise<void> {
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', '');
if (!api || !model) {
showToast('请先选择模型', 'error');
return;
}
const messages: OllamaMessage[] = [];
if (state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED)) {
const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT, '');
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
}
for (const msg of historyMessages) {
messages.push({
role: msg.role as 'user' | 'assistant',
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;
let loopCount = 0;
const maxLoops = 10;
const allToolRecords: ToolCallRecord[] = [];
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<boolean>('thinkEnabled', false),
options: {
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576),
temperature: state.get<number>('temperature', 0.7)
},
...(useTools && { tools })
},
(chunk: OllamaStreamChunk) => {
if (chunk.message?.thinking) {
thinking += chunk.message.thinking;
callbacks.onThinking(thinking);
}
if (chunk.message?.content) {
content += chunk.message.content;
callbacks.onContent(content);
}
if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) {
toolCalls.push({
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments || {}
}
});
} else if (toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && 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, allToolRecords.length > 0 ? allToolRecords : undefined);
return;
}
throw err;
}
const assistantMsg: OllamaMessage = {
role: 'assistant',
content,
...(thinking && { thinking })
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
messages.push(assistantMsg);
if (toolCalls.length === 0) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined);
return;
}
for (const call of toolCalls) {
callbacks.onToolCallStart(call);
if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call);
if (!confirmed) {
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: '用户取消了操作' },
status: 'cancelled',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: '用户取消了操作' })
});
callbacks.onToolCallError(call.function.name, '用户取消', call);
continue;
}
}
try {
const result = await executeTool(call.function.name, call.function.arguments);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result,
status: result.success ? 'success' : 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(result)
});
callbacks.onToolCallResult(call.function.name, result, call);
} catch (err) {
const errorMsg = (err as Error).message;
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: errorMsg },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: errorMsg })
});
callbacks.onToolCallError(call.function.name, errorMsg, call);
}
}
}
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Tool Registry - 工具注册与调度中心
* 管理所有可用工具的定义,负责执行调度
*/
import type { ToolDefinition, ToolResult } 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, must be enabled in settings.',
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<string> = new Set([
'read_file', 'list_directory', 'search_files',
'write_file', 'create_directory', 'delete_file'
]);
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<string, unknown>): Promise<ToolResult> {
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 };
}
}
export function getToolIcon(name: string): string {
const icons: Record<string, string> = {
read_file: '📄',
write_file: '✏️',
list_directory: '📁',
search_files: '🔍',
create_directory: '📂',
delete_file: '🗑️',
run_command: '💻'
};
return icons[name] || '🔧';
}
export function formatToolName(name: string): string {
const names: Record<string, string> = {
read_file: '读取文件',
write_file: '写入文件',
list_directory: '列出目录',
search_files: '搜索文件',
create_directory: '创建目录',
delete_file: '删除文件',
run_command: '执行命令'
};
return names[name] || name;
}