/** * Tool Handlers - 系统与网络操作 */ import * as fs from 'fs/promises'; import * as path from 'path'; import { spawn } from 'child_process'; import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; import { mainWindow } from './main.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; import { getWorkspaceDir } from './workspace.js'; /** 当前工具命令进程(用于用户手动终止) */ let _toolProc: ReturnType | null = null; export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise { try { const cmdCheck = checkCommandAllowed(params.command); if (!cmdCheck.ok) { sendLog('warn', `🔧 run_command 被拦截`, `${params.command.slice(0, 100)} | ${cmdCheck.reason}`); return { success: false, error: cmdCheck.reason }; } const cwd = params.cwd ? path.resolve(params.cwd) : getWorkspaceDir(); const dirCheck = checkPathAllowed(cwd, 'read'); if (!dirCheck.ok) { sendLog('warn', `🔧 run_command 目录被拦截`, `${cwd} | ${dirCheck.reason}`); return { success: false, error: dirCheck.reason }; } sendLog('info', `🔧 run_command 执行`, `${params.command.slice(0, 200)} | cwd: ${cwd}`); return new Promise((resolve) => { const isWin = process.platform === 'win32'; const shell = isWin ? 'cmd.exe' : 'bash'; const actualCmd = isWin ? `chcp 65001 >nul && ${params.command}` : params.command; const shellArgs = isWin ? ['/c', actualCmd] : ['-c', actualCmd]; const startTime = Date.now(); _toolProc = spawn(shell, shellArgs, { cwd, env: { ...process.env, ...(isWin ? {} : { TERM: 'xterm-256color' }), LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', PYTHONIOENCODING: 'utf-8' }, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; // 实时推送到工作空间终端 _toolProc.stdout?.on('data', (data: Buffer) => { const str = data.toString('utf-8'); stdout += str; mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str }); }); _toolProc.stderr?.on('data', (data: Buffer) => { const str = data.toString('utf-8'); stderr += str; mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str }); }); _toolProc.on('close', (code) => { _toolProc = null; const duration = Date.now() - startTime; sendLog( code === 0 ? 'success' : 'warn', `🔧 run_command 完成`, `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B` ); mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code }); resolve({ success: code === 0, stdout, stderr, exitCode: code, duration }); }); _toolProc.on('error', (err) => { _toolProc = null; const duration = Date.now() - startTime; sendLog('error', `🔧 run_command 失败`, `${err.message} | ${duration}ms`); mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: 1 }); resolve({ success: false, stdout, stderr: stderr + (stderr ? '\n' : '') + err.message, exitCode: 1, error: err.message, duration }); }); }); } catch (err) { sendLog('error', `🔧 run_command 异常`, (err as Error).message); return { success: false, stdout: '', stderr: (err as Error).message, exitCode: 1, error: (err as Error).message }; } } /** 终止当前工具命令进程 */ export function killToolProcess(): boolean { if (!_toolProc) { sendLog('warn', `🔧 cmd:kill`, '无正在运行的工具命令'); return false; } try { _toolProc.kill('SIGTERM'); } catch { /* ignore */ } _toolProc = null; sendLog('info', `🔧 cmd:kill`, '工具命令已终止'); return true; } export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise { try { const url = params.url; if (!url.startsWith('http://') && !url.startsWith('https://')) { return { success: false, error: '仅支持 http/https 协议' }; } const maxChars = params.max_chars || 0; // 0 = 不截断,返回全部内容 sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`); const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' } }); if (!resp.ok) { return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; } const contentType = resp.headers.get('content-type') || ''; if (!contentType.includes('text') && !contentType.includes('html') && !contentType.includes('json') && !contentType.includes('xml')) { return { success: false, error: `不支持的内容类型: ${contentType}` }; } let text = await resp.text(); // 简单 HTML → 文本提取 if (contentType.includes('html')) { text = text .replace(//gi, '') .replace(//gi, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/\s+/g, ' ') .trim(); } const truncated = maxChars > 0 && text.length > maxChars; if (truncated) text = text.slice(0, maxChars); sendLog('success', `🌐 web_fetch 完成`, `${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}`); return { success: true, url, content: text, content_type: contentType, status: resp.status, truncated, length: text.length }; } catch (err) { return { success: false, error: (err as Error).message }; } } /** * Web Search — 联网搜索 * 使用 Bing 搜索(首选)+ 百度作为备用 */ export async function handleWebSearch(params: { query: string; max_results?: number }): Promise { try { const query = params.query; if (!query || query.trim().length === 0) { return { success: false, error: '搜索关键词不能为空' }; } const maxResults = Math.min(params.max_results || 15, 15); sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条)`); const searchHeaders = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': 'text/html', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }; // 1. Bing 搜索(首选) let results: Array<{ title: string; url: string; snippet: string }> = []; try { const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; const resp = await fetch(bingUrl, { headers: searchHeaders, }); if (resp.ok) { const html = await resp.text(); results = parseBingResults(html, maxResults); } } catch (e) { sendLog('warn', `🔍 Bing 搜索失败`, (e as Error).message); } // 2. Bing 无结果,尝试百度 if (results.length === 0) { try { const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; const resp = await fetch(baiduUrl, { headers: searchHeaders, }); if (resp.ok) { const html = await resp.text(); results = parseBaiduResults(html, maxResults); } } catch (e) { sendLog('warn', `🔍 百度搜索失败`, (e as Error).message); } } // 3. 百度也失败,尝试 Google if (results.length === 0) { try { const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`; const resp = await fetch(googleUrl, { headers: searchHeaders, }); if (resp.ok) { const html = await resp.text(); results = parseGoogleResults(html, maxResults); } } catch (e) { sendLog('warn', `🔍 Google 搜索失败`, (e as Error).message); } } if (results.length === 0) { return { success: false, error: '未找到搜索结果,请尝试其他关键词' }; } // 构建格式化的搜索结果(附带当前日期) const now = new Date(); const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; const formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]\n\n` + results.map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` ).join('\n\n'); sendLog('success', `🔍 web_search 完成`, `"${query}" → ${results.length} 条结果`); return { success: true, query, results, total: results.length, formatted }; } catch (err) { return { success: false, error: (err as Error).message }; } } /** 解析百度 HTML 搜索结果 */ function parseBaiduResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { const results: Array<{ title: string; url: string; snippet: string }> = []; // 百度结果块:
// 标题:

