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);
}