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