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
+96 -1
View File
@@ -5,7 +5,7 @@
import { state, KEYS } from '../state/state.js';
import { marked } from '../utils/marked-config.js';
import { escapeHtml, formatTime, downloadFile } from '../utils/utils.js';
import type { ChatSession, ChatMessage } from '../types.js';
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
function getFileIcon(filename: string): string {
const name = filename.toLowerCase();
@@ -175,6 +175,14 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
contentHtml += `<div class="rag-sources"><div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}</div>`;
}
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
contentHtml += '<div class="tool-calls-container">';
for (const tc of msg.toolCalls) {
contentHtml += renderToolCallCard(tc);
}
contentHtml += '</div>';
}
div.innerHTML = `
<div class="msg-avatar">${avatar}</div>
<div class="msg-body">
@@ -186,6 +194,93 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
messagesContainerEl.appendChild(div);
}
function renderToolCallCard(tc: ToolCallRecord): string {
const icons: Record<string, string> = {
read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻'
};
const names: Record<string, string> = {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令'
};
const statusLabels: Record<string, string> = {
pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
};
const icon = icons[tc.name] || '🔧';
const name = names[tc.name] || tc.name;
const status = statusLabels[tc.status] || tc.status;
const args = tc.arguments || {};
let paramsHtml = '';
if (args.path) paramsHtml += `<div class="tool-param">路径: <code>${escapeHtml(String(args.path))}</code></div>`;
if (args.command) paramsHtml += `<div class="tool-param">命令: <code>${escapeHtml(String(args.command))}</code></div>`;
if (args.query) paramsHtml += `<div class="tool-param">查询: <code>${escapeHtml(String(args.query))}</code></div>`;
let resultHtml = '';
if (tc.result) {
if (tc.result.success) {
const r = tc.result as Record<string, unknown>;
if (tc.name === 'read_file' && r.content) {
const content = String(r.content);
const lines = content.split('\n');
const preview = lines.slice(0, 20).join('\n');
resultHtml = `<pre class="tool-result-content">${escapeHtml(preview)}${lines.length > 20 ? `\n... (共 ${r.lines || lines.length} 行)` : ''}</pre>`;
resultHtml += `<div class="tool-result-meta">📄 ${(r.size as number || 0) > 0 ? formatFileSize(r.size as number) : ''} · ${r.lines || '?'} 行</div>`;
} else if (tc.name === 'list_directory' && r.entries) {
const entries = r.entries as Array<{ name: string; type: string; size: number | null }>;
resultHtml = entries.slice(0, 30).map(e =>
`<div class="tool-result-entry">${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}${e.size != null ? ` (${formatFileSize(e.size)})` : ''}</div>`
).join('');
if (entries.length > 30) resultHtml += `<div class="tool-result-entry">... 共 ${(r.total as number) || entries.length} 项</div>`;
} else if (tc.name === 'search_files' && r.results) {
const results = r.results as Array<{ path: string; matches: Array<{ line: number; text: string }> }>;
resultHtml = results.slice(0, 10).map(file =>
file.matches.slice(0, 3).map(m =>
`<div class="tool-result-entry">📄 ${escapeHtml(file.path)}:${m.line} ${escapeHtml(m.text).slice(0, 80)}</div>`
).join('')
).join('');
resultHtml += `<div class="tool-result-meta">共 ${r.total_files || '?'} 个文件,${r.total_matches || '?'} 处匹配</div>`;
} else if (tc.name === 'write_file') {
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))} (${formatFileSize(r.bytesWritten as number || 0)})</div>`;
} else if (tc.name === 'create_directory') {
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
} else if (tc.name === 'delete_file') {
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
} else if (tc.name === 'run_command') {
const stdout = String(r.stdout || '').slice(0, 1000);
const stderr = String(r.stderr || '').slice(0, 500);
if (stdout) resultHtml += `<pre class="tool-result-content">${escapeHtml(stdout)}</pre>`;
if (stderr) resultHtml += `<pre class="tool-result-content tool-result-stderr">${escapeHtml(stderr)}</pre>`;
resultHtml += `<div class="tool-result-meta">exit: ${r.exitCode || 0} · ${r.duration || 0}ms</div>`;
} else {
resultHtml = `<div class="tool-result-entry">✅ 操作成功</div>`;
}
} else {
resultHtml = `<div class="tool-result-entry tool-result-error">❌ ${escapeHtml(tc.result.error || '未知错误')}</div>`;
}
}
return `<div class="tool-call-card tool-call-${tc.status}">
<div class="tool-call-header">
<span class="tool-call-icon">${icon}</span>
<span class="tool-call-name">${name}</span>
<span class="tool-call-status">${status}</span>
</div>
${paramsHtml ? `<div class="tool-call-params">${paramsHtml}</div>` : ''}
${resultHtml ? `<div class="tool-call-result">${resultHtml}</div>` : ''}
</div>`;
}
function formatFileSize(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
export function updateLastAssistantMessage(content: string, think: string | null, stats: { eval_count?: number; total_duration?: number } | null, model?: string): void {
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
+189 -1
View File
@@ -13,7 +13,9 @@ import { showToast } from './toast.js';
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile } from '../types.js';
import { runAgentLoop } from '../services/agent-engine.js';
import { showToolConfirm } from './tool-confirm-modal.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile, ToolCallRecord } from '../types.js';
let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement;
@@ -254,6 +256,15 @@ export async function sendMessage(): Promise<void> {
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (!currentSession) return;
// ── Tool Calling Agent Loop 分支 ──
const toolCallingEnabled = state.get<boolean>('toolCallingEnabled', false);
if (toolCallingEnabled) {
await sendMessageWithAgentLoop(text, currentSession, model);
return;
}
// ── 原有普通聊天流程 ──
const now = Date.now();
const msgsToAdd: ChatMessage[] = [];
@@ -520,6 +531,183 @@ export async function sendMessage(): Promise<void> {
}
}
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> {
const now = Date.now();
const msgsToAdd: ChatMessage[] = [];
const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
const images = pendingImages.map(img => img.base64);
if (pendingImages.length > 0) {
msgsToAdd.push({
role: 'user',
content: pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`,
images,
timestamp: now
});
}
if (text || pendingFiles.length > 0) {
const msg: ChatMessage = {
role: 'user',
content: text || '',
timestamp: now
};
if (userFiles.length > 0) {
msg.files = userFiles;
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
}
msgsToAdd.push(msg);
}
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
...(isFirstMsg && {
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
model
}),
messages: [...session.messages, ...msgsToAdd],
updatedAt: Date.now()
}));
const freshSession = state.get<ChatSession>(KEYS.CURRENT_SESSION);
enableAutoScroll();
renderMessages();
await saveCurrentSession();
chatInputEl.value = '';
pendingImages = [];
pendingFiles = [];
imagePreviewEl.style.display = 'none';
imagePreviewEl.innerHTML = '';
filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = '';
autoResizeTextarea();
appendAssistantPlaceholder();
state.set(KEYS.IS_STREAMING, true);
updateSendButton(true);
// 构建历史消息
const historyMessages = freshSession.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.slice(-20)
.map(m => {
let content = m.content || '';
if (m._fileContents && m._fileContents.length > 0) {
const fileParts = m._fileContents.map(f => `📄 文件:\n\`\`\`${f.language}\n${f.content}\n\`\`\``);
content = content ? content + '\n\n---\n' + fileParts.join('\n\n---\n') : fileParts.join('\n\n---\n');
}
return {
role: m.role,
content,
...(m.images?.length && { images: m.images })
};
});
let assistantContent = '';
let thinkContent = '';
try {
await runAgentLoop(text || (pendingFiles.length > 0 ? `请分析 ${pendingFiles.map(f => f.name).join(', ')}` : ''), images, historyMessages, {
onThinking: (thinking) => {
thinkContent = thinking;
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
},
onContent: (content) => {
assistantContent = content;
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
},
onToolCallStart: (_call) => {
// 实时更新已由 agent-engine 的 callbacks 处理
},
onToolCallResult: (_name, _result, _call) => {
// 工具结果实时更新
},
onToolCallError: (_name, _error, _call) => {
// 工具错误实时更新
},
onConfirmTool: async (call) => {
return showToolConfirm(call);
},
onDone: async (finalContent, toolRecords) => {
assistantContent = finalContent;
const assistantMsg: ChatMessage = {
role: 'assistant',
content: assistantContent || '',
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
...(toolRecords?.length && { toolCalls: toolRecords })
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
}));
updateLastAssistantMessage(assistantContent, thinkContent || null, null);
renderMessages();
await saveCurrentSession();
}
});
} catch (err) {
console.error('[AgentLoop] 错误:', err);
if ((err as Error).name === 'AbortError') {
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
if (placeholder) {
placeholder.classList.remove('loading');
placeholder.querySelector('.loading-dots')?.remove();
placeholder.querySelector('.loading-text')?.remove();
const contentDiv = placeholder.querySelector('.msg-content');
if (contentDiv) {
let html = assistantContent ? safeMarkdown(assistantContent) : '';
html += '<p><code>[已停止]</code></p>';
contentDiv.innerHTML = html;
}
}
const partialMsg: ChatMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
}));
appendSystemMessage('⏹ 已停止生成');
await saveCurrentSession();
} else {
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
if (placeholder) {
placeholder.classList.remove('loading');
placeholder.querySelector('.loading-dots')?.remove();
placeholder.querySelector('.loading-text')?.remove();
if (!assistantContent) placeholder.remove();
else {
const contentDiv = placeholder.querySelector('.msg-content');
if (contentDiv) contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
}
}
let errMsg = `❌ 错误: ${(err as Error).message}`;
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行';
}
appendSystemMessage(errMsg);
}
} finally {
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
state.set(KEYS.ABORT_CONTROLLER, null);
}
}
async function saveCurrentSession(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
+23
View File
@@ -103,6 +103,29 @@ export function initSettingsModal(): void {
(e.target as HTMLInputElement).value = '';
}
});
// ── Tool Calling 设置 ──
const toggleToolCalling = document.querySelector('#toggleToolCalling') as HTMLInputElement;
if (toggleToolCalling) {
toggleToolCalling.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
state.set('toolCallingEnabled', toggleToolCalling.checked);
if (db) await db.saveSetting('toolCallingEnabled', toggleToolCalling.checked);
showToast(toggleToolCalling.checked ? '工具调用已开启' : '工具调用已关闭', 'info');
});
}
const toggleRunCommand = document.querySelector('#toggleRunCommand') as HTMLInputElement;
if (toggleRunCommand) {
toggleRunCommand.addEventListener('change', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
state.set('runCommandEnabled', toggleRunCommand.checked);
if (db) await db.saveSetting('runCommandEnabled', toggleRunCommand.checked);
if (toggleRunCommand.checked) {
showToast('⚠️ 命令执行已开启,请谨慎使用', 'warning');
}
});
}
}
export function openSettingsModal(): void {
@@ -0,0 +1,103 @@
/**
* ToolConfirmModal - 工具调用确认对话框
*/
import type { ToolCall } from '../types.js';
import { getToolIcon, formatToolName } from '../services/tool-registry.js';
let modalEl: HTMLElement | null = null;
let resolveConfirm: ((confirmed: boolean) => void) | null = null;
export function initToolConfirmModal(): void {
modalEl = document.querySelector('#toolConfirmModal')!;
if (!modalEl) return;
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) {
cancelConfirm();
}
});
}
export function showToolConfirm(call: ToolCall): Promise<boolean> {
return new Promise((resolve) => {
if (!modalEl) {
modalEl = document.querySelector('#toolConfirmModal')!;
if (!modalEl) { resolve(false); return; }
modalEl.addEventListener('click', (e) => {
if (e.target === modalEl) cancelConfirm();
});
}
resolveConfirm = resolve;
const icon = getToolIcon(call.function.name);
const name = formatToolName(call.function.name);
const titleEl = modalEl.querySelector('#toolConfirmTitle')!;
const bodyEl = modalEl.querySelector('#toolConfirmBody')!;
const confirmBtn = modalEl.querySelector('#toolConfirmBtn')! as HTMLButtonElement;
const cancelBtn = modalEl.querySelector('#toolCancelBtn')! as HTMLButtonElement;
titleEl.textContent = `${icon} 确认操作:${name}`;
let bodyHtml = `<div class="tool-confirm-info">`;
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">操作:</span><span>${name}</span></div>`;
const args = call.function.arguments || {};
if (call.function.name === 'read_file' || call.function.name === 'write_file' ||
call.function.name === 'list_directory' || call.function.name === 'search_files' ||
call.function.name === 'create_directory' || call.function.name === 'delete_file') {
if (args.path) {
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径:</span><code>${escapeHtml(String(args.path))}</code></div>`;
}
}
if (call.function.name === 'run_command') {
if (args.command) {
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">命令:</span><code>${escapeHtml(String(args.command))}</code></div>`;
}
}
if (call.function.name === 'write_file' && args.content) {
const preview = String(args.content).slice(0, 500);
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">内容预览:</span></div>`;
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}</pre>`;
}
bodyHtml += `</div>`;
bodyEl.innerHTML = bodyHtml;
modalEl.style.display = '';
const onConfirm = () => {
cleanup();
resolveConfirm?.(true);
};
const onCancel = () => {
cleanup();
resolveConfirm?.(false);
};
const cleanup = () => {
confirmBtn.removeEventListener('click', onConfirm);
cancelBtn.removeEventListener('click', onCancel);
if (modalEl) modalEl.style.display = 'none';
resolveConfirm = null;
};
confirmBtn.addEventListener('click', onConfirm);
cancelBtn.addEventListener('click', onCancel);
});
}
function cancelConfirm(): void {
if (resolveConfirm) {
const r = resolveConfirm;
resolveConfirm = null;
if (modalEl) modalEl.style.display = 'none';
r(false);
}
}
function escapeHtml(str: string): string {
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return str.replace(/[&<>"']/g, c => map[c]);
}