内的 title // 摘要:
...
const blockRegex = /
]*>([\s\S]*?)(?=
]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/); if (!titleMatch) continue; let url = titleMatch[1].trim(); const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim()); // 百度有时使用 data-url 属性 if (!url.startsWith('http')) { const dataUrlMatch = block.match(/data-url="(https?:\/\/[^"]*)"/); if (dataUrlMatch) url = dataUrlMatch[1]; } // 提取摘要 const snippetMatch = block.match(/
]*>([\s\S]*?)<\/div>/) || block.match(/]*>([\s\S]*?)<\/span>/); const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; if (title) { results.push({ title, url: url.startsWith('http') ? url : `https://www.baidu.com${url}`, snippet }); } } return results; } /** 解析 Bing HTML 搜索结果 */ function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { const results: Array<{ title: string; url: string; snippet: string }> = []; // 匹配
  • 块 const blockRegex = /
  • ]*>([\s\S]*?)<\/li>/gi; let blockMatch; while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) { const block = blockMatch[1]; const titleMatch = block.match(/]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/); const snippetMatch = block.match(/]*>([\s\S]*?)<\/p>/) || block.match(/
    ]*>([\s\S]*?)<\/div>/); if (titleMatch) { const url = titleMatch[1].trim(); const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim()); const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; if (title && url.startsWith('http')) { results.push({ title, url, snippet }); } } } return results; } /** 解析 Google HTML 搜索结果 */ function parseGoogleResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { const results: Array<{ title: string; url: string; snippet: string }> = []; // Google 结果块:
    ...
    ,内含

    title

    和摘要 const blockRegex = /
    ]*>([\s\S]*?)(?=
    \s*<\/div>\s*<\/div>)/gi; let blockMatch; while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) { const block = blockMatch[1]; const titleMatch = block.match(/]*href="(https?:\/\/[^"]*)"[^>]*>[\s\S]*?]*>([\s\S]*?)<\/h3>/); if (!titleMatch) continue; const url = titleMatch[1].trim(); const title = decodeHTML(titleMatch[2].replace(/<[^>]+>/g, '').trim()); // 摘要:尝试多种模式 const snippetMatch = block.match(/]*data-sncf="[^"]*"[^>]*>([\s\S]*?)<\/div>/) || block.match(/]*class="[^"]*st[^"]*"[^>]*>([\s\S]*?)<\/span>/) || block.match(/]*class="[^"]*VwiC3b[^"]*"[^>]*>([\s\S]*?)<\/div>/); const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; if (title && url.startsWith('http') && !url.includes('google.com')) { results.push({ title, url, snippet }); } } return results; } /** 简单 HTML 实体解码 */ function decodeHTML(text: string): string { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, ' ') .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); } 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 协议' }; } sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`); const resp = await fetch(params.url); 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); sendLog('success', `⬇️ download_file 完成`, `${buf.length} bytes`); return { success: true, url: params.url, destination: destPath, size: buf.length, content_type: resp.headers.get('content-type') || 'unknown' }; } catch (err) { sendLog('error', `⬇️ download_file 失败`, (err as Error).message); 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 }; } }