const { app, BrowserWindow, ipcMain, dialog, shell, screen } = require('electron'); const path = require('node:path'); const fs = require('node:fs/promises'); const os = require('node:os'); const { execFile } = require('node:child_process'); let mainWindow; function projectPathOrThrow(projectPath) { if (!projectPath || typeof projectPath !== 'string') throw new Error('未选择项目目录'); return path.resolve(projectPath); } function isIgnored(name) { return new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'target']).has(name); } async function walkFiles(root, current = root, result = []) { if (result.length >= 500) return result; const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { if (isIgnored(entry.name)) continue; const absolute = path.join(current, entry.name); if (entry.isDirectory()) await walkFiles(root, absolute, result); else result.push(path.relative(root, absolute)); if (result.length >= 500) break; } return result; } async function projectFiles(projectPath) { const root = projectPathOrThrow(projectPath); const files = await walkFiles(root); return { root, files }; } function safeProjectFile(projectPath, relativePath) { const root = projectPathOrThrow(projectPath); const target = path.resolve(root, relativePath || ''); if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error('路径超出项目目录范围'); return { root, target }; } async function searchProject(projectPath, query) { const root = projectPathOrThrow(projectPath); const files = await walkFiles(root); const results = []; for (const relative of files) { if (results.length >= 100) break; const absolute = path.join(root, relative); try { const content = await fs.readFile(absolute, 'utf8'); const lines = content.split(/\r?\n/); lines.forEach((line, index) => { if (results.length < 100 && line.toLowerCase().includes(String(query || '').toLowerCase())) results.push({ file: relative, line: index + 1, text: line.trim().slice(0, 240) }); }); } catch { /* binary or unreadable file */ } } return results; } function runCommand(command, cwd) { return new Promise((resolve, reject) => { execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], { cwd: projectPathOrThrow(cwd), windowsHide: true, maxBuffer: 8 * 1024 * 1024, timeout: 120000 }, (error, stdout, stderr) => { if (error) return reject(new Error(stderr.trim() || stdout.trim() || error.message)); resolve({ stdout, stderr, code: 0 }); }); }); } function runPowerShell(script) { return new Promise((resolve, reject) => { execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) return reject(new Error(stderr.trim() || error.message)); resolve(stdout.trim()); }); }); } async function takeScreenshot() { const output = path.join(os.tmpdir(), 'nano-agent-screen.png'); const escaped = output.replace(/'/g, "''"); const script = ` Add-Type -AssemblyName System.Drawing Add-Type -AssemblyName System.Windows.Forms $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height $graphics = [System.Drawing.Graphics]::FromImage($bitmap) $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bitmap.Size) $bitmap.Save('${escaped}', [System.Drawing.Imaging.ImageFormat]::Png) $graphics.Dispose(); $bitmap.Dispose() Write-Output '${escaped}' `; await runPowerShell(script); const data = await fs.readFile(output); return { path: output, dataUrl: `data:image/png;base64,${data.toString('base64')}` }; } async function computerAction(action) { const type = action?.type; if (type === 'screenshot') return takeScreenshot(); const x = Number.isFinite(action?.x) ? Math.round(action.x) : 0; const y = Number.isFinite(action?.y) ? Math.round(action.y) : 0; const buttonMap = { left: 0x0002 | 0x0004, right: 0x0008 | 0x0010, middle: 0x0020 | 0x0040 }; const button = buttonMap[action?.button || 'left'] || buttonMap.left; const keyMap = { ENTER: 0x0D, ESC: 0x1B, TAB: 0x09, CTRL: 0x11, SHIFT: 0x10, ALT: 0x12, BACKSPACE: 0x08, DELETE: 0x2E, SPACE: 0x20 }; const keys = (action?.keys || []).map((key) => keyMap[String(key).toUpperCase()] || String(key).charCodeAt(0)).filter(Boolean); const text = String(action?.text || '').replace(/'/g, "''"); let body = ''; if (type === 'mouse_move') { body = `[Win32]::SetCursorPos(${x}, ${y}) | Out-Null`; } else if (type === 'mouse_click') { body = `[Win32]::SetCursorPos(${x}, ${y}) | Out-Null; [Win32]::mouse_event(${button}, 0, 0, 0, 0)`; } else if (type === 'scroll') { const amount = Math.round(Number(action.amount || 1) * 120); body = `[Win32]::mouse_event(0x0800, 0, 0, ${amount}, 0)`; } else if (type === 'key_press') { body = keys.map((code) => `[Win32]::keybd_event(${code}, 0, 0, 0); [Win32]::keybd_event(${code}, 0, 2, 0)`).join(';'); } else if (type === 'type') { body = `Set-Clipboard -Value '${text}'; [Win32]::keybd_event(0x11,0,0,0); [Win32]::keybd_event(0x56,0,0,0); [Win32]::keybd_event(0x56,0,2,0); [Win32]::keybd_event(0x11,0,2,0)`; } else { throw new Error(`Unsupported computer action: ${type}`); } const script = ` Add-Type @' using System; using System.Runtime.InteropServices; public static class Win32 { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo); [DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extraInfo); } '@ ${body} Write-Output 'ok' `; await runPowerShell(script); return { ok: true, type }; } async function listWindows() { const script = `Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle.Trim() } | Select-Object Id, ProcessName, MainWindowTitle | ConvertTo-Json -Compress`; const output = await runPowerShell(script); if (!output) return []; const parsed = JSON.parse(output); return Array.isArray(parsed) ? parsed : [parsed]; } function createWindow() { mainWindow = new BrowserWindow({ width: 1300, height: 840, minWidth: 1120, minHeight: 720, show: false, backgroundColor: '#00000000', transparent: true, hasShadow: true, titleBarStyle: 'hidden', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }); mainWindow.loadFile(path.join(__dirname, 'src', 'index.html')); mainWindow.once('ready-to-show', () => mainWindow.show()); } const AGENT_TOOLS = [ { type: 'function', function: { name: 'list_files', description: '列出项目目录中的文件。', parameters: { type: 'object', properties: { depth: { type: 'number', description: '最大递归深度,默认 3。' } } } } }, { type: 'function', function: { name: 'read_file', description: '读取项目中的一个文本文件。', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } }, { type: 'function', function: { name: 'search_text', description: '在项目文件中搜索文本。', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }, { type: 'function', function: { name: 'write_file', description: '创建或修改项目文件。执行前必须确认用户授权。', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } } }, { type: 'function', function: { name: 'run_command', description: '在项目目录执行 PowerShell 命令。执行前必须确认用户授权。', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } } }, { type: 'function', function: { name: 'computer_screenshot', description: '获取当前 Windows 屏幕截图,用于观察界面。', parameters: { type: 'object', properties: {} } } }, { type: 'function', function: { name: 'computer_windows', description: '获取当前桌面窗口列表。', parameters: { type: 'object', properties: {} } } }, { type: 'function', function: { name: 'computer_action', description: '执行鼠标、键盘或滚轮动作。执行前必须确认用户授权。', parameters: { type: 'object', properties: { type: { type: 'string' }, x: { type: 'number' }, y: { type: 'number' }, button: { type: 'string' }, text: { type: 'string' }, keys: { type: 'array', items: { type: 'string' } }, amount: { type: 'number' } }, required: ['type'] } } } ]; async function callProvider(config, messages, tools = AGENT_TOOLS) { if (!config?.apiKey || !config?.model) { return { text: '演示模式已启用。请在设置中配置 API Base URL、API Key 和模型,即可连接真实模型。当前界面、任务流和电脑操控基础能力已经可用。', toolCalls: [] }; } const provider = config.provider || 'openai'; const baseUrl = (config.baseUrl || (provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1')).replace(/\/$/, ''); let url; let headers; let body; if (provider === 'anthropic') { url = `${baseUrl}/v1/messages`; headers = { 'content-type': 'application/json', 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' }; const system = messages.find((message) => message.role === 'system')?.content; body = { model: config.model, max_tokens: config.maxOutputTokens || 4096, system, messages: messages.filter((message) => message.role !== 'system'), tools: tools.map((tool) => ({ name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters })) }; } else { url = `${baseUrl}/chat/completions`; headers = { 'content-type': 'application/json', authorization: `Bearer ${config.apiKey}` }; body = { model: config.model, messages, tools, tool_choice: 'auto', stream: false, temperature: config.temperature ?? 0.2 }; } const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); const raw = await response.text(); if (!response.ok) throw new Error(`${response.status}: ${raw.slice(0, 500)}`); const data = JSON.parse(raw); if (provider === 'anthropic') { return { text: data.content?.filter((item) => item.type === 'text').map((item) => item.text || '').join('') || '', toolCalls: data.content?.filter((item) => item.type === 'tool_use').map((item) => ({ id: item.id, name: item.name, input: item.input, raw: item })) || [], assistantContent: data.content || [] }; } const message = data.choices?.[0]?.message || {}; return { text: message.content || '', toolCalls: (message.tool_calls || []).map((call) => ({ id: call.id, name: call.function.name, input: JSON.parse(call.function.arguments || '{}'), raw: call })), assistantMessage: message }; } async function executeAgentTool(name, input, payload) { const projectPath = payload.projectPath; if (name === 'list_files') return projectFiles(projectPath); if (name === 'read_file') return projectReadFile(projectPath, input.path); if (name === 'search_text') return searchProject(projectPath, input.query); if (name === 'write_file') return projectWriteFile(projectPath, input.path, input.content); if (name === 'run_command') return runCommand(input.command, projectPath); if (name === 'computer_screenshot') { const result = await takeScreenshot(); return { path: result.path, note: '截图已生成;支持视觉输入的模型可以继续分析截图。' }; } if (name === 'computer_windows') return listWindows(); if (name === 'computer_action') return computerAction(input); throw new Error(`未知工具:${name}`); } async function projectReadFile(projectPath, relativePath) { const { target } = safeProjectFile(projectPath, relativePath); return { path: relativePath, content: await fs.readFile(target, 'utf8') }; } async function projectWriteFile(projectPath, relativePath, content) { const { target } = safeProjectFile(projectPath, relativePath); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, String(content || ''), 'utf8'); return { ok: true, path: relativePath }; } async function runAgentLoop(event, payload) { const messages = [...payload.messages]; for (let step = 0; step < 8; step += 1) { const response = await callProvider(payload.config, messages); if (response.text) { for (const chunk of response.text.match(/.{1,18}/gs) || [response.text]) { event.sender.send('agent:event', { type: 'message.delta', content: chunk }); await new Promise((resolve) => setTimeout(resolve, 14)); } } if (!response.toolCalls.length) return; if (payload.config?.provider === 'anthropic') { messages.push({ role: 'assistant', content: response.assistantContent }); } else { messages.push({ role: 'assistant', content: response.assistantMessage.content || null, tool_calls: response.assistantMessage.tool_calls }); } for (const toolCall of response.toolCalls) { event.sender.send('agent:event', { type: 'tool.started', callId: toolCall.id, name: toolCall.name, input: toolCall.input }); const protectedTool = new Set(['write_file', 'run_command', 'computer_action']).has(toolCall.name); if (protectedTool && payload.permissionMode !== 'full') { event.sender.send('agent:event', { type: 'approval.required', approvalId: toolCall.id, reason: `工具 ${toolCall.name} 需要完全访问权限` }); return; } let result; try { result = await executeAgentTool(toolCall.name, toolCall.input || {}, payload); } catch (error) { result = { error: error.message }; } event.sender.send('agent:event', { type: 'tool.completed', callId: toolCall.id, name: toolCall.name, output: result }); if (toolCall.name === 'write_file' && result.path) event.sender.send('agent:event', { type: 'file.changed', path: result.path }); if (payload.config?.provider === 'anthropic') { messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: toolCall.id, content: JSON.stringify(result) }] }); } else { messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result) }); } } } throw new Error('智能体工具循环超过最大步骤数'); } ipcMain.handle('app:info', () => ({ version: app.getVersion(), platform: process.platform, displays: screen.getAllDisplays().length })); ipcMain.handle('window:control', (_event, action) => { if (!mainWindow) return; if (action === 'minimize') mainWindow.minimize(); if (action === 'maximize') mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize(); if (action === 'close') mainWindow.close(); }); ipcMain.handle('project:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }); return result.canceled ? null : result.filePaths[0]; }); ipcMain.handle('project:files', (_event, projectPath) => projectFiles(projectPath)); ipcMain.handle('project:read', async (_event, payload) => { const { target } = safeProjectFile(payload.projectPath, payload.relativePath); return { path: payload.relativePath, content: await fs.readFile(target, 'utf8') }; }); ipcMain.handle('project:search', (_event, payload) => searchProject(payload.projectPath, payload.query)); ipcMain.handle('project:write', async (_event, payload) => { const { target } = safeProjectFile(payload.projectPath, payload.relativePath); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, String(payload.content || ''), 'utf8'); return { ok: true, path: payload.relativePath }; }); ipcMain.handle('shell:run', (_event, payload) => runCommand(payload.command, payload.projectPath)); ipcMain.handle('shell:open', (_event, target) => shell.openPath(target)); ipcMain.handle('computer:action', (_event, action) => computerAction(action)); ipcMain.handle('computer:windows', () => listWindows()); ipcMain.handle('agent:run', async (event, payload) => { try { event.sender.send('agent:event', { type: 'run.started' }); await runAgentLoop(event, payload); event.sender.send('agent:event', { type: 'run.completed', status: 'success' }); return { ok: true }; } catch (error) { event.sender.send('agent:event', { type: 'run.error', message: error.message }); return { ok: false, error: error.message }; } }); app.whenReady().then(() => { createWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });