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, terminal = false) => `${JSON.stringify({ event_id: `${body.run_id}:${++sequence}`, type, payload, schema_version: 1, terminal, ...(terminal ? { outcome: 'completed' } : {}), })}\n`; const stream = new ReadableStream({ 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', }, }); }