Files
openmaic/OpenMAIC/app/api/runtime/v1/runs/route.ts

95 lines
4.2 KiB
TypeScript

import type { NextRequest } from 'next/server';
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
import {
courseKnowledge,
loadMakeloreCourse,
parseLearningBinding,
resolveMakeloreCourseModule,
runtimeSceneId,
} from '@/lib/makelore-runtime/course-store';
import { isRuntimeRunRequest } from '@/lib/makelore-runtime/protocol';
import { retrieveChunks } from '@/lib/qa/knowledge';
import { runQaAgent, type QaTurn } from '@/lib/qa/agent';
import { resolveModel } from '@/lib/server/resolve-model';
export const maxDuration = 120;
export async function POST(request: NextRequest) {
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
const body: unknown = await request.json().catch(() => null);
if (!isRuntimeRunRequest(body)) return Response.json({ error: 'invalid_request' }, { status: 400 });
const binding = parseLearningBinding(body.binding.key);
if (!binding) return Response.json({ error: 'invalid_binding' }, { status: 400 });
const course = await loadMakeloreCourse(binding.courseId, binding.contentHash);
if (!course) return Response.json({ error: 'course_not_found' }, { status: 404 });
const module = await resolveMakeloreCourseModule(course, {
moduleId: body.input.anchor?.moduleId,
moduleContentHash: body.input.anchor?.moduleContentHash,
});
if (!module) return Response.json({ error: 'course_module_not_found' }, { status: 404 });
const knowledge = courseKnowledge(module);
const message = body.input.message!.trim().slice(0, 4000);
const retrieved = retrieveChunks(knowledge.knowledge, message, 3);
const anchor = body.input.anchor;
const sceneIndex = module.manifest?.scenes.findIndex((scene, index) =>
(Boolean(anchor?.sceneId) &&
anchor?.sceneId === runtimeSceneId(course, module.descriptor, index)) ||
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder),
) ?? -1;
const anchored = sceneIndex >= 0
? knowledge.knowledge.scenes[sceneIndex]
: knowledge.knowledge.scenes.find((scene) =>
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder) ||
(Boolean(anchor?.sceneId) && scene.sceneId === anchor?.sceneId),
);
const chunks = anchored && !retrieved.some((chunk) => chunk.sceneId === anchored.sceneId)
? [{ ...anchored, score: Number.MAX_SAFE_INTEGER }, ...retrieved].slice(0, 3)
: retrieved;
const history: QaTurn[] = (body.input.history ?? [])
.filter((turn): turn is QaTurn => Boolean(turn)
&& (turn.role === 'user' || turn.role === 'assistant')
&& typeof turn.content === 'string')
.slice(-7)
.map((turn) => ({ role: turn.role, content: turn.content.slice(0, 4000) }));
history.push({ role: 'user', content: message });
const resolved = await resolveModel({ stage: 'qa-assistant' });
const encoder = new TextEncoder();
let sequence = 0;
const event = (type: string, payload: Record<string, unknown>, terminal = false) => `${JSON.stringify({
event_id: `${body.run_id}:${++sequence}`,
type,
payload,
schema_version: 1,
terminal,
...(terminal ? { outcome: 'completed' } : {}),
})}\n`;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const update of runQaAgent({ model: resolved.model, courseware: knowledge, chunks, thinkingConfig: resolved.thinkingConfig }, history)) {
if (update.type === 'text') {
controller.enqueue(encoder.encode(event('learning.assistant.delta', { delta: update.delta })));
} else if (update.type === 'done') {
controller.enqueue(encoder.encode(event('learning.assistant.completed', {
sources: update.sources.map((source) => ({ sceneId: source.sceneId, order: source.order, title: source.title })),
}, true)));
}
}
} catch {
controller.enqueue(encoder.encode(event('learning.assistant.failed', { code: 'learning_runtime_failed', message: '助教暂时无法回答' }, true)));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Content-Encoding': 'identity',
'Cache-Control': 'no-store, no-transform',
},
});
}