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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user