initial open-source release

This commit is contained in:
minorQAQ committed 2026-08-01 11:15:19 +08:00
commit 01c4605498
224 files changed
+55124

No files matched your search

+906
View File
@@ -0,0 +1,906 @@
// 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/config/env_config.json)
const CONFIG_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config.json');
const CONFIG_DIST_PATH = path.join(SRC_DIR, 'agent', 'config', 'env_config_dist.json');
const HOST = '127.0.0.1';
const PORT = 8765;
const BASE_URL = `http://${HOST}:${PORT}`;
// ==================== 全局状态 ====================
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 config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
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) {
let config;
const templatePath = CONFIG_DIST_PATH;
if (fs.existsSync(templatePath)) {
config = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
} else if (fs.existsSync(CONFIG_PATH)) {
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} else {
config = {};
}
config.WORKING_DIR = isPackaged ? process.resourcesPath : AGENT_DIR;
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.ASR_BASE_URL = userInput.asrUrl || (config.ASR_BASE_URL || '');
config.STREAMING_TTS_URL = userInput.ttsUrl || (config.STREAMING_TTS_URL || '');
config.RAG_BASE_URL = userInput.ragUrl || (config.RAG_BASE_URL || '');
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';
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,
}];
config.gui_model_id = userInput.guiModelId || userInput.model || (config.gui_model_id || '');
const configDir = path.dirname(CONFIG_PATH);
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
console.log('[Electron] 配置已写入:', CONFIG_PATH);
}
// ==================== Python 检测 ====================
function findSystemPython() {
// 1. 优先使用用户配置的 Python 路径
try {
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
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 用于超时诊断
function startFastAPI() {
return new Promise((resolve, reject) => {
killPortProcess(PORT);
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 启动失败:', err.message);
reject(err);
});
pythonProcess.on('exit', (code) => {
console.log(`[Electron] FastAPI 退出,码: ${code}`);
pythonProcess = null;
if (!isQuitting && code !== 0 && code !== null && mainWindow) {
dialog.showErrorBox('后端服务异常', `FastAPI 意外退出(退出码: ${code})。`);
}
});
waitForReady(resolve, reject, 30);
});
}
function waitForReady(resolve, reject, retriesLeft) {
if (retriesLeft <= 0) {
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) => {
if (res.statusCode === 200) {
console.log('[Electron] FastAPI 就绪');
resolve();
} else {
retry();
}
});
req.on('error', () => retry());
req.setTimeout(2000, () => { req.destroy(); retry(); });
function retry() {
setTimeout(() => waitForReady(resolve, reject, retriesLeft - 1), 1000);
}
}
function stopFastAPI() {
// 先通过端口杀一次(清理所有关联进程)
killPortProcess(PORT);
// 再通过 PID 杀一次(确保主进程被杀)
if (pythonProcess) {
console.log('[Electron] 关闭 FastAPI...');
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() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: 'minor Agent',
backgroundColor: '#1a1a2e',
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'] });
mainWindow.loadURL(BASE_URL);
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' };
});
}
// ==================== 应用生命周期 ====================
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 和主窗口
try {
await startFastAPI();
await createMainWindow();
} catch (err) {
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
app.quit();
}
}
});
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 startFastAPI();
await createMainWindow();
} catch (err) {
dialog.showErrorBox('启动失败', `无法启动 minor Agent:\n${err.message}`);
app.quit();
} 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('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;
stopFastAPI();
app.quit();
});
app.on('before-quit', () => {
isQuitting = true;
stopFastAPI();
});
app.on('activate', () => {
if (mainWindow === null) {
createMainWindow();
}
});
// 防止多实例
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
+66
View File
@@ -0,0 +1,66 @@
// preload.js — 安全桥接层(桌面版)
// 暴露最小化 API 供 setup.html 和主应用使用
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
isElectron: true,
// ===== 文件系统 =====
readFile: (filePath) => ipcRenderer.invoke('fs:readFile', filePath),
readBinaryFile: (filePath) => ipcRenderer.invoke('fs:readBinaryFile', filePath),
writeFile: (filePath, content) => ipcRenderer.invoke('fs:writeFile', filePath, content),
readDir: (dirPath, depth) => ipcRenderer.invoke('fs:readDir', dirPath, depth ?? 10),
stat: (filePath) => ipcRenderer.invoke('fs:stat', filePath),
rename: (oldPath, newPath) => ipcRenderer.invoke('fs:rename', oldPath, newPath),
deleteFile: (filePath) => ipcRenderer.invoke('fs:delete', filePath),
mkdir: (dirPath) => ipcRenderer.invoke('fs:mkdir', dirPath),
exists: (filePath) => ipcRenderer.invoke('fs:exists', filePath),
copyFile: (srcPath, destDir) => ipcRenderer.invoke('fs:copyFile', srcPath, destDir),
writeFromBuffer: (destDir, fileName, base64Data) => ipcRenderer.invoke('fs:writeFromBuffer', destDir, fileName, base64Data),
// ===== 对话框 =====
pickFolder: () => ipcRenderer.invoke('dialog:openFolder'),
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (defaultPath) => ipcRenderer.invoke('dialog:saveFile', defaultPath),
// ===== 应用信息 =====
getAppPath: () => ipcRenderer.invoke('app:getPath'),
// ===== 首次配置 =====
saveConfig: (data) => ipcRenderer.invoke('save-config', data),
launchApp: () => ipcRenderer.send('launch-app'),
isFirstRun: () => ipcRenderer.invoke('is-first-run'),
onSetupComplete: (cb) => {
ipcRenderer.on('setup:complete', () => cb());
},
// ===== 文件系统监听 =====
watchDir: (dirPath) => ipcRenderer.invoke('fs:watch', dirPath),
unwatchDir: () => ipcRenderer.invoke('fs:unwatch'),
onFileChanged: (cb) => {
ipcRenderer.on('fs:changed', (_e, data) => cb(data));
},
// ===== 窗口控制(自定义标题栏) =====
minimizeWindow: () => ipcRenderer.send('window:minimize'),
maximizeWindow: () => ipcRenderer.send('window:maximize'),
closeWindow: () => ipcRenderer.send('window:close'),
onMaximizeChanged: (cb) => {
ipcRenderer.on('window:maximized', (_e, maximized) => cb(maximized));
},
// ===== 配置窗口控制 =====
setupClose: () => ipcRenderer.send('setup:close'),
// ===== Python 环境安装 =====
pythonDetect: () => ipcRenderer.invoke('python:detect'),
pickPython: () => ipcRenderer.invoke('dialog:pickPython'),
pythonSetup: (pythonPath) => ipcRenderer.invoke('python:setup', pythonPath),
onPythonSetupProgress: (cb) => {
const handler = (_e, data) => cb(data);
ipcRenderer.on('python:setup-progress', handler);
return () => ipcRenderer.removeListener('python:setup-progress', handler);
},
});
+622
View File
@@ -0,0 +1,622 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>minor Agent - 首次配置</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%; overflow: hidden;
font-family: "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, sans-serif;
background: #f8fafc;
color: #0f172a;
}
/* ===== 自定义标题栏 ===== */
.title-bar {
display: flex; align-items: center;
height: 36px; padding: 0 8px 0 14px;
background: #e2e8f0;
-webkit-app-region: drag;
user-select: none; flex-shrink: 0;
}
.title-bar-name {
font-size: 0.78rem; font-weight: 600;
color: #475569; letter-spacing: 0.02em;
}
.title-bar-actions {
display: flex; gap: 2px; margin-left: auto;
-webkit-app-region: no-drag;
}
.title-bar-btn {
width: 36px; height: 28px; border: none;
background: transparent; color: #64748b;
font-family: "Segoe MDL2 Assets", "Segoe UI", sans-serif;
font-size: 0.65rem; cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
}
.title-bar-btn:hover { background: rgba(0,0,0,0.08); color: #334155; }
.title-bar-btn--close:hover { background: #e81123; color: #fff; }
/* ===== 内容区域 ===== */
.setup-body {
height: calc(100% - 36px);
overflow-y: auto;
display: flex; align-items: center; justify-content: center;
}
.setup-card {
background: linear-gradient(165deg, #ffffff, #f8fafc);
border: 1px solid rgba(14, 116, 144, 0.12);
border-radius: 16px;
padding: 28px 32px;
width: 540px;
max-width: 96%;
max-height: 94%;
overflow-y: auto;
box-shadow: 0 12px 48px rgba(14, 116, 144, 0.12), 0 4px 12px rgba(15, 23, 42, 0.06);
animation: cardIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
margin: auto;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
h1 { font-size: 20px; font-weight: 700; margin: 0 0 2px; color: #0f172a; }
.subtitle { font-size: 13px; color: #64748b; margin-bottom: 6px; }
.step-indicator {
display: flex; gap: 8px; margin-bottom: 18px; align-items: center;
font-size: 13px; color: #94a3b8;
}
.step-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #e2e8f0; transition: background 0.3s;
}
.step-dot.active { background: #0e7490; }
.step-dot.done { background: #16a34a; }
.page { display: none; }
.page.active { display: block; }
.section-title {
font-size: 14px; font-weight: 600; color: #334155;
margin-top: 14px; margin-bottom: 4px;
}
.section-title:first-of-type { margin-top: 0; }
.hint { font-size: 11px; color: #94a3b8; margin: 3px 0 6px; }
.hint a { color: #0e7490; }
.optional-badge {
display: inline-block; background: #f1f5f9;
color: #94a3b8; font-size: 10px; padding: 1px 6px;
border-radius: 4px; margin-left: 6px; vertical-align: middle;
}
.required-mark { color: #ef4444; margin-left: 2px; }
label {
display: block; font-size: 13px; color: #475569;
margin-bottom: 4px; margin-top: 14px;
}
label:first-of-type { margin-top: 0; }
input, select {
width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid #cbd5e1; background: #f8fafc;
color: #0f172a; font-size: 14px; outline: none;
transition: border-color 0.2s, background 0.2s;
}
input:focus, select:focus { border-color: #0e7490; background: #fff; }
input::placeholder { color: #94a3b8; }
.btn-row { display: flex; gap: 10px; margin-top: 22px; }
button {
flex: 1; padding: 10px 14px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: opacity 0.2s, transform 0.1s, background 0.2s;
}
button:hover { opacity: 0.9; }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary {
background: linear-gradient(135deg, #0e7490, #0891b2); color: #fff;
}
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; color: #334155; }
.divider {
border: none; border-top: 1px solid #e2e8f0; margin: 16px 0;
}
.error {
color: #ef4444; font-size: 12px; margin-top: 10px; display: none;
white-space: pre-wrap; max-height: 120px; overflow-y: auto;
}
.error.show { display: block; }
.two-col { display: flex; gap: 12px; }
.two-col > div { flex: 1; }
.two-col label { margin-top: 0; }
.path-row { display: flex; gap: 8px; align-items: center; }
.path-row input { flex: 1; }
.btn-browse {
flex: 0 0 auto; padding: 9px 14px;
background: #f1f5f9; color: #475569; border-radius: 8px;
border: 1px solid #cbd5e1; cursor: pointer; font-size: 13px;
font-weight: 500; -webkit-app-region: no-drag;
}
.btn-browse:hover { background: #e2e8f0; color: #334155; }
/* ===== 安装日志 ===== */
.setup-progress { margin-top: 14px; }
.progress-status { font-size: 13px; color: #475569; margin-bottom: 6px; }
.install-log {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 11px; color: #64748b;
background: #f1f5f9; border: 1px solid #e2e8f0;
border-radius: 6px; padding: 8px 10px;
max-height: 180px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
line-height: 1.5;
}
</style>
</head>
<body>
<!-- 自定义标题栏 -->
<header class="title-bar">
<span class="title-bar-name">minor Agent</span>
<div class="title-bar-actions">
<button class="title-bar-btn title-bar-btn--close" id="btnClose" title="关闭">&#xE8BB;</button>
</div>
</header>
<!-- 内容区域 -->
<div class="setup-body">
<div class="setup-card">
<h1>minor Agent</h1>
<p class="subtitle">首次使用 · 请完成以下配置</p>
<div class="step-indicator">
<span class="step-dot active" id="dot1"></span>
<span>环境准备</span>
<span style="margin:0 4px;color:#94a3b8;">→</span>
<span class="step-dot" id="dot2"></span>
<span>模型</span>
<span style="margin:0 4px;color:#94a3b8;">→</span>
<span class="step-dot" id="dot3"></span>
<span>高级</span>
</div>
<!-- 第1页 -->
<form class="page active" id="page1">
<div class="section-title">Python 环境准备<span class="required-mark">*</span></div>
<p class="hint">minor Agent 需要 Python 3.12+。如果系统已安装 Python,将自动检测。</p>
<label for="pythonPath">Python 可执行文件路径</label>
<div class="path-row">
<input id="pythonPath" type="text" placeholder="自动检测中..." />
<button type="button" class="btn-browse" id="browsePython">浏览...</button>
</div>
<div class="setup-progress" id="setupProgress" hidden>
<div class="progress-status" id="progressStatus">准备中...</div>
<div class="install-log" id="installLog"></div>
</div>
<div class="btn-row">
<button type="button" class="btn-primary" id="btnSetupPython" disabled>安装依赖环境</button>
</div>
<p class="error" id="pythonError"></p>
<hr class="divider" />
<div class="section-title">用户信息</div>
<label for="nickname">您的昵称</label>
<input id="nickname" type="text" placeholder="输入昵称,方便识别" />
<hr class="divider" />
<div class="section-title">邮件服务<span class="optional-badge">可选</span></div>
<p class="hint">不填则无法使用邮件收发功能。可跳过。<br/>目前支持 QQ邮箱、网易163邮箱。</p>
<label for="emailAddr">邮箱地址</label>
<input id="emailAddr" type="email" placeholder="example@qq.com" />
<label for="emailCode">邮箱授权码</label>
<input id="emailCode" type="password" placeholder="SMTP 授权码(非登录密码)" />
<hr class="divider" />
<div class="section-title">联网搜索引擎<span class="optional-badge">可选</span></div>
<p class="hint">不填则无法使用 web_search 联网搜索功能。可跳过。<br/>支持 Tavily(免费额度)/ Firecrawl / 火山引擎。<br/>请在设置中配置 WEB_SEARCH_ENGINE 选择引擎,默认 Tavily。</p>
<label for="tavilyKey">搜索 API Key (WEB_SEARCH_API_KEY)</label>
<input id="webSearchKey" type="password" placeholder="tvly-xxx... 或 fc-xxx... 或 AIza... 等" />
<hr class="divider" />
<div class="section-title">ASR / TTS / RAG 服务器<span class="optional-badge">可选</span></div>
<p class="hint">语音识别(ASR)、语音合成(TTS)、知识库(RAG)需独立部署的服务端。<br/>不填则对应功能不可用。</p>
<label for="asrUrl">ASR 服务地址(语音转文字)</label>
<input id="asrUrl" type="text" placeholder="http://localhost:8901" />
<label for="ttsUrl">TTS 服务地址(文字转语音)</label>
<input id="ttsUrl" type="text" placeholder="http://localhost:8902" />
<label for="ragUrl">RAG 服务地址(知识库检索)</label>
<input id="ragUrl" type="text" placeholder="http://localhost:8903" />
<div class="btn-row">
<button type="button" class="btn-primary" id="nextPage1" disabled>下一页 →</button>
</div>
</form>
<!-- 第2页 -->
<form class="page" id="page2">
<div class="section-title">LLM 对话模型<span class="required-mark">*</span></div>
<p class="hint">Agent 核心推理模型,必须填写。</p>
<label for="model">Model Name / ID<span class="required-mark">*</span></label>
<input id="model" type="text" placeholder="例如:gpt-4o / Qwen3.5-122B-A10B" required />
<label for="apiKey">API Key<span class="required-mark">*</span></label>
<input id="apiKey" type="password" placeholder="sk-xxx..." required />
<label for="baseUrl">API Base URL<span class="required-mark">*</span></label>
<input id="baseUrl" type="text" placeholder="https://api.openai.com/v1" required />
<label for="timeout">超时时间(秒)</label>
<input id="timeout" type="number" value="60" min="10" max="600" />
<hr class="divider" />
<div class="btn-row">
<button type="button" class="btn-secondary" id="prevPage2">← 返回上一页</button>
<button type="button" class="btn-primary" id="nextPage2">下一页 →</button>
</div>
</form>
<!-- 第3页 -->
<form class="page" id="page3">
<div class="section-title">Agent 运行参数</div>
<p class="hint">以下均有默认值,一般无需修改。</p>
<label for="llmContextWindow">LLM 上下文窗口大小</label>
<input id="llmContextWindow" type="number" value="262144" />
<div class="two-col">
<div>
<label for="miniCompressRate">轻度压缩率</label>
<input id="miniCompressRate" type="number" value="0.4" step="0.1" min="0.1" max="0.9" />
</div>
<div>
<label for="hardCompressRate">深度压缩率</label>
<input id="hardCompressRate" type="number" value="0.7" step="0.1" min="0.1" max="0.9" />
</div>
</div>
<label for="currentTurns">保留最近 N 轮对话</label>
<input id="currentTurns" type="number" value="4" min="1" max="20" />
<label for="imgSize">图片尺寸(像素)</label>
<input id="imgSize" type="number" value="768" min="256" max="2048" />
<hr class="divider" />
<div class="section-title">RAG 知识库参数</div>
<div class="two-col">
<div>
<label for="ragChunkSize">文档分块大小</label>
<input id="ragChunkSize" type="number" value="500" min="100" max="5000" />
</div>
<div>
<label for="ragChunkOverlap">分块重叠量</label>
<input id="ragChunkOverlap" type="number" value="50" min="0" max="500" />
</div>
</div>
<hr class="divider" />
<div class="section-title">GUI 截图参数</div>
<div class="two-col">
<div>
<label for="groundingWidth">截图宽度</label>
<input id="groundingWidth" type="number" value="1000" min="400" max="4000" />
</div>
<div>
<label for="groundingHeight">截图高度</label>
<input id="groundingHeight" type="number" value="1000" min="400" max="4000" />
</div>
</div>
<hr class="divider" />
<div class="section-title">循环检测参数</div>
<div class="two-col">
<div>
<label for="loopStaleRounds">TodoList 停滞轮数</label>
<input id="loopStaleRounds" type="number" value="20" min="5" max="100" />
</div>
<div>
<label for="loopRepeatedWarn">重复工具警告轮数</label>
<input id="loopRepeatedWarn" type="number" value="3" min="1" max="20" />
</div>
</div>
<label for="loopRepeatedEnd">重复工具终止轮数</label>
<input id="loopRepeatedEnd" type="number" value="5" min="2" max="50" />
<label for="sendFileSizeLimit">发送文件大小限制(MB)</label>
<input id="sendFileSizeLimit" type="number" value="30" min="1" max="500" />
<hr class="divider" />
<div class="section-title">定时任务存储方式</div>
<p class="hint">定时任务数据的存储后端。当前仅支持 JSON 本地文件;MySQL 数据库为预留选项(开发中,暂不可用)。</p>
<label for="cronStorageBackend">存储后端</label>
<select id="cronStorageBackend">
<option value="json" selected>JSON(本地文件,推荐)</option>
<option value="mysql" disabled>MySQL 数据库(开发中,暂不可用)</option>
</select>
<div class="btn-row">
<button type="button" class="btn-secondary" id="prevPage3">← 返回上一页</button>
<button type="submit" class="btn-primary" id="submitBtn">保存并启动</button>
</div>
<p class="error" id="errorMsg"></p>
</form>
</div>
</div>
<script>
// ===== 标题栏关闭按钮 =====
document.getElementById('btnClose').addEventListener('click', () => {
if (window.electronAPI?.setupClose) {
window.electronAPI.setupClose();
}
});
// ===== 配置页逻辑 =====
const page1 = document.getElementById('page1');
const page2 = document.getElementById('page2');
const page3 = document.getElementById('page3');
const dot1 = document.getElementById('dot1');
const dot2 = document.getElementById('dot2');
const dot3 = document.getElementById('dot3');
const errorMsg = document.getElementById('errorMsg');
const submitBtn = document.getElementById('submitBtn');
const pythonPathInput = document.getElementById('pythonPath');
const btnSetupPython = document.getElementById('btnSetupPython');
const nextPage1Btn = document.getElementById('nextPage1');
const setupProgress = document.getElementById('setupProgress');
const progressStatus = document.getElementById('progressStatus');
const pythonError = document.getElementById('pythonError');
const installLog = document.getElementById('installLog');
let pythonReady = false;
let setupPythonPath = '';
// ===== 自动检测 Python =====
(async function autoDetectPython() {
try {
const api = window.electronAPI;
if (!api || !api.pythonDetect) {
pythonPathInput.placeholder = '请手动输入 Python 路径';
btnSetupPython.disabled = false;
return;
}
const result = await api.pythonDetect();
if (result.pythonPath && result.pythonPath !== 'python') {
pythonPathInput.value = result.pythonPath;
if (result.version) pythonPathInput.title = result.version;
}
if (result.version) {
if (result.versionOk) {
pythonPathInput.placeholder = result.version + ' - 已检测到';
} else {
pythonPathInput.placeholder = result.version + ' - 需要 Python 3.12+';
showPythonError('检测到的 Python 版本过低,需要 Python 3.12+,请手动选择');
}
}
} catch (e) {
pythonPathInput.placeholder = '自动检测失败,请手动选择';
}
if (pythonPathInput.value.trim()) btnSetupPython.disabled = false;
})();
// ===== 浏览按钮 =====
document.getElementById('browsePython').addEventListener('click', async () => {
try {
const api = window.electronAPI;
if (!api || !api.pickPython) return;
const result = await api.pickPython();
if (result.canceled || !result.path) return;
pythonPathInput.value = result.path;
pythonPathInput.placeholder = '';
hidePythonError();
btnSetupPython.disabled = false;
} catch (e) {
showPythonError('选择文件失败: ' + e.message);
}
});
pythonPathInput.addEventListener('input', () => {
btnSetupPython.disabled = !pythonPathInput.value.trim();
if (pythonPathInput.value.trim()) hidePythonError();
});
// ===== 安装依赖环境 =====
btnSetupPython.addEventListener('click', async () => {
const pyPath = pythonPathInput.value.trim();
if (!pyPath) return;
btnSetupPython.disabled = true;
pythonReady = false;
nextPage1Btn.disabled = true;
setupProgress.hidden = false;
progressStatus.textContent = '准备中...';
installLog.textContent = '';
hidePythonError();
const unsub = window.electronAPI.onPythonSetupProgress((data) => {
switch (data.type) {
case 'status':
progressStatus.textContent = data.text;
break;
case 'log':
if (data.all != null) {
installLog.textContent = data.all;
installLog.scrollTop = installLog.scrollHeight;
}
break;
}
});
try {
const result = await window.electronAPI.pythonSetup(pyPath);
unsub();
if (result.ok) {
progressStatus.textContent = '环境安装完成!';
installLog.textContent += '\n\n' + '虚拟环境: ' + result.venvDir;
installLog.scrollTop = installLog.scrollHeight;
pythonReady = true;
setupPythonPath = result.venvPython;
pythonPathInput.value = result.venvPython;
nextPage1Btn.disabled = false;
btnSetupPython.textContent = '重新安装';
btnSetupPython.disabled = false;
} else {
showPythonError(result.error || '安装失败');
progressStatus.textContent = '安装失败';
btnSetupPython.disabled = false;
}
} catch (e) {
unsub();
showPythonError('安装异常: ' + e.message);
progressStatus.textContent = '安装异常';
btnSetupPython.disabled = false;
}
});
// ===== 页面导航 =====
nextPage1Btn.addEventListener('click', () => {
if (!pythonReady && pythonPathInput.value.trim()) {
showPythonError('请先点击"安装依赖环境"完成 Python 环境准备');
return;
}
page1.classList.remove('active'); page2.classList.add('active');
dot1.classList.remove('active'); dot1.classList.add('done');
dot2.classList.add('active');
});
document.getElementById('prevPage2').addEventListener('click', () => {
page2.classList.remove('active'); page1.classList.add('active');
dot2.classList.remove('active');
dot1.classList.remove('done'); dot1.classList.add('active');
});
document.getElementById('nextPage2').addEventListener('click', () => {
const model = document.getElementById('model').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const baseUrl = document.getElementById('baseUrl').value.trim();
if (!model || !apiKey || !baseUrl) {
alert('请填写 Model Name / ID、API Key 和 API Base URL(标记 * 的必填项)');
return;
}
page2.classList.remove('active'); page3.classList.add('active');
dot2.classList.remove('active'); dot2.classList.add('done');
dot3.classList.add('active');
});
document.getElementById('prevPage3').addEventListener('click', () => {
page3.classList.remove('active'); page2.classList.add('active');
dot3.classList.remove('active');
dot2.classList.remove('done'); dot2.classList.add('active');
});
nextPage1Btn.disabled = true;
// ===== 表单提交 =====
document.getElementById('page3').addEventListener('submit', async (e) => {
e.preventDefault();
submitBtn.disabled = true;
submitBtn.textContent = '正在验证...';
errorMsg.classList.remove('show');
const getVal = id => document.getElementById(id).value.trim();
const getNum = id => parseInt(document.getElementById(id).value) || 0;
const getFloat = id => parseFloat(document.getElementById(id).value) || 0;
const data = {
nickname: getVal('nickname'),
pythonPath: setupPythonPath || getVal('pythonPath'),
model: getVal('model'),
apiKey: getVal('apiKey'),
baseUrl: getVal('baseUrl').replace(/\/+$/, ''),
timeout: getNum('timeout') || 60,
emailAddr: getVal('emailAddr'),
emailCode: getVal('emailCode'),
webSearchKey: getVal('webSearchKey'),
asrUrl: getVal('asrUrl'),
ttsUrl: getVal('ttsUrl'),
ragUrl: getVal('ragUrl'),
llmContextWindow: getNum('llmContextWindow') || 262144,
miniCompressRate: getFloat('miniCompressRate') || 0.4,
hardCompressRate: getFloat('hardCompressRate') || 0.7,
currentTurns: getNum('currentTurns') || 4,
imgSize: getNum('imgSize') || 768,
ragChunkSize: getNum('ragChunkSize') || 500,
ragChunkOverlap: getNum('ragChunkOverlap') || 50,
groundingWidth: getNum('groundingWidth') || 1000,
groundingHeight: getNum('groundingHeight') || 1000,
loopStaleRounds: getNum('loopStaleRounds') || 20,
loopRepeatedWarn: getNum('loopRepeatedWarn') || 3,
loopRepeatedEnd: getNum('loopRepeatedEnd') || 5,
sendFileSizeLimit: getNum('sendFileSizeLimit') || 30,
cronStorageBackend: getVal('cronStorageBackend') || 'json',
};
if (!data.apiKey || !data.baseUrl || !data.model) {
showError('请返回第2页填写模型 ID、API Key 和 Base URL(标记 * 的必填项)');
return;
}
try {
const result = await window.electronAPI.saveConfig(data);
if (result.success) {
submitBtn.textContent = '配置成功,启动中...';
window.electronAPI.launchApp();
} else {
showError(result.error || '保存配置失败');
}
} catch (err) {
showError('通信失败: ' + err.message);
}
});
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = '保存并启动';
}
function showPythonError(msg) {
pythonError.textContent = msg;
pythonError.classList.add('show');
}
function hidePythonError() {
pythonError.classList.remove('show');
}
</script>
</body>
</html>