210 lines
9.5 KiB
TypeScript
210 lines
9.5 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { HostApiContext } from '../context';
|
|
import {
|
|
flushStreamingHeaders,
|
|
parseJsonBody,
|
|
sendJson,
|
|
writeStreamingChunk,
|
|
} from '../route-utils';
|
|
import { TeacherError } from '../../coding-teacher/config-client';
|
|
import type { TeacherScope } from '../../coding-teacher/service';
|
|
import type { TeacherCheckInInput, TeacherSend } from '../../../shared/coding-teacher';
|
|
import { takeTeacherPreviewRevision } from '../../main/app-deep-link';
|
|
|
|
export async function handleCodingTeacherRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext
|
|
): Promise<boolean> {
|
|
const legacy = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-history(?:\/([^/]+))?$/);
|
|
const conversation = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/agent-conversations\/([^/]+)(?:\/(messages|events|save|seen|requests\/([^/]+)\/cancel))?$/);
|
|
const source = url.pathname.match(
|
|
/^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
|
|
);
|
|
const preview = url.pathname.match(
|
|
/^\/api\/coding\/teacher-preview\/topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
|
|
);
|
|
const projectTopics = url.pathname.match(
|
|
/^\/api\/coding\/projects\/([^/]+)\/(agent|teacher|friend)-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
|
|
);
|
|
const checkIn = url.pathname.match(/^\/api\/coding\/projects\/([^/]+)\/teacher-check-in$/);
|
|
const role = projectTopics?.[2] === 'friend' ? 'friend' : undefined;
|
|
const config = url.pathname === '/api/coding/teacher/config' || url.pathname === '/api/coding/friend/config';
|
|
const catalog = url.pathname === '/api/coding/teacher/teachers';
|
|
const catalogRevision = url.pathname === '/api/coding/teacher/teachers/revision';
|
|
const models = url.pathname === '/api/coding/teacher/models';
|
|
const draft = url.pathname === '/api/coding/teacher-preview';
|
|
const pending = url.pathname === '/api/coding/teacher-preview/pending-link';
|
|
if (!legacy && !conversation && !source && !preview && !projectTopics && !checkIn && !config && !catalog && !catalogRevision && !models && !draft && !pending) return false;
|
|
if ((config || catalog || catalogRevision || models || draft || pending) && req.method !== 'GET') {
|
|
sendJson(res, 405, { error: '不支持此操作。' });
|
|
return true;
|
|
}
|
|
if (pending && req.method === 'GET') {
|
|
sendJson(res, 200, { draftRevision: takeTeacherPreviewRevision() });
|
|
return true;
|
|
}
|
|
const service = ctx.codingProducts?.teacher;
|
|
if (!service) {
|
|
sendJson(res, 503, { error: '智能体服务暂不可用。', code: 'teacher_unavailable' });
|
|
return true;
|
|
}
|
|
try {
|
|
if (legacy) {
|
|
if (req.method !== 'GET') sendJson(res, 405, { error: '旧记录仅供查看。' });
|
|
else sendJson(res, 200, await service.legacyHistory(decodeURIComponent(legacy[1]), legacy[2] ? {
|
|
id: decodeURIComponent(legacy[2]), sourceId: url.searchParams.get('source') ?? 'project', friend: url.searchParams.get('friend') === 'true',
|
|
} : undefined));
|
|
return true;
|
|
}
|
|
const projectId = conversation ? decodeURIComponent(conversation[1]) : undefined;
|
|
const agentId = conversation ? decodeURIComponent(conversation[2]) : undefined;
|
|
if (agentId && !conversation![3] && req.method === 'GET') {
|
|
sendJson(res, 200, await service.conversation(projectId!, agentId, url.searchParams.get('before') ?? undefined));
|
|
return true;
|
|
}
|
|
if (agentId && conversation![3] === 'messages' && req.method === 'POST') {
|
|
const input = await parseJsonBody<TeacherSend>(req);
|
|
if (input.projectId && input.projectId !== projectId)
|
|
throw new TeacherError(422, 'teacher_project_mismatch', '提问不属于当前项目。');
|
|
sendJson(res, 202, await service.sendConversation(agentId, { ...input, projectId }));
|
|
return true;
|
|
}
|
|
if (agentId && conversation![3] === 'seen' && req.method === 'POST') {
|
|
const input = await parseJsonBody<{ requestId: string }>(req);
|
|
sendJson(res, 200, await service.markConversationSeen(projectId!, agentId, input.requestId));
|
|
return true;
|
|
}
|
|
if (checkIn) {
|
|
if (req.method !== 'POST') sendJson(res, 405, { error: '不支持此操作。' });
|
|
else sendJson(res, 200, await service.checkIn(
|
|
{ projectId: decodeURIComponent(checkIn[1]), sourceId: 'project', role: 'teacher' },
|
|
await parseJsonBody<TeacherCheckInInput>(req)
|
|
));
|
|
return true;
|
|
}
|
|
if (catalogRevision) {
|
|
sendJson(res, 200, await service.catalogRevision());
|
|
return true;
|
|
}
|
|
if (models) {
|
|
const runtime = url.searchParams.get('runtime');
|
|
if (runtime !== 'local' && runtime !== 'yuxi') throw new TeacherError(422, 'teacher_runtime_invalid', '模型来源无效。');
|
|
sendJson(res, 200, await service.models(runtime));
|
|
return true;
|
|
}
|
|
if (catalog) {
|
|
sendJson(res, 200, await service.catalog());
|
|
return true;
|
|
}
|
|
if (config && req.method === 'GET') {
|
|
sendJson(res, 200, await service.definition());
|
|
return true;
|
|
}
|
|
if (draft && req.method === 'GET') {
|
|
const revision = Number(url.searchParams.get('draftRevision'));
|
|
if (!Number.isSafeInteger(revision) || revision < 1)
|
|
throw new TeacherError(400, 'teacher_preview_invalid', '草稿版本无效。');
|
|
sendJson(res, 200, await service.previewDefinition(revision));
|
|
return true;
|
|
}
|
|
const continuous = agentId ? await service.conversation(projectId!, agentId) : undefined;
|
|
if (agentId && !continuous?.topic) throw new TeacherError(404, 'teacher_topic_not_found', '智能体聊天尚未开始。');
|
|
const scope: TeacherScope = agentId ? { projectId: projectId!, sourceId: 'project', agentId } : source
|
|
? { projectId: decodeURIComponent(source[1]), sourceId: decodeURIComponent(source[2]) }
|
|
: projectTopics
|
|
? { projectId: decodeURIComponent(projectTopics[1]), sourceId: 'project', role }
|
|
: { projectId: 'preview', sourceId: 'preview' };
|
|
const id = continuous?.topic?.id ?? source?.[3] ?? projectTopics?.[3] ?? preview?.[1],
|
|
action = conversation?.[3] ?? source?.[4] ?? projectTopics?.[4] ?? preview?.[2],
|
|
requestId = conversation?.[4] ?? source?.[5] ?? projectTopics?.[5] ?? preview?.[3];
|
|
if (!id && req.method === 'GET') {
|
|
sendJson(res, 200, await service.list(scope));
|
|
return true;
|
|
}
|
|
if (!id && req.method === 'POST') {
|
|
const body = await parseJsonBody<{ draftRevision?: number; sampleContext?: string; teacherVersion?: number }>(req);
|
|
sendJson(res, 201, await service.create(scope, body.draftRevision, body.sampleContext, body.teacherVersion));
|
|
return true;
|
|
}
|
|
if (id && !action && req.method === 'GET') {
|
|
sendJson(res, 200, await service.read(scope, id, url.searchParams.get('select') !== 'false'));
|
|
return true;
|
|
}
|
|
if (id && action === 'messages' && req.method === 'POST') {
|
|
sendJson(res, 202, await service.send(scope, id, await parseJsonBody<TeacherSend>(req)));
|
|
return true;
|
|
}
|
|
if (id && action === 'save' && req.method === 'POST') {
|
|
sendJson(res, 200, await service.save(scope, id));
|
|
return true;
|
|
}
|
|
if (id && requestId && req.method === 'POST') {
|
|
sendJson(res, 200, await service.cancel(scope, id, requestId));
|
|
return true;
|
|
}
|
|
if (id && action === 'events' && req.method === 'GET') {
|
|
let closed = false;
|
|
const buffered: unknown[] = [];
|
|
let started = false;
|
|
const close = await service.subscribe(scope, id, (topic) => {
|
|
// GET owns paged history. Stream only the current turn and metadata;
|
|
// Renderer merges by request ID/revision without replacing older pages.
|
|
if (conversation) topic = { ...topic, requests: topic.requests.slice(-1) };
|
|
if (!started) {
|
|
buffered.push(topic);
|
|
return;
|
|
}
|
|
if (!closed)
|
|
void writeStreamingChunk(
|
|
res,
|
|
'event: snapshot\ndata: ' + JSON.stringify(topic) + '\n\n'
|
|
).catch(() => {
|
|
closed = true;
|
|
});
|
|
});
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
const cleanup = () => {
|
|
closed = true;
|
|
if (timer) clearInterval(timer);
|
|
close();
|
|
};
|
|
req.once('close', cleanup);
|
|
res.once('close', cleanup);
|
|
try {
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
flushStreamingHeaders(res);
|
|
started = true;
|
|
for (const topic of buffered) {
|
|
if (!await writeStreamingChunk(res, 'event: snapshot\ndata: ' + JSON.stringify(topic) + '\n\n')) {
|
|
cleanup(); return true;
|
|
}
|
|
}
|
|
timer = setInterval(() => {
|
|
if (!closed) void writeStreamingChunk(res, ': keepalive\n\n').then(written => {
|
|
if (!written) cleanup();
|
|
}).catch(cleanup);
|
|
}, 15000);
|
|
} catch {
|
|
cleanup();
|
|
res.end();
|
|
}
|
|
return true;
|
|
}
|
|
sendJson(res, 405, { error: '不支持此操作。' });
|
|
} catch (error) {
|
|
if (error instanceof TeacherError)
|
|
sendJson(res, error.status, { error: error.message, code: error.code });
|
|
else
|
|
sendJson(res, 500, {
|
|
error: '老师操作失败,请保留当前内容后重试。',
|
|
code: 'teacher_operation_failed',
|
|
});
|
|
}
|
|
return true;
|
|
}
|