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
+303
View File
@@ -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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<void> {
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<ToolResult> {
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<string[]> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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
};
}
}