1057 lines
36 KiB
JavaScript
1057 lines
36 KiB
JavaScript
// 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
|
||
|
||
// ==================== 端口清理 ====================
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ==================== 窗口管理 ====================
|
||
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,
|
||
});
|
||
|
||
// 清除缓存后再加载,确保获取最新 JS/CSS
|
||
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.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 终端 ====================
|
||
// 每个标签对应一个 powershell 子进程;面板折叠不 kill,仅标签 x / 右上角 x / 退出时清理。
|
||
const _terminals = new Map(); // id -> { proc, sender }
|
||
let _terminalSeq = 0;
|
||
|
||
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 } = {}) => {
|
||
const id = ++_terminalSeq;
|
||
let proc;
|
||
try {
|
||
proc = spawn('powershell.exe', ['-NoLogo'], {
|
||
cwd: (cwd && fs.existsSync(cwd)) ? cwd : undefined,
|
||
windowsHide: true,
|
||
});
|
||
} catch (e) {
|
||
return { id, ok: false, error: String(e && e.message || e) };
|
||
}
|
||
_terminals.set(id, { proc, sender: event.sender });
|
||
proc.stdout.on('data', (chunk) => {
|
||
if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') });
|
||
});
|
||
proc.stderr.on('data', (chunk) => {
|
||
if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') });
|
||
});
|
||
proc.on('exit', (code) => {
|
||
_terminals.delete(id);
|
||
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code });
|
||
});
|
||
proc.on('error', (err) => {
|
||
if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code: -1, error: String(err.message || err) });
|
||
});
|
||
return { id, ok: true };
|
||
});
|
||
|
||
ipcMain.on('terminal:input', (_event, { id, data } = {}) => {
|
||
const t = _terminals.get(id);
|
||
if (!t || !data) return;
|
||
try { t.proc.stdin.write(data); } 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();
|
||
}
|
||
});
|
||
}
|