diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 147fc15..331229c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -25,7 +25,15 @@ import { handleCopyFile, handleWebFetch, handleAppendFile, - handleEditFile + handleEditFile, + handleGetFileInfo, + handleTree, + handleDownloadFile, + handleDiffFiles, + handleReplaceInFiles, + handleReadMultipleFiles, + handleGitStatus, + handleCompress } from './tool-handlers.js'; import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; @@ -118,6 +126,14 @@ export function setupIPC(): void { case 'web_fetch': return await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string }); case 'append_file': return await handleAppendFile(args as { path: string; content: string; newline?: boolean }); case 'edit_file': return await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean }); + case 'get_file_info': return await handleGetFileInfo(args as { path: string }); + case 'tree': return await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); + case 'download_file': return await handleDownloadFile(args as { url: string; destination: string }); + case 'diff_files': return await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number }); + case 'replace_in_files': return await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string }); + case 'read_multiple_files':return await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); + case 'git_status': return await handleGitStatus(args as { path?: string }); + case 'compress': return await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); default: return { success: false, error: `未知工具: ${toolName}` }; } } catch (err) { diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts index 9a20a42..6ffe958 100644 --- a/src/main/tool-handlers.ts +++ b/src/main/tool-handlers.ts @@ -522,3 +522,402 @@ export async function handleEditFile(params: { path: string; old_text: string; n return { success: false, error: (err as Error).message }; } } + +export async function handleGetFileInfo(params: { path: string }): Promise { + try { + const filePath = resolvePath(params.path); + const allowed = checkPathAllowed(filePath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const stat = await fs.stat(filePath); + return { + success: true, + path: filePath, + name: path.basename(filePath), + type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other', + size: stat.size, + created: stat.birthtime.toISOString(), + modified: stat.mtime.toISOString(), + accessed: stat.atime.toISOString(), + permissions: (stat.mode & 0o777).toString(8) + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleTree(params: { path: string; max_depth?: number; include_hidden?: boolean }): Promise { + try { + const rootPath = resolvePath(params.path); + const allowed = checkPathAllowed(rootPath, 'read'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + const maxDepth = params.max_depth || 3; + const includeHidden = params.include_hidden || false; + const lines: string[] = []; + let fileCount = 0; + let dirCount = 0; + + async function walk(dir: string, prefix: string, depth: number): Promise { + if (depth > maxDepth) return; + let items; + try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } + + const filtered = items + .filter(i => includeHidden || !i.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + + for (let i = 0; i < filtered.length; i++) { + const item = filtered[i]; + const isLast = i === filtered.length - 1; + const connector = isLast ? '└── ' : '├── '; + const icon = item.isDirectory() ? '📁' : '📄'; + lines.push(`${prefix}${connector}${icon} ${item.name}`); + if (item.isDirectory()) { + dirCount++; + const childPrefix = prefix + (isLast ? ' ' : '│ '); + await walk(path.join(dir, item.name), childPrefix, depth + 1); + } else { + fileCount++; + } + } + } + + lines.push(`📁 ${path.basename(rootPath)}/`); + await walk(rootPath, '', 1); + + return { + success: true, + path: rootPath, + tree: lines.join('\n'), + fileCount, + dirCount + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleDownloadFile(params: { url: string; destination: string }): Promise { + try { + const destPath = resolvePath(params.destination); + const allowed = checkPathAllowed(destPath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) { + return { success: false, error: '仅支持 http/https 协议' }; + } + + const resp = await fetch(params.url, { + signal: AbortSignal.timeout(60000) + }); + if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; + + const buf = Buffer.from(await resp.arrayBuffer()); + if (buf.length > 50 * 1024 * 1024) { + return { success: false, error: '文件超过 50MB 限制' }; + } + + const destDir = path.dirname(destPath); + await fs.mkdir(destDir, { recursive: true }); + await fs.writeFile(destPath, buf); + + return { + success: true, + url: params.url, + destination: destPath, + size: buf.length, + content_type: resp.headers.get('content-type') || 'unknown' + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise { + try { + const path1 = resolvePath(params.file1); + const path2 = resolvePath(params.file2); + const check1 = checkPathAllowed(path1, 'read'); + if (!check1.ok) return { success: false, error: check1.reason }; + const check2 = checkPathAllowed(path2, 'read'); + if (!check2.ok) return { success: false, error: check2.reason }; + + const content1 = await fs.readFile(path1, 'utf-8'); + const content2 = await fs.readFile(path2, 'utf-8'); + const lines1 = content1.split('\n'); + const lines2 = content2.split('\n'); + + // 简单的统一 diff 实现 + const diffLines: string[] = []; + const contextSize = params.context_lines || 3; + let i = 0, j = 0; + + while (i < lines1.length || j < lines2.length) { + if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) { + if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) { + // 添加上下文 + const ctxStart = Math.max(0, i - contextSize); + for (let k = ctxStart; k < i; k++) { + diffLines.push(` ${lines1[k]}`); + } + } + diffLines.push(` ${lines1[i]}`); + i++; j++; + } else { + // 尝试找匹配 + let found = false; + for (let look = 1; look <= 5 && i + look < lines1.length; look++) { + if (lines1[i + look] === lines2[j]) { + for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`); + i += look; found = true; break; + } + } + if (!found) { + for (let look = 1; look <= 5 && j + look < lines2.length; look++) { + if (lines1[i] === lines2[j + look]) { + for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`); + j += look; found = true; break; + } + } + } + if (!found) { + if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; } + if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; } + } + } + } + + const hasChanges = diffLines.some(l => l.startsWith('-') || l.startsWith('+')); + return { + success: true, + file1: path1, + file2: path2, + diff: diffLines.join('\n'), + hasChanges, + lines1: lines1.length, + lines2: lines2.length + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise { + try { + const rootPath = resolvePath(params.path); + const allowed = checkPathAllowed(rootPath, 'write'); + if (!allowed.ok) return { success: false, error: allowed.reason }; + + // 简单 glob 匹配 + const pattern = params.glob.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); + const regex = new RegExp(`^${pattern}$`); + + const results: Array<{ file: string; replacements: number }> = []; + let totalReplacements = 0; + const maxFiles = 200; + let fileCount = 0; + + async function scanDir(dir: string): Promise { + if (fileCount >= maxFiles) return; + let items; + try { items = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } + + for (const item of items) { + if (fileCount >= maxFiles) return; + if (item.name.startsWith('.')) continue; + const fullPath = path.join(dir, item.name); + + if (item.isDirectory()) { + await scanDir(fullPath); + } else if (regex.test(item.name)) { + fileCount++; + try { + const content = await fs.readFile(fullPath, 'utf-8'); + if (content.includes(params.old_text)) { + const parts = content.split(params.old_text); + const count = parts.length - 1; + const newContent = parts.join(params.new_text); + await fs.writeFile(fullPath, newContent, 'utf-8'); + results.push({ file: path.relative(rootPath, fullPath), replacements: count }); + totalReplacements += count; + } + } catch { /* skip unreadable */ } + } + } + } + + await scanDir(rootPath); + + return { + success: true, + pattern: params.glob, + filesChanged: results.length, + totalReplacements, + results, + truncated: fileCount >= maxFiles + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise { + try { + const maxChars = params.max_chars_per_file || 2000; + const results: Array<{ path: string; success: boolean; content?: string; error?: string; lines?: number }> = []; + + for (const p of params.paths.slice(0, 20)) { + const filePath = resolvePath(p); + const allowed = checkPathAllowed(filePath, 'read'); + if (!allowed.ok) { + results.push({ path: p, success: false, error: allowed.reason }); + continue; + } + try { + const stat = await fs.stat(filePath); + if (stat.size > 1024 * 1024) { + results.push({ path: p, success: false, error: '文件超过 1MB' }); + continue; + } + let content = await fs.readFile(filePath, 'utf-8'); + const lines = content.split('\n').length; + if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)'; + results.push({ path: p, success: true, content, lines }); + } catch (err) { + results.push({ path: p, success: false, error: (err as Error).message }); + } + } + + return { success: true, files: results, total: results.length }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleGitStatus(params: { path?: string }): Promise { + try { + const cwd = params.path ? path.resolve(params.path) : getWorkspaceDir(); + + return new Promise((resolve) => { + const git = spawn('git', ['status', '--porcelain=v1', '-b'], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + + git.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + + git.on('close', (code) => { + if (code !== 0) { + resolve({ success: false, error: stderr || '不是 git 仓库' }); + return; + } + + const lines = stdout.split('\n').filter(l => l.trim()); + const branchLine = lines.find(l => l.startsWith('##')) || ''; + const branch = branchLine.replace('## ', '').split('...')[0] || 'unknown'; + + const modified: string[] = []; + const staged: string[] = []; + const untracked: string[] = []; + + for (const line of lines) { + if (line.startsWith('##')) continue; + const status = line.slice(0, 2); + const file = line.slice(3); + if (status === '??') untracked.push(file); + else { + if (status[0] !== ' ' && status[0] !== '?') staged.push(file); + if (status[1] !== ' ' && status[1] !== '?') modified.push(file); + } + } + + resolve({ + success: true, + branch, + modified, + staged, + untracked, + isClean: modified.length === 0 && staged.length === 0 && untracked.length === 0 + }); + }); + + git.on('error', (err) => { + resolve({ success: false, error: err.message }); + }); + }); + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} + +export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise { + try { + const format = params.format || 'tar.gz'; + + if (params.action === 'create') { + const srcPath = resolvePath(params.path); + const check = checkPathAllowed(srcPath, 'read'); + if (!check.ok) return { success: false, error: check.reason }; + + const destPath = params.destination + ? resolvePath(params.destination) + : srcPath + (format === 'zip' ? '.zip' : '.tar.gz'); + const destCheck = checkPathAllowed(destPath, 'write'); + if (!destCheck.ok) return { success: false, error: destCheck.reason }; + + return new Promise((resolve) => { + const cmd = format === 'zip' + ? `zip -r "${destPath}" "${path.basename(srcPath)}"` + : `tar czf "${destPath}" "${path.basename(srcPath)}"`; + const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + fs.stat(destPath).then(s => { + resolve({ success: true, action: 'create', archive: destPath, size: s.size }); + }).catch(() => resolve({ success: true, action: 'create', archive: destPath })); + } else { + resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` }); + } + }); + proc.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } else { + // extract + const archivePath = resolvePath(params.path); + const check = checkPathAllowed(archivePath, 'read'); + if (!check.ok) return { success: false, error: check.reason }; + + const destDir = params.destination ? resolvePath(params.destination) : path.dirname(archivePath); + const destCheck = checkPathAllowed(destDir, 'write'); + if (!destCheck.ok) return { success: false, error: destCheck.reason }; + + await fs.mkdir(destDir, { recursive: true }); + + return new Promise((resolve) => { + const isZip = archivePath.endsWith('.zip'); + const cmd = isZip + ? `unzip -o "${archivePath}" -d "${destDir}"` + : `tar xzf "${archivePath}" -C "${destDir}"`; + const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => { + resolve(code === 0 + ? { success: true, action: 'extract', destination: destDir } + : { success: false, error: stderr || `解压失败 (exit ${code})` } + ); + }); + proc.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } + } catch (err) { + return { success: false, error: (err as Error).message }; + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index ea6cc5c..01e28fa 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -350,7 +350,7 @@ diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index 7278608..08a3a2b 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -198,10 +198,134 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ } } } + }, + { + type: 'function', + function: { + name: 'get_file_info', + description: 'Get detailed file or directory information: size, dates, permissions, type.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'File or directory path.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'tree', + description: 'Display directory structure as a tree. Shows nested files and folders.', + parameters: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'Root directory path.' }, + max_depth: { type: 'integer', description: 'Max depth. Default: 3.' }, + include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'download_file', + description: 'Download a file from a URL to a local path. Requires user confirmation.', + parameters: { + type: 'object', + required: ['url', 'destination'], + properties: { + url: { type: 'string', description: 'URL to download (http/https).' }, + destination: { type: 'string', description: 'Local path to save the file.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'diff_files', + description: 'Compare two files and show differences. Returns a unified diff.', + parameters: { + type: 'object', + required: ['file1', 'file2'], + properties: { + file1: { type: 'string', description: 'First file path.' }, + file2: { type: 'string', description: 'Second file path.' }, + context_lines: { type: 'integer', description: 'Context lines around changes. Default: 3.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'replace_in_files', + description: 'Find and replace text across multiple files matching a glob pattern. Requires user confirmation.', + parameters: { + type: 'object', + required: ['path', 'glob', 'old_text', 'new_text'], + properties: { + path: { type: 'string', description: 'Root directory.' }, + glob: { type: 'string', description: 'File pattern, e.g. "*.ts", "**/*.js".' }, + old_text: { type: 'string', description: 'Text to find.' }, + new_text: { type: 'string', description: 'Replacement text.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'read_multiple_files', + description: 'Read multiple files at once. Returns contents of all requested files.', + parameters: { + type: 'object', + required: ['paths'], + properties: { + paths: { type: 'array', items: { type: 'string' }, description: 'Array of file paths to read.' }, + max_chars_per_file: { type: 'integer', description: 'Max chars per file. Default: 2000.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'git_status', + description: 'Show git repository status: branch, modified/staged/untracked files.', + parameters: { + type: 'object', + required: [], + properties: { + path: { type: 'string', description: 'Repository path. Defaults to workspace.' } + } + } + } + }, + { + type: 'function', + function: { + name: 'compress', + description: 'Create or extract archives (zip/tar.gz). Requires user confirmation for create.', + parameters: { + type: 'object', + required: ['action', 'path'], + properties: { + action: { type: 'string', enum: ['create', 'extract'], description: 'Create or extract archive.' }, + path: { type: 'string', description: 'Source path (create) or archive path (extract).' }, + destination: { type: 'string', description: 'Output archive path (create) or extract dir (extract).' }, + format: { type: 'string', enum: ['zip', 'tar.gz'], description: 'Archive format. Default: tar.gz.' } + } + } + } } ]; -const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory', 'move_file', 'copy_file', 'append_file', 'edit_file']; +const CONFIRM_TOOLS = ['write_file', 'delete_file', 'run_command', 'create_directory', 'move_file', 'copy_file', 'append_file', 'edit_file', 'download_file', 'replace_in_files', 'compress']; export function needsConfirmation(toolName: string): boolean { return CONFIRM_TOOLS.includes(toolName); @@ -211,7 +335,9 @@ let enabledTools: Set = new Set([ 'read_file', 'list_directory', 'search_files', 'write_file', 'create_directory', 'delete_file', 'run_command', - 'move_file', 'copy_file', 'web_fetch', 'append_file', 'edit_file' + 'move_file', 'copy_file', 'web_fetch', 'append_file', 'edit_file', + 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', + 'read_multiple_files', 'git_status', 'compress' ]); export function setToolEnabled(toolName: string, enabled: boolean): void { @@ -289,7 +415,15 @@ export function getToolIcon(name: string): string { copy_file: '📋', web_fetch: '🌐', append_file: '➕', - edit_file: '✂️' + edit_file: '✂️', + get_file_info: 'ℹ️', + tree: '🌳', + download_file: '⬇️', + diff_files: '🔀', + replace_in_files: '🔄', + read_multiple_files: '📚', + git_status: '🔖', + compress: '🗜️' }; return icons[name] || '🔧'; } @@ -307,7 +441,15 @@ export function formatToolName(name: string): string { copy_file: '复制文件', web_fetch: '网页抓取', append_file: '追加文件', - edit_file: '编辑文件' + edit_file: '编辑文件', + get_file_info: '文件信息', + tree: '目录树', + download_file: '下载文件', + diff_files: '文件对比', + replace_in_files: '批量替换', + read_multiple_files: '批量读取', + git_status: 'Git 状态', + compress: '压缩/解压' }; return names[name] || name; }