Files
2026-11-12 15:29:12 +08:00

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);
});
}