Files
auto_agent/electron/main.js
T

1137 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// main.js — Electron 主进程(桌面版最小安装包)
//
// 两种模式:
// - 开发模式 (npm start): 使用项目本地的 src/ 和 myenv/
// - 打包模式 (minor Agent.exe): 使用 resources/ 下的 src/ 和 myenv/
//
// 首次运行会弹出 setup.html 让用户配置 LLM API Key,
// 写入 env_config.json 后启动 FastAPI 并加载 Web UI。
const { app, BrowserWindow, dialog, ipcMain, Menu } = require('electron');
const { spawn, execSync } = require('child_process');
const path = require('path');
const http = require('http');
const fs = require('fs');
// ==================== 路径解析 ====================
const isPackaged = app.isPackaged;
// 开发模式下所有文件在项目目录中
// 打包模式下: 主进程代码 -> resources/app.asar,源码+venv -> resources/
const AGENT_DIR = isPackaged
? process.resourcesPath // resources/
: path.join(__dirname, '..'); // minors_app/
// 打包模式下 Python 源码在 resources/app/src/(非 resources/src/)
const SRC_DIR = isPackaged
? path.join(process.resourcesPath, 'app', 'src')
: path.join(AGENT_DIR, 'src');
// 合并后的唯一配置文件 (src/agent/agents/config.json),分区见 agent/core/config_manager.py
const CONFIG_PATH = path.join(SRC_DIR, 'agent', 'agents', 'config.json');
const CONFIG_DIST_PATH = path.join(SRC_DIR, 'agent', 'agents', 'config_dist.json');
const THEME_CONFIG_PATH = CONFIG_PATH;
const HOST = '127.0.0.1';
const PORT = 8765;
const BASE_URL = `http://${HOST}:${PORT}`;
// ==================== 主题检测(启动玻璃层随主题色变化) ====================
// 与前端 themes.js 的 dark 标记保持一致
const DARK_THEMES = new Set(['vscode-dark', 'cyber-neon', 'deep-space-indigo', 'pixel-gold']);
function getThemeMode() {
try {
if (fs.existsSync(THEME_CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(THEME_CONFIG_PATH, 'utf-8'));
const id = (cfg.theme || {}).theme || '';
if (DARK_THEMES.has(id)) return 'dark';
if (id) {
// 未知主题按 id 推断(与 themes.js 的回退逻辑一致)
return (!id.includes('light') && !id.includes('solarized')) ? 'dark' : 'light';
}
}
} catch {}
return 'dark'; // 默认深色,与窗口默认底色一致
}
// ==================== 全局状态 ====================
let mainWindow = null;
let pythonProcess = null;
let isQuitting = false;
let isLaunching = false; // 防止 setup 窗口关闭时触发 app.quit
let _closeConfirmed = false; // 后台任务确认后放行关闭
/** 查询后端运行中的后台终端任务数(退出确认用;后端不可用时视为 0) */
function checkRunningTasks() {
return new Promise((resolve) => {
const req = http.get(`${BASE_URL}/api/terminal/running-count`, { timeout: 2000 }, (res) => {
let body = '';
res.on('data', (d) => (body += d));
res.on('end', () => {
try {
const n = Number(JSON.parse(body).running) || 0;
resolve(n);
} catch { resolve(0); }
});
});
req.on('error', () => resolve(0));
req.on('timeout', () => { req.destroy(); resolve(0); });
});
}
// ==================== 端口清理 ====================
function killPortProcess(port) {
try {
// 查找占用端口的所有 PID 并强制终止(含子进程树)
const result = execSync(
`powershell -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }"`,
{ timeout: 5000, stdio: 'pipe' }
);
return true;
} catch {
return false;
}
}
// ==================== 首次运行检测 ====================
function isFirstRun() {
if (!fs.existsSync(CONFIG_PATH)) {
return true;
}
try {
const merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
const config = (merged && merged.env) || {};
const models = config.models || [];
if (models.length === 0) return true;
if (models[0].id === 'SETUP_REQUIRED') return true;
if (!models[0].api_key || !models[0].base_url || !models[0].model) return true;
return false;
} catch {
return true;
}
}
// ==================== 配置管理 ====================
function writeEnvConfig(userInput) {
// 读入现有 config.json(保留其他分区),仅覆写 env / gui 分区
let merged = {};
if (fs.existsSync(CONFIG_PATH)) {
try { merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch {}
}
const templatePath = CONFIG_DIST_PATH;
let config = {};
if (fs.existsSync(templatePath)) {
const tpl = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
config = (tpl && tpl.env) || {};
} else if (merged.env && typeof merged.env === 'object') {
config = merged.env;
}
config.USER_NICKNAME = userInput.nickname || (config.USER_NICKNAME || '');
config.USER_PYTHON_PATH = userInput.pythonPath || (config.USER_PYTHON_PATH || '');
config.EMAIL_ADDRESS = userInput.emailAddr || (config.EMAIL_ADDRESS || '');
config.EMAIL_AUTH_CODE = userInput.emailCode || (config.EMAIL_AUTH_CODE || '');
config.WEB_SEARCH_API_KEY = userInput.webSearchKey || (config.WEB_SEARCH_API_KEY || '');
config.LLM_CONTEXT_WINDOW = String(userInput.llmContextWindow || config.LLM_CONTEXT_WINDOW || 262144);
config.MINI_COMPRESS_RATE = String(userInput.miniCompressRate || config.MINI_COMPRESS_RATE || 0.4);
config.HARD_COMPRESS_RATE = String(userInput.hardCompressRate || config.HARD_COMPRESS_RATE || 0.7);
config.CURRENT_TURNS = String(userInput.currentTurns || config.CURRENT_TURNS || 4);
config.IMG_SIZE = String(userInput.imgSize || config.IMG_SIZE || 768);
config.RAG_CHUNK_SIZE = String(userInput.ragChunkSize || config.RAG_CHUNK_SIZE || 500);
config.RAG_CHUNK_OVERLAP = String(userInput.ragChunkOverlap || config.RAG_CHUNK_OVERLAP || 50);
config.GROUNDING_WIDTH = String(userInput.groundingWidth || config.GROUNDING_WIDTH || 1000);
config.GROUNDING_HEIGHT = String(userInput.groundingHeight || config.GROUNDING_HEIGHT || 1000);
config.LOOP_DETECT_TODOLIST_STALE_ROUNDS = String(userInput.loopStaleRounds || config.LOOP_DETECT_TODOLIST_STALE_ROUNDS || 20);
config.LOOP_DETECT_REPEATED_TOOL_WARN = String(userInput.loopRepeatedWarn || config.LOOP_DETECT_REPEATED_TOOL_WARN || 3);
config.LOOP_DETECT_REPEATED_TOOL_END = String(userInput.loopRepeatedEnd || config.LOOP_DETECT_REPEATED_TOOL_END || 5);
config.SEND_FILE_SIZE_LIMIT = String(userInput.sendFileSizeLimit || config.SEND_FILE_SIZE_LIMIT || 30);
// 存储后端:json(本地文件) / mysql(数据库,存定时任务+会话历史)
// 向后兼容:优先读 STORAGE_BACKEND,回退旧的 CRON_STORAGE_BACKEND
config.STORAGE_BACKEND = userInput.cronStorageBackend || config.STORAGE_BACKEND || config.CRON_STORAGE_BACKEND || 'json';
// 保留本地服务模型(ASR/TTS/RAG/ImageGen):配置向导只覆盖用户主模型,不清除这四个注册项
const LOCAL_SERVICE_MODELS = new Set(['Qwen3-ASR-1.7B', 'VoxCPM1.5', 'Qwen3-Embedding-0.6B', 'Z-Image-Turbo']);
const preservedModels = (config.models || []).filter((m) => LOCAL_SERVICE_MODELS.has(m && m.model));
config.models = [{
id: 'user_configured',
name: userInput.model,
model: userInput.model,
api_key: userInput.apiKey,
base_url: userInput.baseUrl,
timeout: userInput.timeout || 60,
max_retries: 0,
}, ...preservedModels];
// GUI 模型选择归属 gui 分区
merged.gui = merged.gui || {};
merged.gui.gui_model_id = userInput.guiModelId || userInput.model || (merged.gui.gui_model_id || '');
merged.env = config;
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8');
console.log('[Electron] CONFIG_PATH:', CONFIG_PATH);
}
// ==================== Python 检测 ====================
function findSystemPython() {
// 1. 优先使用用户配置的 Python 路径
try {
if (fs.existsSync(CONFIG_PATH)) {
const merged = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
const config = (merged && merged.env) || {};
if (config.USER_PYTHON_PATH && fs.existsSync(config.USER_PYTHON_PATH)) {
return config.USER_PYTHON_PATH;
}
}
} catch {}
// 2. 常见 Python 安装路径
const candidates = [
'python',
'python3',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python312', 'python.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python311', 'python.exe'),
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python313', 'python.exe'),
'C:\\Python312\\python.exe',
'C:\\Python311\\python.exe',
'C:\\Python313\\python.exe',
];
for (const p of candidates) {
if (p === 'python' || p === 'python3') {
// PATH 中的 python 直接用,无法预检
return p;
}
if (fs.existsSync(p)) return p;
}
return 'python'; // 最后 fallback 到 PATH
}
// ==================== FastAPI 生命周期 ====================
let _fastapiStderr = ''; // 累积 stderr 用于超时诊断
let _fastapiReadySettled = false; // 健康检查已结束(成功或超时),停止轮询
function startFastAPI() {
return new Promise((resolve, reject) => {
killPortProcess(PORT);
_fastapiReadySettled = false;
const pythonExe = findSystemPython();
// 检查 Python 是否存在
if (!fs.existsSync(pythonExe)) {
reject(new Error(`Python 路径不存在:\n${pythonExe}\n\n请运行安装程序或手动配置 Python 路径。`));
return;
}
console.log(`[Electron] Python: ${pythonExe}`);
console.log(`[Electron] SRC_DIR: ${SRC_DIR}`);
_fastapiStderr = '';
// 启动 uvicorn:用 shell 模式确保 PYTHONPATH 含空格路径能正确传递
const uvicornArgs = ['-m', 'uvicorn', 'web.server:app', '--host', HOST, '--port', String(PORT), '--log-level', 'warning'];
pythonProcess = spawn(`"${pythonExe}"`, uvicornArgs, {
cwd: SRC_DIR,
env: {
...process.env,
PYTHONPATH: SRC_DIR,
PYTHONUNBUFFERED: '1',
WORKING_DIR: path.resolve(AGENT_DIR, '.'),
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
shell: true,
});
console.log(`[Electron] FastAPI PID: ${pythonProcess.pid}`);
pythonProcess.stdout.on('data', (d) => {
const line = d.toString().trim();
if (line) console.log(`[FastAPI] ${line}`);
});
pythonProcess.stderr.on('data', (d) => {
const text = d.toString().trim();
if (text) {
console.log(`[FastAPI] ${text}`);
_fastapiStderr += text + '\n';
if (_fastapiStderr.length > 5000) _fastapiStderr = _fastapiStderr.slice(-3000);
}
});
pythonProcess.on('error', (err) => {
console.error('[Electron] FastAPI Failed:', err.message);
reject(err);
});
pythonProcess.on('exit', (code) => {
console.log(`[Electron] FastAPI Exit Code: ${code}`);
pythonProcess = null;
if (!isQuitting && code !== 0 && code !== null && mainWindow) {
dialog.showErrorBox('Backend Service Exception', `FastAPI Exit Code: ${code}`);
}
});
waitForReady(resolve, reject, 30);
});
}
function waitForReady(resolve, reject, retriesLeft) {
if (_fastapiReadySettled) return;
if (retriesLeft <= 0) {
_fastapiReadySettled = true;
reject(new Error(
`FastAPI 启动超时\n\nPython: ${findSystemPython()}\n工作目录: ${SRC_DIR}\n\n最后 stderr 输出:\n${_fastapiStderr || '(无输出)'}`
));
return;
}
const req = http.get(`${BASE_URL}/api/health`, (res) => {
res.resume(); // 及时排空响应体,避免 keep-alive 悬挂
if (res.statusCode === 200) {
if (!_fastapiReadySettled) {
_fastapiReadySettled = true;
console.log('[Electron] FastAPI Ready');
resolve();
}
} else {
retry();
}
});
req.on('error', () => retry());
req.setTimeout(2000, () => { req.destroy(); retry(); });
function retry() {
// 已就绪/已超时后不再轮询,防止反复打印日志
setTimeout(() => {
if (!_fastapiReadySettled) waitForReady(resolve, reject, retriesLeft - 1);
}, 1000);
}
}
function stopFastAPI() {
// 先通过端口杀一次(清理所有关联进程)
killPortProcess(PORT);
// 再通过 PID 杀一次(确保主进程被杀)
if (pythonProcess) {
console.log('[Electron] Killing FastAPI Process...');
try {
if (process.platform === 'win32') {
execSync(`taskkill /pid ${pythonProcess.pid} /f /t 2>nul`, { stdio: 'ignore' });
} else {
pythonProcess.kill('SIGTERM');
}
} catch {}
pythonProcess = null;
}
}
// ==================== 窗口管理 ====================
// 缓存清理标记:仅首次运行清一次(应用页资源经 FastAPI no-cache 头加载,
// 后续启动无需反复清磁盘缓存,省掉每次启动的等待)
const CACHE_CLEARED_FLAG = () => path.join(app.getPath('userData'), '.cache-cleared');
/** 返回 true 表示本次应清理缓存(首次运行且已写标记);标记写入失败时保持旧行为 */
function ensureInitialCacheClear() {
try {
if (fs.existsSync(CACHE_CLEARED_FLAG())) return false;
fs.writeFileSync(CACHE_CLEARED_FLAG(), String(Date.now()), 'utf-8');
return true;
} catch {
return true;
}
}
async function createMainWindow(themeMode) {
themeMode = themeMode || getThemeMode();
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: 'minor Agent',
backgroundColor: themeMode === 'dark' ? '#1a1a2e' : '#eef1f7',
frame: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
// 仅首次运行清除缓存(本地文件与应用页资源不走 HTTP 缓存,无需每次清理)
if (ensureInitialCacheClear()) {
await mainWindow.webContents.session.clearCache();
await mainWindow.webContents.session.clearStorageData({ storages: ['caches', 'serviceworkers'] });
}
// 先显示本地过渡页:毛玻璃 + 中央图标(无任何按钮),主题随 theme_config.json 变化;
// FastAPI 就绪后再切到应用页;应用页自带的同款玻璃覆盖层会无缝接手
mainWindow.loadFile(path.join(__dirname, 'splash.html'), { query: { theme: themeMode } });
mainWindow.once('ready-to-show', () => mainWindow.show());
mainWindow.on('closed', () => { mainWindow = null; });
// 关闭拦截:若仍有后台终端任务在运行,先弹确认("终止并关闭 / 取消")
mainWindow.on('close', (e) => {
if (isQuitting || _closeConfirmed) return;
e.preventDefault();
checkRunningTasks().then(async (n) => {
if (n > 0) {
const { response } = await dialog.showMessageBox(mainWindow, {
type: 'warning',
buttons: ['终止并关闭', '取消'],
defaultId: 1,
cancelId: 1,
title: '后台终端仍在运行',
message: `仍有 ${n} 个后台终端任务在运行`,
detail: '终止这些任务并关闭应用?(若不终止,进程将与应用一并被清理)',
});
if (response !== 0) return;
}
_closeConfirmed = true;
mainWindow.close();
});
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
require('electron').shell.openExternal(url).catch(() => {});
return { action: 'deny' };
});
// 阻止应用内主窗口导航到外部网页(聊天消息里的 <a href> 默认会顶掉整个应用页,
// 且应用壳无地址栏/返回按钮,导致"跳走后回不来");外部链接一律交给系统浏览器打开。
// 本地源放行:file://(splash/打包页)与 127.0.0.1/localhost(FastAPI 应用页自身)
mainWindow.webContents.on('will-navigate', (event, url) => {
try {
const u = new URL(url);
const isLocal = u.protocol === 'file:' ||
u.hostname === '127.0.0.1' || u.hostname === 'localhost';
if (!isLocal) {
event.preventDefault();
require('electron').shell.openExternal(url).catch(() => {});
}
} catch (e) { /* 非法 URL,忽略 */ }
});
return mainWindow;
}
// 启动主流程:立即弹出毛玻璃窗口 → 后台启动 FastAPI → 就绪后加载应用页
async function launchMainFlow() {
const themeMode = getThemeMode();
try {
// 1. 创建并显示主窗口(本地过渡页,秒开,无按钮),主题随 theme_config.json
await createMainWindow(themeMode);
// 2. 后台启动 FastAPI(窗口已先显示,用户不会盯着空白等待)
await startFastAPI();
if (!mainWindow) return; // 启动期间用户已关闭窗口(window-all-closed 会走退出流程)
// 3. 后端就绪后加载应用页;带上主题参数,保证应用页首帧即为当前主题(避免深色闪屏),
// 页面加载完成后由前端播放玻璃层淡出动画
mainWindow.loadURL(`${BASE_URL}?theme=${themeMode}`);
} catch (err) {
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
app.quit();
}
}
// ==================== 应用生命周期 ====================
let setupWindow = null;
function createSetupWindow() {
setupWindow = new BrowserWindow({
width: 560,
height: 720,
minWidth: 500,
minHeight: 600,
title: 'minor Agent - 首次配置',
backgroundColor: '#f8fafc',
frame: false,
resizable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
show: false,
});
setupWindow.loadFile(path.join(__dirname, 'setup.html'));
setupWindow.once('ready-to-show', () => setupWindow.show());
setupWindow.on('closed', () => { setupWindow = null; });
}
app.whenReady().then(async () => {
// 隐藏菜单栏
Menu.setApplicationMenu(null);
if (isFirstRun()) {
// 首次运行:打开配置窗口(Python 环境安装 + LLM 配置)
createSetupWindow();
} else {
// 非首次:立即显示毛玻璃启动窗口,同时后台启动 FastAPI 和主流程
await launchMainFlow();
}
});
ipcMain.handle('save-config', async (_event, data) => {
try {
writeEnvConfig(data);
const testOk = await testLLMConnection(data);
if (!testOk) {
return { success: false, error: '无法连接到 LLM 服务,请检查 API Key 和 Base URL' };
}
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
});
ipcMain.on('launch-app', async () => {
// 标记正在启动,防止关闭 setup 窗口时触发 app.quit
isLaunching = true;
// 关闭配置窗口
if (setupWindow) {
setupWindow.close();
setupWindow = null;
}
// 立即显示毛玻璃启动窗口,后台启动 FastAPI 并加载应用页
try {
await launchMainFlow();
} finally {
isLaunching = false;
}
});
// ===== 首次运行检测(供前端查询) =====
ipcMain.handle('is-first-run', () => isFirstRun());
async function testLLMConnection(data) {
const https = require('https');
return new Promise((resolve) => {
// 检查 LLM 服务健康状态:GET /models 返回 200 即可
const url = new URL(data.baseUrl + (data.baseUrl.endsWith('/') ? '' : '/') + 'models');
const isHttps = url.protocol === 'https:';
const mod = isHttps ? https : http;
const req = mod.request({
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: {
'Authorization': `Bearer ${data.apiKey}`,
},
timeout: 15000,
rejectUnauthorized: false,
}, (res) => {
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.end();
});
}
// ==================== IPC:文件系统操作 ====================
const SUPPORTED_FILE_EXTS = new Set([
'.txt','.json','.md','.cpp','.c','.py','.m','.java','.html','.htm',
'.svg','.css','.js','.ts','.xml','.yaml','.yml','.toml',
'.csv','.log','.env','.ini','.cfg','.sh','.bat','.ps1','.sql',
'.r','.go','.rs','.rb','.php','.swift','.kt','.scala',
'.docx','.pptx','.pdf','.xlsx','.doc','.xls','.ppt',
'.png','.jpg','.jpeg','.gif','.bmp','.webp','.ico',
]);
function isSupportedFile(name) {
const ext = '.' + (name.split('.').pop() || '').toLowerCase();
return SUPPORTED_FILE_EXTS.has(ext);
}
function _getMime(filePath) {
const ext = '.' + (filePath.split('.').pop() || '').toLowerCase();
const map = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml', '.pdf': 'application/pdf',
'.html': 'text/html', '.htm': 'text/html',
};
return map[ext] || 'application/octet-stream';
}
function readDirRecursive(dirPath, depth, currentDepth, rootDir) {
if (currentDepth > depth) return [];
const baseRoot = rootDir || dirPath;
let results = [];
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') continue;
if (entry.isDirectory()) {
if (currentDepth < depth) {
results = results.concat(readDirRecursive(fullPath, depth, currentDepth + 1, baseRoot));
}
} else if (entry.isFile()) {
if (isSupportedFile(entry.name)) {
try {
const stat = fs.statSync(fullPath);
results.push({
path: path.relative(baseRoot, fullPath).replace(/\\/g, '/'),
name: entry.name,
is_dir: false,
size: stat.size,
});
} catch {}
}
}
}
} catch {}
return results;
}
ipcMain.handle('shell:openInExplorer', (_event, filePath) => {
// 在系统资源管理器中打开路径(不自动最大化:用 Shell.Application.Open 以还原态打开)
try {
if (!filePath) return false;
const { execFile } = require('child_process');
const escaped = String(filePath).replace(/'/g, "''");
const script = `$shell = New-Object -ComObject Shell.Application; $shell.Open('${escaped}')`;
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true });
return true;
} catch (e) {
console.error('[Electron] openInExplorer failed:', e.message);
return false;
}
});
ipcMain.handle('fs:readFile', async (_event, filePath) => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return { content, status: 'ok' };
} catch (err) {
return { content: '', status: 'error', error: err.message };
}
});
ipcMain.handle('fs:readBinaryFile', async (_event, filePath) => {
try {
const buf = fs.readFileSync(filePath);
return { data: buf.toString('base64'), mime: _getMime(filePath), status: 'ok' };
} catch (err) {
return { data: '', mime: '', status: 'error', error: err.message };
}
});
ipcMain.handle('fs:writeFile', async (_event, filePath, content) => {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf-8');
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:readDir', async (_event, dirPath, depth = 10) => {
try {
const files = readDirRecursive(dirPath, depth, 0);
return { files, status: 'ok' };
} catch (err) {
return { files: [], status: 'error', error: err.message };
}
});
ipcMain.handle('fs:stat', async (_event, filePath) => {
try {
const s = fs.statSync(filePath);
return { exists: true, is_dir: s.isDirectory(), size: s.size, mtime: s.mtimeMs };
} catch {
return { exists: false };
}
});
ipcMain.handle('fs:rename', async (_event, oldPath, newPath) => {
try {
fs.mkdirSync(path.dirname(newPath), { recursive: true });
fs.renameSync(oldPath, newPath);
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:delete', async (_event, filePath) => {
try {
// 使用 shell.trashItem 移入回收站,而非永久删除
const { shell } = require('electron');
await shell.trashItem(filePath);
return { ok: true };
} catch (err) {
// 回收站不可用时回退到永久删除(如网络驱动器、U盘等)
try {
const s = fs.statSync(filePath);
if (s.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
fs.unlinkSync(filePath);
}
return { ok: true };
} catch (err2) {
return { ok: false, error: err2.message };
}
}
});
ipcMain.handle('fs:mkdir', async (_event, dirPath) => {
try {
fs.mkdirSync(dirPath, { recursive: true });
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:copyFile', async (_event, srcPath, destDir) => {
try {
const srcName = path.basename(srcPath);
let destPath = path.join(destDir, srcName);
// 避免重名:追加 _1, _2 ...
let counter = 1;
const ext = path.extname(srcName);
const base = path.basename(srcName, ext);
while (fs.existsSync(destPath)) {
destPath = path.join(destDir, `${base}_${counter}${ext}`);
counter++;
}
fs.mkdirSync(destDir, { recursive: true });
const stat = fs.statSync(srcPath);
if (stat.isDirectory()) {
// 目录递归复制(Node.js 16.7+)
fs.cpSync(srcPath, destPath, { recursive: true });
} else {
fs.copyFileSync(srcPath, destPath);
}
return { ok: true, destPath, destName: path.basename(destPath) };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:writeFromBuffer', async (_event, destDir, fileName, base64Data) => {
try {
let destPath = path.join(destDir, fileName);
// 避免重名
let counter = 1;
const ext = path.extname(fileName);
const base = path.basename(fileName, ext);
while (fs.existsSync(destPath)) {
destPath = path.join(destDir, `${base}_${counter}${ext}`);
counter++;
}
fs.mkdirSync(destDir, { recursive: true });
const buf = Buffer.from(base64Data, 'base64');
fs.writeFileSync(destPath, buf);
return { ok: true, destPath, destName: path.basename(destPath) };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('fs:exists', async (_event, filePath) => {
return fs.existsSync(filePath);
});
// ==================== IPC:对话框 ====================
ipcMain.handle('dialog:openFolder', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory'],
title: '选择工作文件夹',
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
return { canceled: false, path: result.filePaths[0] };
});
ipcMain.handle('dialog:openFile', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
title: '打开文件',
filters: [
{ name: '支持的文件', extensions: [
'txt','json','md','cpp','c','py','m','java','html','htm',
'svg','css','js','ts','xml','yaml','yml','toml',
'csv','log','env','ini','cfg','sh','bat','ps1','sql',
'r','go','rs','rb','php','swift','kt','scala',
'docx','pptx','pdf','xlsx',
'png','jpg','jpeg','gif','bmp','webp',
]},
{ name: '所有文件', extensions: ['*'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
const filePath = result.filePaths[0];
const name = path.basename(filePath);
try {
const content = fs.readFileSync(filePath, 'utf-8');
return { canceled: false, path: filePath, name, content };
} catch {
return { canceled: false, path: filePath, name, content: '' };
}
});
ipcMain.handle('dialog:saveFile', async (_event, defaultPath) => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showSaveDialog(win, {
defaultPath: defaultPath,
title: '保存文件',
});
if (result.canceled) return { canceled: true };
return { canceled: false, path: result.filePath };
});
// ==================== IPC:应用信息 ====================
ipcMain.handle('app:getPath', () => AGENT_DIR);
// ===== Python 检测 =====
ipcMain.handle('python:detect', async () => {
const pythonPath = findSystemPython();
let version = '';
let versionOk = false;
try {
const result = execSync(`"${pythonPath}" --version`, { timeout: 5000, stdio: 'pipe' });
version = result.toString().trim();
// 检查版本 >= 3.12
const m = version.match(/Python\s+(\d+)\.(\d+)/i);
if (m) {
const major = parseInt(m[1]), minor = parseInt(m[2]);
versionOk = major > 3 || (major === 3 && minor >= 12);
}
} catch {}
return { pythonPath, version, versionOk };
});
// ===== 选择 Python 可执行文件 =====
ipcMain.handle('dialog:pickPython', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
title: '选择 Python 可执行文件',
filters: [
{ name: 'Python 可执行文件', extensions: ['exe'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
return { canceled: false, path: result.filePaths[0] };
});
// ===== Python 环境安装 =====
ipcMain.handle('python:setup', async (event, pythonPath) => {
const win = BrowserWindow.fromWebContents(event.sender);
const send = (type, data) => {
try { win?.webContents?.send('python:setup-progress', { type, ...data }); } catch {}
};
try {
send('status', { text: '检测 Python...' });
// 验证 Python 可用
try {
execSync(`"${pythonPath}" --version`, { timeout: 5000, stdio: 'pipe' });
} catch {
return { ok: false, error: 'Python 无法启动,请检查路径' };
}
// 确定 venv 路径(在安装目录下,卸载时可一并清理)
const installDir = isPackaged ? path.dirname(app.getPath('exe')) : AGENT_DIR;
const venvDir = path.join(installDir, 'agent_venv');
const venvPython = path.join(venvDir, 'Scripts', 'python.exe');
if (!fs.existsSync(venvPython)) {
send('status', { text: '创建虚拟环境...' });
try {
execSync(`"${pythonPath}" -m venv "${venvDir}"`, { timeout: 60000, stdio: 'pipe' });
} catch (e) {
return { ok: false, error: '创建虚拟环境失败: ' + e.message };
}
} else {
send('status', { text: '使用已有虚拟环境' });
}
// pip install -r requirements.txt
const reqPath = path.join(__dirname, '..', 'requirements.txt');
if (!fs.existsSync(reqPath)) {
return { ok: false, error: '未找到 requirements.txt' };
}
send('status', { text: '安装依赖包...' });
const pipArgs = ['-m', 'pip', 'install', '-r', reqPath];
const proc = spawn(venvPython, pipArgs, {
cwd: path.dirname(venvDir),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
// 实时流式输出安装日志
let logLines = [];
let stderrBuf = '';
let pendingLine = '';
const flushLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return;
// 过滤掉无意义的进度条和空白行
if (/^\s*\d+%/.test(trimmed)) return;
if (/^\s*[-|/\\]+\s*$/.test(trimmed)) return;
logLines.push(trimmed);
if (logLines.length > 200) logLines = logLines.slice(-150);
send('log', { lines: [trimmed], all: logLines.join('\n') });
};
proc.stdout.on('data', (chunk) => {
const text = chunk.toString();
const parts = (pendingLine + text).split('\n');
pendingLine = parts.pop() || '';
for (const line of parts) flushLine(line);
});
proc.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderrBuf += text;
if (stderrBuf.length > 10000) stderrBuf = stderrBuf.slice(-8000);
const parts = text.split('\n');
// stderr 最后一行可能不完整,但 pip 进度条通常每行独立
for (const line of parts) {
const trimmed = line.trim();
if (trimmed && !trimmed.includes('%')) flushLine(trimmed);
}
});
return new Promise((resolve) => {
proc.on('close', (code) => {
if (code === 0) {
send('done', { venvDir, venvPython });
resolve({ ok: true, venvDir, venvPython });
} else {
const errDetail = stderrBuf.split('\n').filter(l => l.trim()).slice(-10).join('\n');
resolve({ ok: false, error: `pip install 退出码 ${code}${errDetail ? '\n' + errDetail : ''}` });
}
});
proc.on('error', (err) => {
resolve({ ok: false, error: err.message });
});
});
} catch (e) {
send('status', { text: '安装失败' });
return { ok: false, error: e.message };
}
});
// ===== 文件系统 IPC =====
let _fsWatcher = null;
let _watchedPath = null;
ipcMain.handle('fs:watch', async (_event, dirPath) => {
if (_fsWatcher) { _fsWatcher.close(); _fsWatcher = null; }
_watchedPath = dirPath;
try {
_fsWatcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
if (!filename || !mainWindow) return;
const rel = filename.replace(/\\/g, '/');
if (filename.startsWith('.') || filename.includes('node_modules') || filename.includes('__pycache__')) return;
const absPath = path.join(dirPath, filename);
try {
const stat = fs.statSync(absPath);
const file = {
path: rel, name: path.basename(filename),
is_dir: stat.isDirectory(), size: stat.size,
};
mainWindow.webContents.send('fs:changed', { event: eventType, file });
} catch {
// 文件可能已被删除,仍然发送事件让前端感知变化
mainWindow.webContents.send('fs:changed', {
event: eventType,
file: { path: rel, name: path.basename(filename), is_dir: false, size: 0 },
});
}
});
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('fs:unwatch', async () => {
if (_fsWatcher) { _fsWatcher.close(); _fsWatcher = null; _watchedPath = null; }
return { ok: true };
});
// ===== 窗口控制(自定义标题栏) =====
ipcMain.on('window:minimize', () => {
if (mainWindow) mainWindow.minimize();
});
// ===== 配置窗口关闭 =====
ipcMain.on('setup:close', () => {
if (setupWindow) setupWindow.close();
else app.quit();
});
let _maximizeLock = false;
ipcMain.on('window:maximize', () => {
if (_maximizeLock || !mainWindow) return;
_maximizeLock = true;
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize();
setTimeout(() => { _maximizeLock = false; }, 300);
});
ipcMain.on('window:close', () => {
if (mainWindow) mainWindow.close();
});
// 最大化/还原状态变化时通知渲染进程
app.on('browser-window-created', (_, win) => {
win.on('maximize', () => win.webContents.send('window:maximized', true));
win.on('unmaximize', () => win.webContents.send('window:maximized', false));
});
app.on('window-all-closed', () => {
if (isLaunching) return; // setup → main 过渡中,不退出
isQuitting = true;
killAllTerminals();
stopFastAPI();
app.quit();
});
app.on('before-quit', () => {
isQuitting = true;
killAllTerminals();
stopFastAPI();
});
// ==================== 内置 PowerShell 终端(ConPTY 真终端) ====================
// 每个标签对应一个经 node-pty (Windows ConPTY) 启动的真实 PowerShell 会话,
// PSReadLine / Tab 补全 / 历史 / 全屏应用由真实控制台提供,前端 xterm.js 渲染。
// 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。
const _terminals = new Map(); // id -> { proc, sender }
let _terminalSeq = 0;
// 优先 PowerShell 7(truecolor / PSReadLine 更强),回退 Windows PowerShell 5.1
let _shellPathCache = '';
function resolveShellPath() {
if (_shellPathCache) return _shellPathCache;
try {
execSync('where pwsh.exe', { stdio: 'ignore', shell: true });
_shellPathCache = 'pwsh.exe';
} catch (e) {
_shellPathCache = path.join(
process.env.SystemRoot || 'C:\\Windows',
'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'
);
}
return _shellPathCache;
}
function forceKill(proc) {
try { proc.kill(); } catch (e) { /* ignore */ }
// Windows 兜底:taskkill 结束整个进程树(处理 Ctrl 信号不响应/子进程残留)
try {
execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' });
} catch (e) { /* 进程已退出 */ }
}
function killAllTerminals() {
for (const { proc } of _terminals.values()) {
forceKill(proc);
}
_terminals.clear();
}
ipcMain.handle('terminal:spawn', (event, { cwd, cols, rows } = {}) => {
const id = ++_terminalSeq;
let proc;
try {
// ConPTY 需真实控制台;懒加载,模块缺失时不影响应用其余功能
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
proc = pty.spawn(resolveShellPath(), ['-NoLogo'], {
cwd: (cwd && fs.existsSync(cwd)) ? cwd : undefined,
cols: (cols | 0) || 80,
rows: (rows | 0) || 24,
env: { ...process.env, TERM: 'xterm-256color' },
});
} catch (e) {
return { id, ok: false, error: String((e && e.message) || e) };
}
_terminals.set(id, { proc, sender: event.sender });
proc.onData((chunk) => {
if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk });
});
proc.onExit(({ exitCode }) => {
_terminals.delete(id);
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code: exitCode });
});
return { id, ok: true };
});
ipcMain.on('terminal:input', (_event, { id, data } = {}) => {
const t = _terminals.get(id);
if (!t || !data) return;
try { t.proc.write(data); } catch (e) { /* ignore */ }
});
ipcMain.on('terminal:resize', (_event, { id, cols, rows } = {}) => {
const t = _terminals.get(id);
if (!t) return;
try { t.proc.resize((cols | 0) || 80, (rows | 0) || 24); } catch (e) { /* ignore */ }
});
ipcMain.on('terminal:kill', (_event, { id } = {}) => {
const t = _terminals.get(id);
if (!t) return;
forceKill(t.proc);
_terminals.delete(id);
});
ipcMain.on('terminal:killAll', () => {
killAllTerminals();
});
app.on('activate', () => {
if (mainWindow === null) {
launchMainFlow();
}
});
// 防止多实例
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}