221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
/**
|
||
* HoloLake Desktop · Electron 主进程
|
||
*
|
||
* 职责:
|
||
* 1. 创建应用窗口
|
||
* 2. 启动知识库后端服务器(Express + Git 引擎)
|
||
* 3. 管理应用生命周期
|
||
*
|
||
* 架构:
|
||
* Electron Main Process
|
||
* ├── 启动 Express 服务器(Git 引擎层,端口 3890)
|
||
* ├── 创建 BrowserWindow(加载 Vite 前端或打包后的 dist)
|
||
* └── 数据存储:~/Library/Application Support/HoloLake Era/data/
|
||
*/
|
||
|
||
import { app, BrowserWindow, shell, dialog, ipcMain, safeStorage } from 'electron';
|
||
import path from 'path';
|
||
import { spawn, ChildProcess } from 'child_process';
|
||
import fs from 'fs';
|
||
import { importKnowledgeFolder } from './folder-import.js';
|
||
|
||
// ─── 配置 ───
|
||
|
||
const isDev = !app.isPackaged;
|
||
const SERVER_PORT = 3890;
|
||
const CLIENT_PORT = 5180;
|
||
|
||
// 数据目录:macOS 标准位置
|
||
const DATA_DIR = path.join(app.getPath('userData'), 'data');
|
||
const KB_REPO_PATH = path.join(DATA_DIR, 'knowledge-base');
|
||
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
|
||
|
||
interface ModelConfigFile {
|
||
baseUrl: string;
|
||
model: string;
|
||
encryptedKey?: string;
|
||
}
|
||
|
||
function readModelConfig(): ModelConfigFile {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(MODEL_CONFIG_PATH, 'utf8')) as ModelConfigFile;
|
||
} catch {
|
||
return { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
|
||
}
|
||
}
|
||
|
||
function applyModelConfig(): ModelConfigFile {
|
||
const config = readModelConfig();
|
||
process.env.HOLOLAKE_LLM_BASE = config.baseUrl;
|
||
process.env.HOLOLAKE_LLM_MODEL = config.model;
|
||
if (config.encryptedKey && safeStorage.isEncryptionAvailable()) {
|
||
process.env.HOLOLAKE_LLM_KEY = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
|
||
}
|
||
return config;
|
||
}
|
||
|
||
// ─── 后端服务器 ───
|
||
|
||
let serverProcess: ChildProcess | null = null;
|
||
|
||
function startServer(): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
if (isDev) {
|
||
// 开发模式:用 spawn 启动外部 tsx
|
||
const serverScript = path.resolve(__dirname, '../../guanghu-knowledge-base/server/index.ts');
|
||
serverProcess = spawn('npx', ['tsx', serverScript], {
|
||
env: {
|
||
...process.env,
|
||
KB_PORT: String(SERVER_PORT),
|
||
KB_REPO_PATH,
|
||
NODE_ENV: 'development',
|
||
},
|
||
cwd: path.resolve(__dirname, '../..'),
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
|
||
serverProcess.stdout?.on('data', (data: Buffer) => {
|
||
const msg = data.toString().trim();
|
||
console.log(`[KB Server] ${msg}`);
|
||
if (msg.includes('已启动')) resolve();
|
||
});
|
||
|
||
serverProcess.stderr?.on('data', (data: Buffer) => {
|
||
console.error(`[KB Server Error] ${data.toString().trim()}`);
|
||
});
|
||
|
||
serverProcess.on('error', (err: Error) => {
|
||
console.error('服务器启动失败:', err);
|
||
reject(err);
|
||
});
|
||
|
||
serverProcess.on('exit', (code: number | null) => {
|
||
console.log(`[KB Server] 退出,code=${code}`);
|
||
serverProcess = null;
|
||
});
|
||
|
||
setTimeout(() => resolve(), 5000);
|
||
} else {
|
||
// 生产模式:直接在主进程内加载 server-bundle
|
||
try {
|
||
process.env.KB_PORT = String(SERVER_PORT);
|
||
process.env.KB_REPO_PATH = KB_REPO_PATH;
|
||
const serverBundle = path.join(__dirname, 'server-bundle.cjs');
|
||
require(serverBundle);
|
||
console.log(`[KB Server] 内嵌启动,端口 ${SERVER_PORT}`);
|
||
resolve();
|
||
} catch (err) {
|
||
console.error('服务器内嵌启动失败:', err);
|
||
reject(err);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function stopServer(): void {
|
||
if (serverProcess) {
|
||
serverProcess.kill('SIGTERM');
|
||
serverProcess = null;
|
||
}
|
||
}
|
||
|
||
// ─── 窗口 ───
|
||
|
||
let mainWindow: BrowserWindow | null = null;
|
||
|
||
function createWindow(): void {
|
||
mainWindow = new BrowserWindow({
|
||
width: 1280,
|
||
height: 860,
|
||
minWidth: 800,
|
||
minHeight: 600,
|
||
titleBarStyle: 'hiddenInset',
|
||
trafficLightPosition: { x: 16, y: 16 },
|
||
backgroundColor: '#f7f8fb',
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.cjs'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
if (isDev) {
|
||
mainWindow.loadURL(`http://localhost:${CLIENT_PORT}`);
|
||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||
} else {
|
||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||
}
|
||
|
||
// 外部链接用系统浏览器打开
|
||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (url.startsWith('http')) shell.openExternal(url);
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
mainWindow.on('closed', () => {
|
||
mainWindow = null;
|
||
});
|
||
}
|
||
|
||
ipcMain.handle('get-data-path', () => DATA_DIR);
|
||
ipcMain.handle('knowledge:import-folder', async () => {
|
||
const selection = await dialog.showOpenDialog(mainWindow ?? undefined, {
|
||
title: '导入本地知识文件夹',
|
||
buttonLabel: '导入到 HoloLake',
|
||
properties: ['openDirectory'],
|
||
});
|
||
if (selection.canceled || !selection.filePaths[0]) {
|
||
return { cancelled: true, imported: 0, assets: 0, skipped: 0, failed: [] };
|
||
}
|
||
return importKnowledgeFolder(selection.filePaths[0], KB_REPO_PATH);
|
||
});
|
||
ipcMain.handle('agent:get-config', () => {
|
||
const config = readModelConfig();
|
||
return {
|
||
baseUrl: config.baseUrl,
|
||
model: config.model,
|
||
configured: Boolean(config.encryptedKey && safeStorage.isEncryptionAvailable()),
|
||
};
|
||
});
|
||
ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: string; apiKey?: string }) => {
|
||
const baseUrl = String(input.baseUrl || '').trim().replace(/\/$/, '');
|
||
const model = String(input.model || '').trim();
|
||
if (!/^https:\/\//i.test(baseUrl)) throw new Error('模型服务地址必须使用 HTTPS');
|
||
if (!model) throw new Error('模型名称不能为空');
|
||
if (!safeStorage.isEncryptionAvailable()) throw new Error('macOS 加密存储当前不可用');
|
||
const previous = readModelConfig();
|
||
const encryptedKey = input.apiKey
|
||
? safeStorage.encryptString(String(input.apiKey)).toString('base64')
|
||
: previous.encryptedKey;
|
||
if (!encryptedKey) throw new Error('请填写模型密钥');
|
||
fs.mkdirSync(path.dirname(MODEL_CONFIG_PATH), { recursive: true });
|
||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ baseUrl, model, encryptedKey }), { mode: 0o600 });
|
||
applyModelConfig();
|
||
return { baseUrl, model, configured: true };
|
||
});
|
||
|
||
// ─── 应用生命周期 ───
|
||
|
||
app.whenReady().then(async () => {
|
||
try {
|
||
applyModelConfig();
|
||
await startServer();
|
||
createWindow();
|
||
} catch (err) {
|
||
dialog.showErrorBox('启动失败', `知识库引擎启动失败:${err}`);
|
||
app.quit();
|
||
}
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
stopServer();
|
||
if (process.platform !== 'darwin') app.quit();
|
||
});
|
||
|
||
app.on('before-quit', () => {
|
||
stopServer();
|
||
});
|