122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { loadProvider, PROJECT_ROOT } from '../agent/config.ts';
|
|
import { createLLM } from '../agent/llm.ts';
|
|
import { RUNS_DIR } from '../agent/workspace.ts';
|
|
import { LiveRun, scenarios } from '../live/run.ts';
|
|
import type { LiveEvent } from '../live/types.ts';
|
|
|
|
const port = Number(process.env.PORT ?? 8791);
|
|
const host = '127.0.0.1';
|
|
const index = join(PROJECT_ROOT, 'web/dist/index.html');
|
|
const provider = {
|
|
...loadProvider(),
|
|
baseURL: 'https://dd-ai-api.eastmoney.com/coding/v1',
|
|
model: 'DeepSeek-V4.1-Flash-1017',
|
|
contextWindow: 1_000_000,
|
|
maxOutputTokens: 384_000,
|
|
inputModalities: ['text', 'image'],
|
|
};
|
|
const llm = createLLM(provider);
|
|
const runs = new Map<string, LiveRun>();
|
|
mkdirSync(RUNS_DIR, { recursive: true });
|
|
|
|
function json(res: ServerResponse, status: number, body: unknown) {
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
let raw = '';
|
|
for await (const chunk of req) {
|
|
raw += chunk;
|
|
if (raw.length > 12_000) throw new Error('请求过长。');
|
|
}
|
|
const data = JSON.parse(raw || '{}') as unknown;
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('请求体必须是对象。');
|
|
return data as Record<string, unknown>;
|
|
}
|
|
|
|
function events(req: IncomingMessage, res: ServerResponse, run: LiveRun) {
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
res.flushHeaders();
|
|
const lastId = Number(req.headers['last-event-id'] ?? 0);
|
|
const send = (event: LiveEvent) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
for (const event of run.events.slice(Number.isSafeInteger(lastId) && lastId > 0 ? lastId : 0)) send(event);
|
|
run.listeners.add(send);
|
|
const ping = setInterval(() => res.write(': ping\n\n'), 15_000);
|
|
res.on('close', () => {
|
|
clearInterval(ping);
|
|
run.listeners.delete(send);
|
|
});
|
|
}
|
|
|
|
async function handle(req: IncomingMessage, res: ServerResponse) {
|
|
const origin = `http://${req.headers.host}`;
|
|
if (!/^(127\.0\.0\.1|localhost)(?::\d+)?$/.test(req.headers.host ?? '') ||
|
|
(req.method === 'POST' && req.headers.origin && req.headers.origin !== origin)) {
|
|
return json(res, 403, { error: '请从本机主讲稿发起请求。' });
|
|
}
|
|
const path = new URL(req.url ?? '/', origin).pathname;
|
|
if (req.method === 'GET' && path === '/') {
|
|
let html: Buffer;
|
|
try {
|
|
html = readFileSync(index);
|
|
} catch {
|
|
return json(res, 503, { error: '请先运行 pnpm build 或 pnpm start。' });
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
return res.end(html);
|
|
}
|
|
if (req.method === 'GET' && path === '/api/config') {
|
|
const { displayName, baseURL, model, contextWindow, maxOutputTokens, inputModalities } = provider;
|
|
return json(res, 200, {
|
|
provider: { displayName, baseURL, model, contextWindow, maxOutputTokens, inputModalities },
|
|
scenarios: scenarios.map(({ execute, ...info }) => info),
|
|
});
|
|
}
|
|
if (req.method === 'POST' && path === '/api/runs') {
|
|
const { scenario: id } = await readBody(req);
|
|
const scenario = scenarios.find((candidate) => candidate.id === id);
|
|
if (!scenario) return json(res, 400, { error: '请选择演示场景。' });
|
|
const run = new LiveRun(scenario, llm);
|
|
runs.set(run.id, run);
|
|
void run.start();
|
|
return json(res, 201, { id: run.id });
|
|
}
|
|
const route = /^\/api\/runs\/([\w-]+)\/(events|answer|stop)$/.exec(path);
|
|
const run = route && runs.get(route[1]);
|
|
if (!run) return json(res, 404, { error: '运行不存在。' });
|
|
if (req.method === 'GET' && route[2] === 'events') return events(req, res, run);
|
|
if (req.method === 'POST' && route[2] === 'stop') {
|
|
run.stop();
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
if (req.method === 'POST' && route[2] === 'answer') {
|
|
const { questionId, answer } = await readBody(req);
|
|
if (typeof questionId !== 'string') throw new Error('缺少问题 ID。');
|
|
run.runtime.answer(questionId, answer);
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
return json(res, 404, { error: '接口不存在。' });
|
|
}
|
|
|
|
createServer((req, res) => {
|
|
void handle(req, res).catch((error: Error) => {
|
|
if (!res.headersSent) {
|
|
const message = error.message.replaceAll(provider.apiKey, '[已隐藏]');
|
|
json(res, 400, { error: message });
|
|
}
|
|
});
|
|
}).listen(port, host, () => {
|
|
console.log(`主讲稿 http://${host}:${port} · ${provider.model}`);
|
|
});
|
|
|
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
process.on(signal, () => {
|
|
for (const run of runs.values()) run.stop();
|
|
process.exit(0);
|
|
});
|
|
}
|