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:
@@ -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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => 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'),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user