assistant/electron/backend.ts

176 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { execSync, spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
interface JsonResponse {
status: number;
json: Record<string, unknown>;
}
/** 怎么拉起后端:源码模式是 .venv 的 python + backend/main.py,打包后是 resources/backend 里的冻结 exe。 */
export interface BackendLaunch {
command: string;
args: string[];
cwd: string;
env: Record<string, string>;
}
export function devBackendLaunch(rootDir: string): BackendLaunch {
const venv = process.platform === 'win32'
? path.join(rootDir, '.venv', 'Scripts', 'python.exe')
: path.join(rootDir, '.venv', 'bin', 'python');
const command = fs.existsSync(venv) ? venv : process.platform === 'win32' ? 'python' : 'python3';
return { command, args: ['backend/main.py'], cwd: rootDir, env: {} };
}
export function packagedBackendLaunch(resourcesPath: string, homeDir: string): BackendLaunch {
const executable = process.platform === 'win32' ? 'school-assistant-backend.exe' : 'school-assistant-backend';
return {
command: path.join(resourcesPath, 'backend', executable),
args: [],
cwd: homeDir,
env: {
ASSISTANT_HOME: homeDir,
KNOWLEDGE_DIR: path.join(resourcesPath, 'knowledge'),
},
};
}
/** Python FastAPI 后端的生命周期:Electron 是它唯一的所有者。 */
export class BackendManager {
private child: ChildProcess | null = null;
constructor(private readonly launch: BackendLaunch, private readonly apiPort: number) {}
get apiBase(): string {
return `http://127.0.0.1:${this.apiPort}/api`;
}
request(method: string, pathname: string, body?: unknown, timeoutMs = 1500): Promise<JsonResponse> {
return new Promise((resolve, reject) => {
const data = body === undefined ? null : JSON.stringify(body);
const req = http.request({
host: '127.0.0.1',
port: this.apiPort,
path: `/api${pathname}`,
method,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
},
timeout: timeoutMs,
}, res => {
let buffer = '';
res.on('data', chunk => { buffer += chunk; });
res.on('end', () => {
let json: Record<string, unknown> = {};
try { json = buffer ? JSON.parse(buffer) as Record<string, unknown> : {}; } catch { json = {}; }
resolve({ status: res.statusCode ?? 0, json });
});
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}
async info(): Promise<Record<string, unknown> | null> {
try {
const res = await this.request('GET', '/health');
return res.status === 200 ? res.json : null;
} catch {
return null;
}
}
private pidsOnPort(): number[] {
if (process.platform !== 'win32') return [];
try {
const out = execSync('netstat -ano -p tcp', { encoding: 'utf8' });
const pids = new Set<number>();
for (const line of out.split(/\r?\n/)) {
const match = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)$/);
if (match && parseInt(match[1], 10) === this.apiPort) pids.add(parseInt(match[2], 10));
}
return [...pids].filter(pid => pid > 0);
} catch {
return [];
}
}
/** 端口上若已有后端(上次没退干净 / 手动起的),先关掉,保证连的是最新代码。 */
async stopStale(): Promise<void> {
const info = await this.info();
if (!info) return;
console.log(`[electron] 端口 ${this.apiPort} 上已有后端 (pid ${String(info.pid ?? '?')}),先关闭它`);
try { await this.request('POST', '/shutdown'); } catch { /* 旧版本没有 /shutdown */ }
for (let i = 0; i < 10; i += 1) {
await sleep(300);
if (!(await this.info())) return;
}
for (const pid of this.pidsOnPort()) {
try { process.kill(pid, 'SIGKILL'); console.log(`[electron] 已强制结束 pid ${pid}`); } catch (error) {
console.warn(`[electron] 结束 pid ${pid} 失败: ${(error as Error).message}`);
}
}
for (let i = 0; i < 10; i += 1) {
await sleep(300);
if (!(await this.info())) return;
}
console.warn('[electron] 旧后端仍在运行,新后端可能无法绑定端口');
}
start(extraEnv: Record<string, string>): void {
if (!fs.existsSync(this.launch.command) && path.isAbsolute(this.launch.command)) {
console.error(`[electron] 找不到后端可执行文件: ${this.launch.command}`);
}
this.child = spawn(this.launch.command, this.launch.args, {
cwd: this.launch.cwd,
env: { ...process.env, PYTHONUTF8: '1', API_PORT: String(this.apiPort), ...this.launch.env, ...extraEnv },
stdio: 'inherit',
windowsHide: true,
});
this.child.on('exit', code => {
console.log(`[electron] 后端进程退出 code=${String(code)}`);
this.child = null;
});
this.child.on('error', error => console.error('Failed to start backend:', error));
}
async waitReady(maxAttempts = 120): Promise<Record<string, unknown>> {
for (let i = 0; i < maxAttempts; i += 1) {
const info = await this.info();
if (info) return info;
await sleep(500);
}
throw new Error('Backend not reachable');
}
stop(): void {
if (!this.child) return;
this.request('POST', '/shutdown').catch(() => undefined);
const child = this.child;
this.child = null;
setTimeout(() => { try { child.kill(); } catch { /* already gone */ } }, 500);
}
/** 渲染层 IPC 用的代理:把请求转给后端并抛出可读错误。 */
async apiFetch(pathname: string, init: { method?: string; body?: unknown } = {}): Promise<unknown> {
const response = await fetch(`${this.apiBase}${pathname}`, {
method: init.method ?? 'GET',
headers: { 'Content-Type': 'application/json' },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const data = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok) {
const detail = typeof data.detail === 'string' ? data.detail : response.statusText;
throw new Error(detail);
}
return data;
}
}