initial open-source release
This commit is contained in:
commit
01c4605498
224 files changed
+55124
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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="关闭"></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>
|
||||
Reference in new issue
Block a user