From 5bfb137a8a3a8cb9449c299e84fea1dde5e9a8d0 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 6 Apr 2026 13:29:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v3.0=20Tool=20Calling=20=E2=80=94=20AI?= =?UTF-8?q?=20=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增文件: - 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: 初始化工具调用配置 --- src/main/ipc.ts | 33 ++ src/main/preload.ts | 5 + src/main/tool-handlers.ts | 303 ++++++++++++++++++ src/main/tool-security.ts | 104 ++++++ src/renderer/api/ollama.ts | 5 +- src/renderer/components/chat-area.ts | 97 +++++- src/renderer/components/input-area.ts | 190 ++++++++++- src/renderer/components/settings-modal.ts | 23 ++ src/renderer/components/tool-confirm-modal.ts | 103 ++++++ src/renderer/index.html | 45 +++ src/renderer/main.ts | 12 + src/renderer/services/agent-engine.ts | 221 +++++++++++++ src/renderer/services/tool-registry.ts | 190 +++++++++++ src/renderer/styles/style.css | 213 ++++++++++++ src/renderer/types.d.ts | 71 +++- 15 files changed, 1609 insertions(+), 6 deletions(-) create mode 100644 src/main/tool-handlers.ts create mode 100644 src/main/tool-security.ts create mode 100644 src/renderer/components/tool-confirm-modal.ts create mode 100644 src/renderer/services/agent-engine.ts create mode 100644 src/renderer/services/tool-registry.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3739d6c..4eb4997 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,6 +7,16 @@ import * as fs from 'fs'; import * as path from 'path'; import { mainWindow } from './main.js'; import { showNotification } from './utils.js'; +import { + handleReadFile, + handleWriteFile, + handleListDir, + handleSearchFiles, + handleCreateDir, + handleDeleteFile, + handleRunCommand +} from './tool-handlers.js'; +import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; export function setupIPC(): void { ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => { @@ -69,4 +79,27 @@ export function setupIPC(): void { shell.openExternal(url); } }); + + // ── Tool Calling IPC ── + ipcMain.handle('tool:execute', async (_, toolName: string, args: Record) => { + switch (toolName) { + case 'read_file': return handleReadFile(args as { path: string; encoding?: string; start_line?: number; end_line?: number }); + case 'write_file': return handleWriteFile(args as { path: string; content: string; encoding?: string }); + case 'list_directory': return handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }); + case 'search_files': return handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }); + case 'create_directory': return handleCreateDir(args as { path: string }); + case 'delete_file': return handleDeleteFile(args as { path: string; recursive?: boolean }); + case 'run_command': return handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); + default: return { success: false, error: `未知工具: ${toolName}` }; + } + }); + + ipcMain.handle('tool:getConfig', () => ({ + allowedDirs: getAllowedDirs(), + blockedDirs: getBlockedDirs() + })); + + ipcMain.handle('tool:setAllowedDirs', (_, dirs: string[]) => { + setAllowedDirs(dirs); + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 0c4dd54..c63f44b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('metonaDesktop', { readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content) }, + tool: { + execute: (toolName: string, args: Record) => ipcRenderer.invoke('tool:execute', toolName, args), + getConfig: () => ipcRenderer.invoke('tool:getConfig'), + setAllowedDirs: (dirs: string[]) => ipcRenderer.invoke('tool:setAllowedDirs', dirs) + }, notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body), window: { minimize: () => ipcRenderer.invoke('window:minimize'), diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts new file mode 100644 index 0000000..05f8be6 --- /dev/null +++ b/src/main/tool-handlers.ts @@ -0,0 +1,303 @@ +/** + * Tool Handlers - 主进程工具执行器 + * 所有文件系统操作在此执行,通过 IPC 被渲染进程调用 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; + +const execAsync = promisify(exec); + +export interface ToolResult { + success: boolean; + [key: string]: unknown; +} + +export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number }): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const stat = await fs.stat(filePath); + if (stat.size > 1024 * 1024) { + return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 1MB` }; + } + + const encoding = (params.encoding as BufferEncoding) || 'utf-8'; + const content = await fs.readFile(filePath, encoding); + const lines = content.split('\n'); + + let resultContent = content; + let lineRange: [number, number] = [1, lines.length]; + let truncated = false; + + if (params.start_line || params.end_line) { + const start = Math.max(1, params.start_line || 1) - 1; + const end = Math.min(lines.length, params.end_line || Math.min(start + 500, lines.length)); + resultContent = lines.slice(start, end).join('\n'); + lineRange = [start + 1, end]; + truncated = end < lines.length; + } else if (lines.length > 500) { + resultContent = lines.slice(0, 500).join('\n'); + lineRange = [1, 500]; + truncated = true; + } + + return { + success: true, + path: filePath, + content: resultContent, + encoding, + size: stat.size, + lines: lines.length, + truncated, + line_range: lineRange + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleWriteFile(params: { path: string; content: string; encoding?: string }): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + if (params.content.length > 5 * 1024 * 1024) { + return { success: false, error: '内容过大,最大支持 5MB' }; + } + + let created = false; + try { + await fs.stat(filePath); + } catch { + created = true; + } + + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + + await fs.writeFile(filePath, params.content, (params.encoding as BufferEncoding) || 'utf-8'); + + return { + success: true, + path: filePath, + bytesWritten: Buffer.byteLength(params.content, (params.encoding as BufferEncoding) || 'utf-8'), + created + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }): Promise { + try { + const dirPath = path.resolve(params.path); + const allowed = checkPathAllowed(dirPath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const maxEntries = 500; + const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = []; + let truncated = false; + + async function scanDir(dir: string, depth: number): Promise { + if (entries.length >= maxEntries) { truncated = true; return; } + if (params.recursive && params.max_depth && depth > params.max_depth) return; + + const items = await fs.readdir(dir, { withFileTypes: true }); + for (const item of items) { + if (entries.length >= maxEntries) { truncated = true; return; } + if (!params.include_hidden && item.name.startsWith('.')) continue; + if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue; + + const fullPath = path.join(dir, item.name); + const stat = await fs.stat(fullPath); + entries.push({ + name: params.recursive ? path.relative(dirPath, fullPath) : item.name, + type: item.isDirectory() ? 'directory' : 'file', + size: item.isFile() ? stat.size : null, + modified: stat.mtime.toISOString() + }); + + if (params.recursive && item.isDirectory()) { + await scanDir(fullPath, depth + 1); + } + } + } + + await scanDir(dirPath, 1); + return { success: true, path: dirPath, entries, total: entries.length, truncated }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }): Promise { + try { + const rootPath = path.resolve(params.path); + const allowed = checkPathAllowed(rootPath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const searchType = params.search_type || 'both'; + const caseSensitive = params.case_sensitive || false; + const maxResults = params.max_results || 50; + const query = caseSensitive ? params.query : params.query.toLowerCase(); + + const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = []; + let totalMatches = 0; + let filesScanned = 0; + const maxScanFiles = 1000; + + async function collectFiles(dir: string): Promise { + const files: string[] = []; + let items; + try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return files; } + for (const item of items) { + if (filesScanned >= maxScanFiles) return files; + if (item.name.startsWith('.')) continue; + const fullPath = path.join(dir, item.name); + if (item.isDirectory()) { + files.push(...(await collectFiles(fullPath))); + } else { + if (params.file_extensions?.length) { + const ext = path.extname(item.name); + if (!params.file_extensions.includes(ext)) continue; + } + files.push(fullPath); + filesScanned++; + } + } + return files; + } + + const allFiles = await collectFiles(rootPath); + + for (const filePath of allFiles) { + if (totalMatches >= maxResults) break; + + if (searchType === 'filename' || searchType === 'both') { + const fileName = path.basename(filePath); + const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase(); + if (nameToCheck.includes(query)) { + results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] }); + totalMatches++; + continue; + } + } + + if (searchType === 'content' || searchType === 'both') { + try { + const stat = await fs.stat(filePath); + if (stat.size > 500 * 1024) continue; + + const content = await fs.readFile(filePath, 'utf-8'); + const lines = content.split('\n'); + const fileMatches: Array<{ line: number; text: string; column: number }> = []; + + for (let i = 0; i < lines.length && fileMatches.length < 10; i++) { + const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase(); + const col = lineToCheck.indexOf(query); + if (col !== -1) { + fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 }); + totalMatches++; + } + } + + if (fileMatches.length > 0) { + results.push({ path: filePath, matches: fileMatches }); + } + } catch { /* skip unreadable files */ } + } + } + + return { + success: true, + query: params.query, + search_type: searchType, + results, + total_files: allFiles.length, + total_matches: totalMatches, + truncated: totalMatches >= maxResults + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleCreateDir(params: { path: string }): Promise { + try { + const dirPath = path.resolve(params.path); + const allowed = checkPathAllowed(dirPath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + await fs.mkdir(dirPath, { recursive: true }); + return { success: true, path: dirPath, created: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleDeleteFile(params: { path: string; recursive?: boolean }): Promise { + try { + const filePath = path.resolve(params.path); + const allowed = checkPathAllowed(filePath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + if (params.recursive) { + await fs.rm(filePath, { recursive: true, force: true }); + } else { + await fs.rmdir(filePath); + } + } else { + await fs.unlink(filePath); + } + + return { success: true, path: filePath, deleted: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise { + try { + const cmdCheck = checkCommandAllowed(params.command); + if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason }; + + const timeout = Math.min(params.timeout || 30000, 120000); + const cwd = params.cwd ? path.resolve(params.cwd) : process.env.HOME || '/'; + + const cwdAllowed = checkPathAllowed(cwd, 'read'); + if (!cwdAllowed.ok) return { success: false, error: cwdAllowed.reason }; + + const start = Date.now(); + const { stdout, stderr } = await execAsync(params.command, { + cwd, + timeout, + maxBuffer: 100 * 1024 + }); + + return { + success: true, + stdout: stdout.slice(0, 100 * 1024), + stderr: stderr.slice(0, 100 * 1024), + exitCode: 0, + duration: Date.now() - start + }; + } catch (err) { + const error = err as { stdout?: string; stderr?: string; code?: number; message: string }; + return { + success: false, + stdout: error.stdout?.slice(0, 100 * 1024) || '', + stderr: error.stderr?.slice(0, 100 * 1024) || error.message, + exitCode: error.code || 1, + error: error.message + }; + } +} diff --git a/src/main/tool-security.ts b/src/main/tool-security.ts new file mode 100644 index 0000000..18e11de --- /dev/null +++ b/src/main/tool-security.ts @@ -0,0 +1,104 @@ +/** + * Tool Security - 安全检查模块 + * 路径白名单/黑名单、命令过滤、路径遍历检测 + */ + +import * as path from 'path'; +import * as os from 'os'; + +const HOME = os.homedir(); + +/** 默认允许的目录(可通过设置覆盖) */ +let allowedDirs: string[] = [ + HOME, + path.join(HOME, 'Desktop'), + path.join(HOME, 'Documents'), + path.join(HOME, 'Downloads'), + path.join(HOME, 'Projects'), + path.join(HOME, 'projects'), + '/tmp', +]; + +/** 永久禁止的目录 */ +const BLOCKED_DIRS: string[] = [ + '/etc', '/sys', '/proc', '/dev', '/boot', '/root', + 'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData', + path.join(HOME, '.ssh'), + path.join(HOME, '.gnupg'), + path.join(HOME, '.aws'), +]; + +/** 命令黑名单 */ +const BLOCKED_COMMANDS: string[] = [ + 'rm -rf /', 'rm -rf /*', ':(){ :|:& };:', + 'mkfs', 'dd if=', 'wipefs', 'shred', + 'shutdown', 'reboot', 'poweroff', 'halt', + 'useradd', 'usermod', 'userdel', 'passwd', + 'chmod 777', 'chown root', + 'crontab -e', + 'systemctl enable', 'systemctl disable', + 'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash', + 'eval', 'exec >', +]; + +export interface CheckResult { + ok: boolean; + reason?: string; +} + +export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult { + const resolved = path.resolve(targetPath); + + if (targetPath.split(path.sep).filter(s => s === '..').length > 5) { + return { ok: false, reason: '路径遍历深度过大' }; + } + + for (const blocked of BLOCKED_DIRS) { + if (resolved === blocked || resolved.startsWith(blocked + path.sep)) { + return { ok: false, reason: `禁止访问受保护路径: ${blocked}` }; + } + } + + if (operation === 'write') { + const inAllowedDir = allowedDirs.some(dir => + resolved === dir || resolved.startsWith(dir + path.sep) + ); + if (!inAllowedDir) { + return { ok: false, reason: `写操作被限制在允许的目录内。当前路径: ${resolved}` }; + } + } + + return { ok: true }; +} + +export function checkCommandAllowed(command: string): CheckResult { + const lowerCmd = command.toLowerCase().trim(); + + for (const blocked of BLOCKED_COMMANDS) { + if (lowerCmd.includes(blocked.toLowerCase())) { + return { ok: false, reason: `命令包含被禁止的操作: ${blocked}` }; + } + } + + if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(lowerCmd)) { + return { ok: false, reason: '禁止通过管道执行 shell 命令' }; + } + + if (/\/dev\/tcp\//i.test(lowerCmd) || /bash\s+-i\s+>&/i.test(lowerCmd)) { + return { ok: false, reason: '检测到疑似反弹 shell 操作' }; + } + + return { ok: true }; +} + +export function setAllowedDirs(dirs: string[]): void { + allowedDirs = dirs.map(d => path.resolve(d)); +} + +export function getAllowedDirs(): string[] { + return [...allowedDirs]; +} + +export function getBlockedDirs(): string[] { + return [...BLOCKED_DIRS]; +} diff --git a/src/renderer/api/ollama.ts b/src/renderer/api/ollama.ts index 0806c38..4e1dfa3 100644 --- a/src/renderer/api/ollama.ts +++ b/src/renderer/api/ollama.ts @@ -5,7 +5,7 @@ import type { OllamaChatParams, OllamaStreamChunk, OllamaModelsResponse, OllamaPsResponse, OllamaVersionResponse, OllamaModelDetail, - OllamaEmbedResponse + OllamaEmbedResponse, ToolDefinition } from '../types.js'; export class OllamaAPI { @@ -65,7 +65,7 @@ export class OllamaAPI { } async chatStream( - params: OllamaChatParams, + params: OllamaChatParams & { tools?: ToolDefinition[] }, onChunk: (chunk: OllamaStreamChunk) => void, abortController?: AbortController ): Promise { @@ -78,6 +78,7 @@ export class OllamaAPI { if (params.system) body.system = params.system; if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive; if (params.options) body.options = params.options; + if (params.tools?.length) body.tools = params.tools; const fetchOptions: RequestInit = { method: 'POST', diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 533d8ef..56d02ec 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -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 += `
🧠 基于知识库回答
${sourcesHtml}
`; } + if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) { + contentHtml += '
'; + for (const tc of msg.toolCalls) { + contentHtml += renderToolCallCard(tc); + } + contentHtml += '
'; + } + div.innerHTML = `
${avatar}
@@ -186,6 +194,93 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { messagesContainerEl.appendChild(div); } +function renderToolCallCard(tc: ToolCallRecord): string { + const icons: Record = { + read_file: '📄', write_file: '✏️', list_directory: '📁', + search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻' + }; + const names: Record = { + read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', + search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令' + }; + const statusLabels: Record = { + 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 += `
路径: ${escapeHtml(String(args.path))}
`; + if (args.command) paramsHtml += `
命令: ${escapeHtml(String(args.command))}
`; + if (args.query) paramsHtml += `
查询: ${escapeHtml(String(args.query))}
`; + + let resultHtml = ''; + if (tc.result) { + if (tc.result.success) { + const r = tc.result as Record; + 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 = `
${escapeHtml(preview)}${lines.length > 20 ? `\n... (共 ${r.lines || lines.length} 行)` : ''}
`; + resultHtml += `
📄 ${(r.size as number || 0) > 0 ? formatFileSize(r.size as number) : ''} · ${r.lines || '?'} 行
`; + } 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 => + `
${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}${e.size != null ? ` (${formatFileSize(e.size)})` : ''}
` + ).join(''); + if (entries.length > 30) resultHtml += `
... 共 ${(r.total as number) || entries.length} 项
`; + } 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 => + `
📄 ${escapeHtml(file.path)}:${m.line} ${escapeHtml(m.text).slice(0, 80)}
` + ).join('') + ).join(''); + resultHtml += `
共 ${r.total_files || '?'} 个文件,${r.total_matches || '?'} 处匹配
`; + } else if (tc.name === 'write_file') { + resultHtml = `
✅ 已写入 ${escapeHtml(String(r.path || ''))} (${formatFileSize(r.bytesWritten as number || 0)})
`; + } else if (tc.name === 'create_directory') { + resultHtml = `
✅ 已创建 ${escapeHtml(String(r.path || ''))}
`; + } else if (tc.name === 'delete_file') { + resultHtml = `
✅ 已删除 ${escapeHtml(String(r.path || ''))}
`; + } 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 += `
${escapeHtml(stdout)}
`; + if (stderr) resultHtml += `
${escapeHtml(stderr)}
`; + resultHtml += `
exit: ${r.exitCode || 0} · ${r.duration || 0}ms
`; + } else { + resultHtml = `
✅ 操作成功
`; + } + } else { + resultHtml = `
❌ ${escapeHtml(tc.result.error || '未知错误')}
`; + } + } + + return `
+
+ ${icon} + ${name} + ${status} +
+ ${paramsHtml ? `
${paramsHtml}
` : ''} + ${resultHtml ? `
${resultHtml}
` : ''} +
`; +} + +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; diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index b1a904d..2915769 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -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 { const currentSession = state.get(KEYS.CURRENT_SESSION); if (!currentSession) return; + // ── Tool Calling Agent Loop 分支 ── + const toolCallingEnabled = state.get('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 { } } +async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise { + 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(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 += '

[已停止]

'; + 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) + '

[已中断]

'; + } + } + + 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 { const db = state.get(KEYS.DB); const currentSession = state.get(KEYS.CURRENT_SESSION); diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 2a7084e..db062de 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -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(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(KEYS.DB); + state.set('runCommandEnabled', toggleRunCommand.checked); + if (db) await db.saveSetting('runCommandEnabled', toggleRunCommand.checked); + if (toggleRunCommand.checked) { + showToast('⚠️ 命令执行已开启,请谨慎使用', 'warning'); + } + }); + } } export function openSettingsModal(): void { diff --git a/src/renderer/components/tool-confirm-modal.ts b/src/renderer/components/tool-confirm-modal.ts new file mode 100644 index 0000000..ce196bc --- /dev/null +++ b/src/renderer/components/tool-confirm-modal.ts @@ -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 { + 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 = `
`; + bodyHtml += `
操作:${name}
`; + + 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 += `
路径:${escapeHtml(String(args.path))}
`; + } + } + if (call.function.name === 'run_command') { + if (args.command) { + bodyHtml += `
命令:${escapeHtml(String(args.command))}
`; + } + } + if (call.function.name === 'write_file' && args.content) { + const preview = String(args.content).slice(0, 500); + bodyHtml += `
内容预览:
`; + bodyHtml += `
${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}
`; + } + + bodyHtml += `
`; + 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return str.replace(/[&<>"']/g, c => map[c]); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index d6af2ab..34c10c1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -199,6 +199,34 @@

无运行中的模型

+
+ +

启用后,AI 可以在对话中主动调用本地工具(读写文件、搜索、创建目录等)。需要模型支持 Tool Calling。

+
+
+
📄 读取文件自动
+
✏️ 写入文件需确认
+
📁 列出目录自动
+
🔍 搜索文件自动
+
📂 创建目录需确认
+
🗑️ 删除文件需确认
+
+ 💻 执行命令 + +
+
+

⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。

+
+
@@ -370,6 +398,23 @@ 预览
+ + +
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index bec3818..dfb1251 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -23,6 +23,7 @@ import { initKBModal } from './components/kb-modal.js'; import { initPresetBar, initPresetModal } from './components/preset-bar.js'; import { initPresetManager } from './services/preset-manager.js'; import { initVectorStore } from './services/rag.js'; +import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import type { ChatSession } from './types.js'; function initHelpModal(): void { @@ -155,6 +156,7 @@ async function init(): Promise { initPresetBar(); initPresetModal(); initHelpModal(); + initToolConfirmModal(); setupDesktopIntegration(); bindGlobalEvents(); @@ -188,6 +190,16 @@ async function init(): Promise { (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1); + // ── Tool Calling 设置 ── + const toolCallingEnabled = await db.getSetting('toolCallingEnabled', false); + const runCommandEnabled = await db.getSetting('runCommandEnabled', false); + state.set('toolCallingEnabled', toolCallingEnabled); + state.set('runCommandEnabled', runCommandEnabled); + const toggleTC = document.querySelector('#toggleToolCalling'); + if (toggleTC) toggleTC.checked = toolCallingEnabled; + const toggleRC = document.querySelector('#toggleRunCommand'); + if (toggleRC) toggleRC.checked = runCommandEnabled; + (document.querySelector('#chatInput') as HTMLElement)?.focus(); console.log('[App] 初始化完成'); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts new file mode 100644 index 0000000..83620eb --- /dev/null +++ b/src/renderer/services/agent-engine.ts @@ -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; +} + +export async function runAgentLoop( + userContent: string, + images: string[], + historyMessages: Array<{ role: string; content: string; images?: string[] }>, + callbacks: AgentCallbacks +): Promise { + const api = state.get(KEYS.API); + const model = state.get('_defaultModel', ''); + + if (!api || !model) { + showToast('请先选择模型', 'error'); + return; + } + + const messages: OllamaMessage[] = []; + + if (state.get(KEYS.SYSTEM_PROMPT_ENABLED)) { + const systemPrompt = state.get(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('thinkEnabled', false), + options: { + num_ctx: state.get(KEYS.NUM_CTX, 24576), + temperature: state.get('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); +} diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts new file mode 100644 index 0000000..e47e6bf --- /dev/null +++ b/src/renderer/services/tool-registry.ts @@ -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 = 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): Promise { + 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 = { + 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 = { + read_file: '读取文件', + write_file: '写入文件', + list_directory: '列出目录', + search_files: '搜索文件', + create_directory: '创建目录', + delete_file: '删除文件', + run_command: '执行命令' + }; + return names[name] || name; +} diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 9198df5..fe5959b 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -2181,3 +2181,216 @@ html, body { margin: 8px 0; color: var(--text-secondary); } + +/* ═══════════════════════════════════════════════════════════════ + Tool Calling 工具调用卡片 + ═══════════════════════════════════════════════════════════════ */ + +.tool-calls-container { + margin-top: 8px; +} + +.tool-call-card { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin: 8px 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); + font-size: 13px; +} + +.tool-call-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.tool-call-icon { + font-size: 15px; +} + +.tool-call-name { + font-weight: 600; + color: var(--accent-cyan, #00f5d4); +} + +.tool-call-status { + margin-left: auto; + font-size: 12px; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.tool-call-params { + padding: 6px 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.tool-call-params code { + background: rgba(0, 0, 0, 0.3); + padding: 1px 6px; + border-radius: 4px; + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 12px; + color: var(--accent-cyan, #00f5d4); +} + +.tool-param { + margin: 2px 0; +} + +.tool-call-result { + padding: 8px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + max-height: 300px; + overflow-y: auto; +} + +.tool-result-content { + background: rgba(0, 0, 0, 0.3); + padding: 8px 10px; + border-radius: 6px; + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + margin: 0; + color: rgba(255, 255, 255, 0.85); + max-height: 200px; + overflow-y: auto; +} + +.tool-result-stderr { + color: #ff6b6b; + border-left: 3px solid #ff6b6b; +} + +.tool-result-entry { + padding: 2px 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.75); +} + +.tool-result-entry code { + background: rgba(0, 0, 0, 0.3); + padding: 1px 6px; + border-radius: 4px; + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 11px; +} + +.tool-result-meta { + margin-top: 6px; + font-size: 11px; + color: rgba(255, 255, 255, 0.4); +} + +.tool-result-error { + color: #ff6b6b; +} + +/* 状态颜色 */ +.tool-call-pending { + border-color: rgba(255, 165, 0, 0.4); +} + +.tool-call-running { + border-color: rgba(0, 245, 212, 0.4); +} + +.tool-call-success { + border-color: rgba(0, 200, 83, 0.3); +} + +.tool-call-error { + border-color: rgba(255, 82, 82, 0.4); +} + +.tool-call-cancelled { + border-color: rgba(150, 150, 150, 0.4); +} + +/* 执行中 spinner */ +.tool-call-running .tool-call-header::after { + content: ''; + width: 14px; + height: 14px; + border: 2px solid rgba(0, 245, 212, 0.3); + border-top-color: var(--accent-cyan, #00f5d4); + border-radius: 50%; + animation: tool-spin 0.8s linear infinite; + margin-left: auto; +} + +@keyframes tool-spin { + to { transform: rotate(360deg); } +} + +/* ── 工具调用设置 ── */ + +.tool-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tool-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + font-size: 12px; +} + +.tool-item span:first-child { + color: var(--text-primary, #fff); +} + +/* ── 工具确认对话框 ── */ + +.tool-confirm-info { + font-size: 13px; +} + +.tool-confirm-row { + display: flex; + gap: 8px; + margin: 6px 0; + align-items: baseline; +} + +.tool-confirm-label { + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + white-space: nowrap; + min-width: 60px; +} + +.tool-confirm-row code { + background: rgba(0, 0, 0, 0.3); + padding: 2px 8px; + border-radius: 4px; + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 12px; + color: var(--accent-cyan, #00f5d4); + word-break: break-all; +} + +.tool-confirm-preview { + background: rgba(0, 0, 0, 0.3); + padding: 8px 10px; + border-radius: 6px; + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + margin: 4px 0 0; + max-height: 150px; + overflow-y: auto; + color: rgba(255, 255, 255, 0.75); +} diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 2ac288b..68180fb 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -1,11 +1,13 @@ // ── Ollama API 类型 ── export interface OllamaMessage { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant' | 'system' | 'tool'; content: string; images?: string[]; thinking?: string; reasoning_content?: string; + tool_calls?: ToolCall[]; + tool_name?: string; } export interface OllamaChatParams { @@ -14,6 +16,7 @@ export interface OllamaChatParams { stream?: boolean; think?: boolean; system?: string; + tools?: ToolDefinition[]; keep_alive?: number | string; options?: { num_ctx?: number; @@ -29,6 +32,7 @@ export interface OllamaStreamChunk { content?: string; thinking?: string; reasoning_content?: string; + tool_calls?: ToolCall[]; }; done?: boolean; eval_count?: number; @@ -100,6 +104,7 @@ export interface ChatMessage { _fileContents?: FileContent[]; ragSources?: RagSource[]; stopped?: boolean; + toolCalls?: ToolCallRecord[]; } export interface ChatSession { @@ -172,6 +177,11 @@ export interface MetonaDesktopAPI { readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>; writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>; }; + tool: { + execute: (toolName: string, args: Record) => Promise; + getConfig: () => Promise<{ allowedDirs: string[]; blockedDirs: string[] }>; + setAllowedDirs: (dirs: string[]) => Promise; + }; notify: (title: string, body: string) => void; window: { minimize: () => void; @@ -238,7 +248,62 @@ export type StateKey = | 'temperature' | 'thinkEnabled' | 'presets' - | 'activePresetId'; + | 'activePresetId' + | 'toolCallingEnabled' + | 'runCommandEnabled'; + +// ═══════════════════════════════════════════════════════════ +// Tool Calling 类型 +// ═══════════════════════════════════════════════════════════ + +export interface ToolParameterProperty { + type: string; + description?: string; + enum?: string[]; + items?: { type: string }; +} + +export interface ToolParameters { + type: 'object'; + required?: string[]; + properties: Record; +} + +export interface ToolFunctionDefinition { + name: string; + description: string; + parameters: ToolParameters; +} + +export interface ToolDefinition { + type: 'function'; + function: ToolFunctionDefinition; +} + +export interface ToolCall { + type: 'function'; + function: { + name: string; + arguments: Record; + }; +} + +export interface ToolResult { + success: boolean; + error?: string; + [key: string]: unknown; +} + +export interface ToolCallRecord { + name: string; + arguments: Record; + result: ToolResult | null; + status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'; + confirmed?: boolean; + timestamp: number; +} + +export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done'; // ── Window global 声明 ── @@ -248,3 +313,5 @@ declare global { __metonaBridge?: DesktopBridge; } } + +export {};