diff --git a/OpenMAIC/.env.example b/OpenMAIC/.env.example index e160e7c..41d2ba8 100644 --- a/OpenMAIC/.env.example +++ b/OpenMAIC/.env.example @@ -464,8 +464,31 @@ DEFAULT_MODEL= # Where the server keeps courseware records and bundle bytes. # Defaults to ./data/coursewares and ./data/courseware-bundles. # COURSEWARE_DATA_DIR= + +# Private Works -> Learning Engine authentication. LEARNING_ENGINE_TOKEN is +# canonical; MAKELORE_RUNTIME_TOKEN is a temporary migration alias. +# LEARNING_ENGINE_TOKEN=replace-with-a-long-random-secret +# MAKELORE_RUNTIME_TOKEN= +# One shared writable root for Engine + Learning Ops state. Mount /app/data as +# RWX when those two deployments are separate processes. +# LEARNING_DATA_DIR=/app/data +# Optional sidecar HMAC for exact aggregate/single archive bytes. +# LEARNING_PACKAGE_SIGNING_KEY= +# LEARNING_PACKAGE_SIGNING_KEY_ID=default +# MAKELORE_COURSE_PACKAGE_DIR=/app/data/makelore-packages +# MAKELORE_COURSE_STORE_DIR=/app/data/makelore-courses # COURSEWARE_BUNDLE_DIR= +# Learning Ops build/runtime. OPENMAIC_BASE_PATH is a build-time Next basePath; +# build the Ops artifact/image with /learning-ops. Browser bearer sessions are +# introspected against Works and production never falls back to ACCESS_CODE. +# OPENMAIC_BASE_PATH=/learning-ops +# WORKS_SQUARE_API_BASE_URL=http://works-square-server:8000 +# Ops runner mutations use the same Engine process as Works cancel/resume. +# Canonical value includes the private API prefix. When omitted, the existing +# COURSE_PUBLISH_SERVER_BASE_URL origin is reused with /api/runtime/v1. +# LEARNING_ENGINE_BASE_URL=http://makelore-learning-engine:3000/api/runtime/v1 + # Max accepted size of each module bundle (default 300 MiB). # COURSEWARE_MAX_UPLOAD_BYTES=314572800 diff --git a/OpenMAIC/Dockerfile b/OpenMAIC/Dockerfile index 31cdda1..5f79ce4 100644 --- a/OpenMAIC/Dockerfile +++ b/OpenMAIC/Dockerfile @@ -33,6 +33,7 @@ ARG NEXT_PUBLIC_ENABLE_VIDEO_EXPORT ARG NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION ARG NEXT_PUBLIC_ENABLE_PPTX_IMPORT ARG NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=learner +ARG OPENMAIC_BASE_PATH ENV ALLOWED_FRAME_ANCESTORS=$ALLOWED_FRAME_ANCESTORS ENV NEXT_PUBLIC_PERSISTENCE=$NEXT_PUBLIC_PERSISTENCE ENV NEXT_PUBLIC_PERSISTENCE_TOKEN=$NEXT_PUBLIC_PERSISTENCE_TOKEN @@ -45,6 +46,7 @@ ENV NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=$NEXT_PUBLIC_ENABLE_VIDEO_EXPORT ENV NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION=$NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION ENV NEXT_PUBLIC_ENABLE_PPTX_IMPORT=$NEXT_PUBLIC_ENABLE_PPTX_IMPORT ENV NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=$NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE +ENV OPENMAIC_BASE_PATH=$OPENMAIC_BASE_PATH COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/packages ./packages @@ -59,18 +61,22 @@ FROM node:22-alpine AS runner WORKDIR /app ARG NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=learner +ARG OPENMAIC_BASE_PATH ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 ENV NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=$NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE ENV OPENMAIC_DEPLOYMENT_ROLE=$NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE +ENV OPENMAIC_BASE_PATH=$OPENMAIC_BASE_PATH RUN apk add --no-cache libc6-compat cairo pango jpeg giflib librsvg RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs +RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data + COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static @@ -78,5 +84,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 +VOLUME ["/app/data"] CMD ["node", "server.js"] diff --git a/OpenMAIC/app/api/agent/edit/route.ts b/OpenMAIC/app/api/agent/edit/route.ts index 0ebae3b..f2fe853 100644 --- a/OpenMAIC/app/api/agent/edit/route.ts +++ b/OpenMAIC/app/api/agent/edit/route.ts @@ -15,7 +15,7 @@ import { buildAgent, buildSystemPrompt } from '@/lib/agent/runtime/build-agent'; import { buildToolset } from '@/lib/agent/tools/registry'; import { callLLM } from '@/lib/ai/llm'; import { createLogger } from '@/lib/logger'; -import type { SceneContext } from '@/lib/agent/tools/regenerate-scene-actions'; +import type { SceneContextMap } from '@/lib/agent/runtime/types'; const log = createLogger('MAIC Agent'); @@ -25,13 +25,6 @@ const log = createLogger('MAIC Agent'); // slow models / media-heavy slides aren't terminated mid-stream. export const maxDuration = 300; -/** - * Scene/stage context map sent by the client. - * Keyed by scene id; the client reads `useStageStore` to build this so the - * server never has to access a (non-existent) server-side scene store. - */ -export type SceneContextMap = Record; - interface AgentEditBody { message: string; scene?: { id: string; title: string }; diff --git a/OpenMAIC/app/api/courses/[courseId]/cancel/route.ts b/OpenMAIC/app/api/courses/[courseId]/cancel/route.ts index 6b8df62..1956c82 100644 --- a/OpenMAIC/app/api/courses/[courseId]/cancel/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/cancel/route.ts @@ -15,6 +15,7 @@ import { isCourseGenerationRunning, } from '@/lib/course-framework/runner'; import { requireOpsAccess } from '@/lib/server/ops-access'; +import { proxyLargeCourseControlToEngine } from '@/lib/makelore-runtime/ops-engine-control'; const log = createLogger('Course Cancel API'); @@ -22,7 +23,7 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(request); + const denied = await requireOpsAccess(request); if (denied) return denied; try { @@ -34,6 +35,12 @@ export async function POST( if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`); } + const proxied = await proxyLargeCourseControlToEngine({ + request, + course: record, + action: 'cancel', + }); + if (proxied) return proxied; if (!isCourseGenerationRunning(courseId)) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, 'Course generation is not running'); } diff --git a/OpenMAIC/app/api/courses/[courseId]/framework/regenerate/route.ts b/OpenMAIC/app/api/courses/[courseId]/framework/regenerate/route.ts index 8c4eb76..32d5e7b 100644 --- a/OpenMAIC/app/api/courses/[courseId]/framework/regenerate/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/framework/regenerate/route.ts @@ -21,6 +21,11 @@ import { CourseMutationInProgressError, isCoursePublishing, } from '@/lib/course-framework/publish-state'; +import { proxyLargeCourseControlToEngine } from '@/lib/makelore-runtime/ops-engine-control'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; const log = createLogger('Framework Regenerate API'); @@ -28,7 +33,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(req); + const denied = await requireOpsAccess(req); if (denied) return denied; try { @@ -40,6 +45,13 @@ export async function POST( if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`); } + const proxied = await proxyLargeCourseControlToEngine({ + request: req, + course: record, + action: 'framework-regenerate', + }); + if (proxied) return proxied; + await assertLearningCourseMutable(courseId, record); if ( isCourseGenerationRunning(courseId) || isCoursePublishing(courseId) || @@ -59,6 +71,9 @@ export async function POST( detailUrl: `${baseUrl}/api/courses/${courseId}`, }); } catch (error) { + if (error instanceof FrozenLearningCourseMutationError) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); + } if (error instanceof CourseMutationInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } diff --git a/OpenMAIC/app/api/courses/[courseId]/modules/[index]/regenerate/route.ts b/OpenMAIC/app/api/courses/[courseId]/modules/[index]/regenerate/route.ts index d65d33a..ed69b8a 100644 --- a/OpenMAIC/app/api/courses/[courseId]/modules/[index]/regenerate/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/modules/[index]/regenerate/route.ts @@ -17,6 +17,11 @@ import { CourseMutationInProgressError, isCoursePublishing, } from '@/lib/course-framework/publish-state'; +import { proxyLargeCourseControlToEngine } from '@/lib/makelore-runtime/ops-engine-control'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; const log = createLogger('Module Regenerate API'); @@ -24,7 +29,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ courseId: string; index: string }> }, ) { - const denied = requireOpsAccess(req); + const denied = await requireOpsAccess(req); if (denied) return denied; try { @@ -37,6 +42,14 @@ export async function POST( if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`); } + const proxied = await proxyLargeCourseControlToEngine({ + request: req, + course: record, + action: 'module-regenerate', + moduleIndex: index, + }); + if (proxied) return proxied; + await assertLearningCourseMutable(courseId, record); if (!record.framework) { return apiError( API_ERROR_CODES.INVALID_REQUEST, @@ -68,6 +81,9 @@ export async function POST( detailUrl: `${baseUrl}/api/courses/${courseId}`, }); } catch (error) { + if (error instanceof FrozenLearningCourseMutationError) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); + } if (error instanceof CourseMutationInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } diff --git a/OpenMAIC/app/api/courses/[courseId]/publish/route.ts b/OpenMAIC/app/api/courses/[courseId]/publish/route.ts index f06debf..cd92f23 100644 --- a/OpenMAIC/app/api/courses/[courseId]/publish/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/publish/route.ts @@ -17,7 +17,10 @@ import { import { COURSEWARE_PUBLISH_TOKEN_ENV, isPublishTokenConfigured } from '@/lib/courseware-repo'; import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types'; import type { CoursePublicationReceipt } from '@/lib/course-framework/types'; -import { requireOpsAccess } from '@/lib/server/ops-access'; +import { + configuredWorksSquareApiOrigin, + requireOpsAccess, +} from '@/lib/server/ops-access'; import { buildRequestOrigin } from '@/lib/server/classroom-storage'; import { isCourseGenerationRunning } from '@/lib/course-framework/runner'; import { @@ -49,6 +52,7 @@ import { publishCourseToServer } from '@/lib/server/course-publish-transport'; import { commitRemoteCoursePublish } from '@/lib/server/course-publish-transaction'; const log = createLogger('Course Publish API'); +export const maxDuration = 900; function buildPublicationReceipt( snapshot: CoursePublishSnapshot, @@ -112,7 +116,7 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(request); + const denied = await requireOpsAccess(request); if (denied) return denied; try { @@ -120,6 +124,64 @@ export async function POST( if (!isValidCourseId(courseId)) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, `Invalid course id: ${courseId}`); } + const worksOrigin = configuredWorksSquareApiOrigin(); + if (worksOrigin) { + const authorization = request.headers.get('authorization') ?? ''; + const finalizeResponse = await fetch( + `${worksOrigin}/api/admin/learning/generations/${encodeURIComponent(courseId)}/finalize`, + { + method: 'POST', + headers: { Authorization: authorization, Accept: 'application/json' }, + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(10 * 60_000), + }, + ); + const finalized = (await finalizeResponse.json().catch(() => null)) as { + id?: unknown; + status?: unknown; + } | null; + if (!finalizeResponse.ok || typeof finalized?.id !== 'string') { + return apiError( + API_ERROR_CODES.UPSTREAM_ERROR, + finalizeResponse.status >= 400 && finalizeResponse.status < 500 + ? finalizeResponse.status + : 502, + 'Works could not finalize the aggregate learning course', + ); + } + const publishResponse = await fetch( + `${worksOrigin}/api/admin/learning/courses/${encodeURIComponent(finalized.id)}/publish`, + { + method: 'POST', + headers: { Authorization: authorization, Accept: 'application/json' }, + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }, + ); + const published = (await publishResponse.json().catch(() => null)) as { + id?: unknown; + status?: unknown; + } | null; + if (!publishResponse.ok || typeof published?.id !== 'string') { + return apiError( + API_ERROR_CODES.UPSTREAM_ERROR, + publishResponse.status >= 400 && publishResponse.status < 500 + ? publishResponse.status + : 502, + 'Works registered the course package but could not publish it', + ); + } + await updateCourseRecord(courseId, { + externalPublication: { + courseId: published.id, + status: published.status === 'published' ? 'published' : 'ready', + publishedAt: new Date().toISOString(), + }, + }); + return apiSuccess({ record: published, aggregate: true }); + } if (!isPublishTokenConfigured()) { return apiError( API_ERROR_CODES.INTERNAL_ERROR, diff --git a/OpenMAIC/app/api/courses/[courseId]/resume/route.ts b/OpenMAIC/app/api/courses/[courseId]/resume/route.ts index 0df1943..94afdb4 100644 --- a/OpenMAIC/app/api/courses/[courseId]/resume/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/resume/route.ts @@ -21,6 +21,11 @@ import { CourseMutationInProgressError, isCoursePublishing, } from '@/lib/course-framework/publish-state'; +import { proxyLargeCourseControlToEngine } from '@/lib/makelore-runtime/ops-engine-control'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; const log = createLogger('Course Resume API'); @@ -28,7 +33,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(req); + const denied = await requireOpsAccess(req); if (denied) return denied; try { @@ -40,6 +45,13 @@ export async function POST( if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`); } + const proxied = await proxyLargeCourseControlToEngine({ + request: req, + course: record, + action: 'resume', + }); + if (proxied) return proxied; + await assertLearningCourseMutable(courseId, record); if (record.status === 'completed') { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, 'Course is already completed'); } @@ -61,6 +73,9 @@ export async function POST( detailUrl: `${baseUrl}/api/courses/${courseId}`, }); } catch (error) { + if (error instanceof FrozenLearningCourseMutationError) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); + } if (error instanceof CourseMutationInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } diff --git a/OpenMAIC/app/api/courses/[courseId]/route.ts b/OpenMAIC/app/api/courses/[courseId]/route.ts index 1e8349f..e219149 100644 --- a/OpenMAIC/app/api/courses/[courseId]/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/route.ts @@ -25,7 +25,7 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(request); + const denied = await requireOpsAccess(request); if (denied) return denied; try { @@ -46,6 +46,10 @@ export async function GET( for (const moduleRec of record.modules) { let published = false; let publishedCoursewareId: string | undefined; + if (record.externalPublication?.status === 'published') { + published = true; + publishedCoursewareId = `${record.externalPublication.courseId}_m${moduleRec.index}`; + } if (moduleRec.classroomId) { const coursewareId = courseModuleCoursewareId(courseId, moduleRec.index); const receipt = record.publication?.modules.find( @@ -57,7 +61,7 @@ export async function GET( const currentSourceRevisionHash = currentClassroom ? computeClassroomSourceRevisionHash(currentClassroom) : undefined; - published = Boolean( + published = published || Boolean( receipt && receipt.coursewareId === coursewareId && receipt.sourceClassroomId === moduleRec.classroomId && diff --git a/OpenMAIC/app/api/courses/[courseId]/start/route.ts b/OpenMAIC/app/api/courses/[courseId]/start/route.ts index 0a3bd4a..1e92386 100644 --- a/OpenMAIC/app/api/courses/[courseId]/start/route.ts +++ b/OpenMAIC/app/api/courses/[courseId]/start/route.ts @@ -21,6 +21,11 @@ import { CourseMutationInProgressError, isCoursePublishing, } from '@/lib/course-framework/publish-state'; +import { proxyLargeCourseControlToEngine } from '@/lib/makelore-runtime/ops-engine-control'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; const log = createLogger('Course Start API'); @@ -28,7 +33,7 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ courseId: string }> }, ) { - const denied = requireOpsAccess(req); + const denied = await requireOpsAccess(req); if (denied) return denied; try { @@ -40,6 +45,13 @@ export async function POST( if (!record) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`); } + const proxied = await proxyLargeCourseControlToEngine({ + request: req, + course: record, + action: 'start', + }); + if (proxied) return proxied; + await assertLearningCourseMutable(courseId, record); if (!record.framework) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, '课程框架尚未生成,请等待框架生成完成'); } @@ -70,6 +82,9 @@ export async function POST( detailUrl: `${baseUrl}/api/courses/${courseId}`, }); } catch (error) { + if (error instanceof FrozenLearningCourseMutationError) { + return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); + } if (error instanceof CourseMutationInProgressError) { return apiError(API_ERROR_CODES.INVALID_REQUEST, 409, error.message); } diff --git a/OpenMAIC/app/api/courses/route.ts b/OpenMAIC/app/api/courses/route.ts index eef438f..d8ded72 100644 --- a/OpenMAIC/app/api/courses/route.ts +++ b/OpenMAIC/app/api/courses/route.ts @@ -16,12 +16,15 @@ import { createLogger } from '@/lib/logger'; import type { CourseCreateInput } from '@/lib/course-framework/types'; import { createCourse, runCourseFrameworkGeneration } from '@/lib/course-framework/runner'; import { listCourseRecords, readCourseRecordReconciled } from '@/lib/course-framework/store'; -import { requireOpsAccess } from '@/lib/server/ops-access'; +import { + configuredWorksSquareApiOrigin, + requireOpsAccess, +} from '@/lib/server/ops-access'; const log = createLogger('Courses API'); export async function POST(req: NextRequest) { - const denied = requireOpsAccess(req); + const denied = await requireOpsAccess(req); if (denied) return denied; let requirementSnippet: string | undefined; @@ -39,6 +42,8 @@ export async function POST(req: NextRequest) { ? { enableVideoGeneration: rawBody.enableVideoGeneration } : {}), ...(rawBody.enableTTS != null ? { enableTTS: rawBody.enableTTS } : {}), + ...(rawBody.interactiveMode != null ? { interactiveMode: rawBody.interactiveMode } : {}), + ...(rawBody.taskEngineMode != null ? { taskEngineMode: rawBody.taskEngineMode } : {}), ...(rawBody.pdfContent ? { pdfContent: rawBody.pdfContent } : {}), }; const { requirement } = body; @@ -51,6 +56,70 @@ export async function POST(req: NextRequest) { ); } + const worksOrigin = configuredWorksSquareApiOrigin(); + if (worksOrigin) { + const upstream = await fetch(`${worksOrigin}/api/admin/learning/generations`, { + method: 'POST', + headers: { + Authorization: req.headers.get('authorization') ?? '', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + requirement: body.requirement, + enableWebSearch: body.enableWebSearch ?? false, + enableImageGeneration: body.enableImageGeneration ?? false, + enableVideoGeneration: body.enableVideoGeneration ?? false, + enableTTS: body.enableTTS ?? true, + interactiveMode: body.interactiveMode ?? true, + taskEngineMode: body.taskEngineMode ?? false, + }), + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }); + const result = (await upstream.json().catch(() => null)) as { + jobId?: unknown; + job_id?: unknown; + status?: unknown; + message?: unknown; + } | null; + if (!upstream.ok) { + return apiError( + API_ERROR_CODES.UPSTREAM_ERROR, + upstream.status >= 400 && upstream.status < 500 ? upstream.status : 502, + 'Works rejected the large-course generation request', + ); + } + const remoteJobId = + typeof result?.jobId === 'string' + ? result.jobId + : typeof result?.job_id === 'string' + ? result.job_id + : ''; + if (!remoteJobId) { + return apiError( + API_ERROR_CODES.UPSTREAM_ERROR, + 502, + 'Works returned an invalid generation response', + ); + } + return apiSuccess( + { + courseId: remoteJobId, + jobId: remoteJobId, + status: typeof result?.status === 'string' ? result.status : 'queued', + message: + typeof result?.message === 'string' + ? result.message + : 'Course framework generation started', + detailUrl: `${buildRequestOrigin(req)}/api/courses/${remoteJobId}`, + pollIntervalMs: 3000, + }, + 202, + ); + } + const courseId = nanoid(10); const baseUrl = buildRequestOrigin(req); await createCourse(courseId, body); @@ -85,7 +154,7 @@ export async function POST(req: NextRequest) { } export async function GET(request: NextRequest) { - const denied = requireOpsAccess(request); + const denied = await requireOpsAccess(request); if (denied) return denied; try { diff --git a/OpenMAIC/app/api/coursewares/route.ts b/OpenMAIC/app/api/coursewares/route.ts index 95c0cc7..681f920 100644 --- a/OpenMAIC/app/api/coursewares/route.ts +++ b/OpenMAIC/app/api/coursewares/route.ts @@ -70,7 +70,7 @@ export async function POST(request: NextRequest) { const sameOriginOpsSession = role === 'ops' || role === 'all'; if (sameOriginOpsSession) { - const denied = requireOpsAccess(request); + const denied = await requireOpsAccess(request); if (denied) return denied; } else if (role === 'server') { if (!isPublishTokenConfigured()) { diff --git a/OpenMAIC/app/api/generate-classroom/route.ts b/OpenMAIC/app/api/generate-classroom/route.ts index c797a8d..d70a329 100644 --- a/OpenMAIC/app/api/generate-classroom/route.ts +++ b/OpenMAIC/app/api/generate-classroom/route.ts @@ -39,6 +39,7 @@ export async function POST(req: NextRequest) { : {}), ...(rawBody.enableTTS != null ? { enableTTS: rawBody.enableTTS } : {}), ...(rawBody.interactiveMode != null ? { interactiveMode: rawBody.interactiveMode } : {}), + ...(rawBody.taskEngineMode != null ? { taskEngineMode: rawBody.taskEngineMode } : {}), }; const { requirement } = body; diff --git a/OpenMAIC/app/api/internal/course-publish/route.ts b/OpenMAIC/app/api/internal/course-publish/route.ts index 2986c79..a77a1ec 100644 --- a/OpenMAIC/app/api/internal/course-publish/route.ts +++ b/OpenMAIC/app/api/internal/course-publish/route.ts @@ -1,288 +1,6 @@ -// Server-only transactional large-course ingestion. -// -// One multipart request carries `metadata` JSON plus `module-{index}` ZIPs. -// The server stages every frozen bundle as unpublished, promotes the complete -// batch, commits one schema-v2 manifest, and compensates this batch back to -// unpublished on failure. Learner APIs never expose sourceClassroomId. +import { type NextRequest } from 'next/server'; -import { type NextRequest, NextResponse } from 'next/server'; -import { CoursePublishInProgressError } from '@/lib/course-framework/publish-state'; -import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types'; -import { - COURSEWARE_BUNDLES_DIR, - createFileBundleByteStore, - type BundleByteStore, -} from '@/lib/courseware-repo/bundle-store'; -import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; -import { - COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, - COURSEWARE_PUBLIC_BASE_URL_ENV, - CoursePublishTransportError, - DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, - DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, - MAX_COURSE_PUBLISH_METADATA_BYTES, - parseRemoteCoursePublishMetadata, - positiveIntegerEnv, - resolveCoursewarePublicBaseUrl, - type RemoteCoursePublishErrorBody, - type RemoteCoursePublishSuccessBody, -} from '@/lib/server/course-publish-contract'; -import { - commitRemoteCoursePublish, - type CoursePublishTransactionRepos, -} from '@/lib/server/course-publish-transaction'; -import { buildRequestOrigin } from '@/lib/server/classroom-storage'; -import { capBodyStream } from '@/lib/server/capped-stream'; -import { createLogger } from '@/lib/logger'; -import { - COURSEWARE_PUBLISH_TOKEN_ENV, - isPublishTokenConfigured, - verifyPublishToken, -} from '@/lib/courseware-repo'; -import type { CoursewareRepo } from '@/lib/courseware-repo/types'; -import { getServerDeploymentRole } from '@/lib/server/ops-access'; - -const log = createLogger('Internal Course Publish API'); - -export interface CoursePublishRouteDependencies { - coursewares?: CoursewareRepo; - bundles?: BundleByteStore; - manifests?: CourseManifestRepo; - publicBaseUrl?: string; -} - -function bearerToken(request: NextRequest): string | null { - const header = request.headers.get('authorization'); - if (!header?.startsWith('Bearer ')) return null; - return header.slice('Bearer '.length).trim() || null; -} - -function errorResponse(error: CoursePublishTransportError): NextResponse { - const body: RemoteCoursePublishErrorBody = { - success: false, - errorCode: error.errorCode, - error: error.message, - phase: error.phase, - ...(error.moduleIndex ? { moduleIndex: error.moduleIndex } : {}), - ...(error.details ? { details: error.details } : {}), - }; - return NextResponse.json(body, { status: error.status }); -} - -function routeRepos(deps: CoursePublishRouteDependencies): Partial { - return { - coursewares: deps.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR), - bundles: deps.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR), - ...(deps.manifests ? { manifests: deps.manifests } : {}), - }; -} - -function canonicalPublicBaseUrl( - request: NextRequest, - deps: CoursePublishRouteDependencies, -): string { - const configured = - deps.publicBaseUrl?.trim() || process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim(); - if (!configured && process.env.NODE_ENV === 'production') { - throw new CoursePublishTransportError( - 'PUBLIC_URL_MISSING', - `${COURSEWARE_PUBLIC_BASE_URL_ENV} is required on a production server`, - 'request', - 503, - ); - } - return resolveCoursewarePublicBaseUrl(configured || buildRequestOrigin(request)); -} - -export async function handleCoursePublishRequest( - request: NextRequest, - deps: CoursePublishRouteDependencies = {}, -): Promise { - const role = getServerDeploymentRole(); - if (role !== 'server' && role !== 'all') { - return errorResponse( - new CoursePublishTransportError( - 'FORBIDDEN', - 'Transactional course publishing is only available on the server deployment', - 'request', - 403, - ), - ); - } - if (!isPublishTokenConfigured()) { - return errorResponse( - new CoursePublishTransportError( - 'PUBLISH_DISABLED', - `Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`, - 'request', - 503, - ), - ); - } - const token = bearerToken(request); - if (!verifyPublishToken(token)) { - return errorResponse( - new CoursePublishTransportError( - 'UNAUTHORIZED', - 'Invalid or missing publish token', - 'request', - 401, - ), - ); - } - - try { - if (!request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) { - throw new CoursePublishTransportError( - 'INVALID_CONTENT_TYPE', - 'Course publish requires multipart/form-data', - 'request', - 415, - ); - } - const maxUploadBytes = positiveIntegerEnv( - COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, - DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, - ); - const declaredLength = Number(request.headers.get('content-length') ?? ''); - if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) { - throw new CoursePublishTransportError( - 'COURSE_UPLOAD_TOO_LARGE', - `Course publish exceeds ${maxUploadBytes} bytes`, - 'request', - 413, - ); - } - if (!request.body) { - throw new CoursePublishTransportError( - 'BODY_MISSING', - 'Course publish body is missing', - 'request', - 400, - ); - } - - const capped = capBodyStream(request.body, maxUploadBytes); - let form: FormData; - try { - form = await new Response(capped.stream, { - headers: { 'content-type': request.headers.get('content-type')! }, - }).formData(); - } catch (error) { - if (capped.exceeded()) { - throw new CoursePublishTransportError( - 'COURSE_UPLOAD_TOO_LARGE', - `Course publish exceeds ${maxUploadBytes} bytes`, - 'request', - 413, - ); - } - throw new CoursePublishTransportError( - 'MULTIPART_INVALID', - 'Course publish multipart body could not be parsed', - 'request', - 400, - undefined, - error instanceof Error ? error.message : undefined, - ); - } - - const metadataField = form.get('metadata'); - if (typeof metadataField !== 'string') { - throw new CoursePublishTransportError( - 'METADATA_MISSING', - 'Course publish metadata is missing', - 'request', - 400, - ); - } - if (new TextEncoder().encode(metadataField).byteLength > MAX_COURSE_PUBLISH_METADATA_BYTES) { - throw new CoursePublishTransportError( - 'METADATA_TOO_LARGE', - 'Course publish metadata exceeds the size limit', - 'request', - 413, - ); - } - let metadataValue: unknown; - try { - metadataValue = JSON.parse(metadataField) as unknown; - } catch { - throw new CoursePublishTransportError( - 'INVALID_METADATA', - 'Course publish metadata is not valid JSON', - 'request', - 400, - ); - } - const metadata = parseRemoteCoursePublishMetadata(metadataValue); - const perModuleLimit = positiveIntegerEnv( - 'COURSEWARE_MAX_UPLOAD_BYTES', - DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, - ); - const archives = metadata.modules.map((moduleRecord) => { - const fieldName = `module-${moduleRecord.index}`; - const fields = form.getAll(fieldName); - if (fields.length !== 1 || !(fields[0] instanceof File)) { - throw new CoursePublishTransportError( - 'MODULE_ARCHIVE_MISSING', - `Module ${moduleRecord.index} requires exactly one ZIP`, - 'request', - 400, - moduleRecord.index, - ); - } - const file = fields[0]; - if (file.size > perModuleLimit) { - throw new CoursePublishTransportError( - 'MODULE_ARCHIVE_TOO_LARGE', - `Module ${moduleRecord.index} ZIP exceeds ${perModuleLimit} bytes`, - 'request', - 413, - moduleRecord.index, - ); - } - return { index: moduleRecord.index, file }; - }); - const loadedArchives = await Promise.all( - archives.map(async ({ index, file }) => ({ - index, - zipBytes: new Uint8Array(await file.arrayBuffer()), - })), - ); - const result = await commitRemoteCoursePublish({ - metadata, - archives: loadedArchives, - token, - publicBaseUrl: canonicalPublicBaseUrl(request, deps), - repos: routeRepos(deps), - }); - const body: RemoteCoursePublishSuccessBody = { - success: true, - record: result.record, - idempotent: result.idempotent, - }; - return NextResponse.json(body, { status: result.idempotent ? 200 : 201 }); - } catch (error) { - if (error instanceof CoursePublishTransportError) return errorResponse(error); - if (error instanceof CoursePublishInProgressError) { - return errorResponse( - new CoursePublishTransportError('PUBLISH_IN_PROGRESS', error.message, 'request', 409), - ); - } - log.error('Transactional course publish failed:', error); - return errorResponse( - new CoursePublishTransportError( - 'INTERNAL_ERROR', - 'Transactional course publish failed', - 'commit', - 500, - undefined, - error instanceof Error ? error.message : undefined, - ), - ); - } -} +import { handleCoursePublishRequest } from '@/lib/server/course-publish-route'; export async function POST(request: NextRequest) { return handleCoursePublishRequest(request); diff --git a/OpenMAIC/app/api/persistence/[...path]/route.ts b/OpenMAIC/app/api/persistence/[...path]/route.ts index fb00f37..903b8b3 100644 --- a/OpenMAIC/app/api/persistence/[...path]/route.ts +++ b/OpenMAIC/app/api/persistence/[...path]/route.ts @@ -1,283 +1,7 @@ -import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http'; -import { Readable } from 'node:stream'; - -import { PgAssetStore, ensureAssetSchema } from '@openmaic/storage/asset/pg'; -import { PgDocumentStore, ensureDocumentSchema } from '@openmaic/storage/document/pg'; -import { PgRuntimeStore, ensureSchema } from '@openmaic/storage/runtime/pg'; -import { createStorageHttpHandler } from '@openmaic/storage/server'; -import { - nodePostgresTransaction, - type ConnectableQueryable, -} from '@openmaic/storage/server/reference'; -import { Pool } from 'pg'; - -import { validateAppScene, validateAppStage } from '@/lib/document-store/validators'; -import { lazyAssetByteStore } from '@/lib/persistence/asset-byte-store'; -import { authenticatePersistenceRequest } from '@/lib/persistence/server-auth'; -import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators'; +import { handlePersistenceRequest } from '@/lib/persistence/http-route'; export const runtime = 'nodejs'; -const ROUTE_PREFIX = '/api/persistence'; - -type PoolFactory = (connectionString: string) => Pool; - -interface PersistenceHandlerState { - connectionString?: string; - handlerPromise?: Promise; -} - -const HANDLER_STATE_KEY = Symbol.for('openmaic.persistence-route.handler'); -const globalState = globalThis as typeof globalThis & { - [key: symbol]: PersistenceHandlerState | undefined; -}; -const handlerState = (globalState[HANDLER_STATE_KEY] ??= {}); - -function jsonError(status: number, code: string, message: string): Response { - return Response.json({ error: { code, message } }, { status }); -} - -async function createPersistenceHandler( - connectionString: string, - poolFactory: PoolFactory, -): Promise { - const pool = poolFactory(connectionString); - const queryable = pool as unknown as ConnectableQueryable; - try { - await ensureSchema(queryable); - await ensureDocumentSchema(queryable); - await ensureAssetSchema(queryable); - const withTransaction = nodePostgresTransaction(queryable); - // Deferred to first asset use: the asset backend is optional, so its - // misconfiguration (an invalid ASSET_S3_BUCKET, an unresolvable AWS SDK) - // must fail asset requests only, never this handler's initialization. - const byteStore = lazyAssetByteStore(process.env.ASSET_S3_BUCKET, queryable); - const runtimeStore = new PgRuntimeStore(queryable, { - withTransaction, - payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS, - }); - const documentStore = new PgDocumentStore(queryable, { - withTransaction, - validateScene: validateAppScene, - validateStage: validateAppStage, - }); - // The asset contract requires a server-derived principal; this development - // authenticator instead takes the partition key from a client-supplied header. - // Cross-principal isolation is therefore not in force: asset bytes are as - // reachable as documents and runtime records under this authenticator. Before - // asset routes carry anything that matters, production must replace - // authenticatePersistenceRequest with real session verification. See - // lib/persistence/server-auth.ts for the token's limits. - const assetStore = new PgAssetStore(queryable, { withTransaction, byteStore }); - // Reclamation is not scheduled from here, and must not be: a route module - // has no once-per-process guarantee and no shutdown hook. AssetCollector - // runs from instrumentation.ts instead, over the byte store this same - // lib/persistence/asset-byte-store selection produces, so the collector - // always deletes through the layer the request path wrote through. - return createStorageHttpHandler(runtimeStore, documentStore, { - authenticate: authenticatePersistenceRequest, - authorizeMerge: async () => false, - authorizeAdmin: async () => false, - authorizeDocuments: async () => true, - validateScene: validateAppScene, - validateStage: validateAppStage, - payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS, - assetStore, - }); - } catch (error) { - await pool.end().catch(() => {}); - throw error; - } -} - -function getPersistenceHandler( - connectionString: string, - poolFactory: PoolFactory, -): Promise { - if (handlerState.handlerPromise && handlerState.connectionString === connectionString) { - return handlerState.handlerPromise; - } - - handlerState.connectionString = connectionString; - const initialization = createPersistenceHandler(connectionString, poolFactory).catch((error) => { - // Do not poison the singleton with a rejected promise. createPersistenceHandler - // has already closed its failed pool, and the next request gets a clean retry. - if (handlerState.handlerPromise === initialization) { - handlerState.handlerPromise = undefined; - handlerState.connectionString = undefined; - } - throw error; - }); - handlerState.handlerPromise = initialization; - return initialization; -} - -function nodeRequest(request: Request): IncomingMessage { - const url = new URL(request.url); - const pathname = url.pathname.startsWith(ROUTE_PREFIX) - ? url.pathname.slice(ROUTE_PREFIX.length) || '/' - : url.pathname; - const body = request.body - ? Readable.fromWeb( - request.body as unknown as import('node:stream/web').ReadableStream, - ) - : Readable.from([]); - return Object.assign(body, { - method: request.method, - url: `${pathname}${url.search}`, - headers: Object.fromEntries(request.headers.entries()), - }) as IncomingMessage; -} - -function setHeaders(target: Headers, source: Record): void { - for (const [name, value] of Object.entries(source)) { - if (Array.isArray(value)) { - for (const item of value) target.append(name, item); - } else { - target.set(name, String(value)); - } - } -} - -type ResponseCallback = () => void; - -function responseEncoding(encodingOrCallback?: BufferEncoding | ResponseCallback): BufferEncoding { - const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8'; - if (!Buffer.isEncoding(encoding)) { - // Let Buffer produce Node's ERR_UNKNOWN_ENCODING TypeError. - Buffer.from('', encoding); - } - return encoding; -} - -function responseCallback( - encodingOrCallback?: BufferEncoding | ResponseCallback, - callback?: ResponseCallback, -): ResponseCallback | undefined { - return typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; -} - -function suppressesResponseBody(request: Request, status: number): boolean { - return request.method === 'HEAD' || status === 204 || status === 205 || status === 304; -} - -function runNodeHandler(handler: RequestListener, request: Request): Promise { - return new Promise((resolve, reject) => { - let status = 200; - const headers = new Headers(); - let headersSent = false; - // Buffered as bytes rather than as a string. A handler may end with a - // `Uint8Array`, which `ServerResponse.end` accepts and which is not - // necessarily valid UTF-8; decoding it would replace every unpaired byte - // with U+FFFD and silently corrupt the response. - const body: Buffer[] = []; - - const appendChunk = (chunk: string | Uint8Array, encoding: BufferEncoding) => { - body.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : Buffer.from(chunk)); - }; - - const response = { - get headersSent() { - return headersSent; - }, - writeHead( - statusCode: number, - statusMessageOrHeaders?: string | Record, - outgoingHeaders?: Record, - ) { - status = statusCode; - headersSent = true; - const values = - typeof statusMessageOrHeaders === 'string' ? outgoingHeaders : statusMessageOrHeaders; - if (values) setHeaders(headers, values); - return this; - }, - write( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ResponseCallback, - callback?: ResponseCallback, - ) { - // `write` is part of the `ServerResponse` surface this object claims to - // implement. Omitting it made any chunked handler a runtime TypeError - // that the `as unknown as ServerResponse` cast hid from the compiler. - headersSent = true; - appendChunk(chunk, responseEncoding(encodingOrCallback)); - const done = responseCallback(encodingOrCallback, callback); - if (done) process.nextTick(done); - return true; - }, - end( - chunkOrCallback?: string | Uint8Array | ResponseCallback, - encodingOrCallback?: BufferEncoding | ResponseCallback, - callback?: ResponseCallback, - ) { - headersSent = true; - const chunk = typeof chunkOrCallback === 'function' ? undefined : chunkOrCallback; - const done = - typeof chunkOrCallback === 'function' - ? chunkOrCallback - : responseCallback(encodingOrCallback, callback); - if (chunk !== undefined) appendChunk(chunk, responseEncoding(encodingOrCallback)); - resolve( - new Response( - suppressesResponseBody(request, status) || body.length === 0 - ? undefined - : Buffer.concat(body), - { - status, - headers, - }, - ), - ); - if (done) process.nextTick(done); - return this; - }, - destroy(error?: Error) { - reject(error ?? new Error('Persistence HTTP handler destroyed the response')); - return this; - }, - } as unknown as ServerResponse; - - try { - handler(nodeRequest(request), response); - } catch (error) { - reject(error); - } - }); -} - -interface PersistenceRequestDeps { - poolFactory?: PoolFactory; -} - -export async function handlePersistenceRequest( - request: Request, - deps: PersistenceRequestDeps = {}, -): Promise { - const connectionString = process.env.DATABASE_URL; - if (!connectionString) { - return jsonError(404, 'PERSISTENCE_NOT_CONFIGURED', 'server persistence not configured'); - } - if (!process.env.PERSISTENCE_DEV_TOKEN) { - return jsonError( - 503, - 'PERSISTENCE_DEV_TOKEN_MISSING', - 'server persistence requires PERSISTENCE_DEV_TOKEN (development auth only)', - ); - } - - try { - const poolFactory = deps.poolFactory ?? ((value) => new Pool({ connectionString: value })); - return await runNodeHandler( - await getPersistenceHandler(connectionString, poolFactory), - request, - ); - } catch (error) { - console.error('Embedded persistence route initialization failed', error); - return jsonError(500, 'PERSISTENCE_INIT_FAILED', 'server persistence initialization failed'); - } -} - export const GET = (request: Request) => handlePersistenceRequest(request); export const POST = (request: Request) => handlePersistenceRequest(request); export const PUT = (request: Request) => handlePersistenceRequest(request); diff --git a/OpenMAIC/app/api/runtime/v1/bindings/validate/route.ts b/OpenMAIC/app/api/runtime/v1/bindings/validate/route.ts new file mode 100644 index 0000000..c58cbea --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/bindings/validate/route.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { loadMakeloreCourse, parseLearningBinding } from '@/lib/makelore-runtime/course-store'; +import { isRuntimeOpenRequest } from '@/lib/makelore-runtime/protocol'; + +export async function POST(request: NextRequest) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const body: unknown = await request.json().catch(() => null); + if (!isRuntimeOpenRequest(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 }); + return Response.json({ + type: 'learning.course.snapshot', + schema_version: 1, + binding_source_sequence: 0, + payload: { + courseId: course.courseId, + contentHash: course.contentHash, + title: course.title, + sceneCount: course.sceneCount, + }, + }); +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/[courseId]/package/route.ts b/OpenMAIC/app/api/runtime/v1/courses/[courseId]/package/route.ts new file mode 100644 index 0000000..77ee3ed --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/[courseId]/package/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { + readFrozenLearningPackageRecord, + readUniqueMakeloreCoursePackage, +} from '@/lib/makelore-course/package'; + +export async function GET(request: NextRequest, context: { params: Promise<{ courseId: string }> }) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const { courseId } = await context.params; + const contentHash = request.headers.get('x-content-hash')?.trim() || ''; + const record = await readFrozenLearningPackageRecord(courseId).catch(() => null); + if (!record || record.contentHash !== contentHash) { + return Response.json({ error: 'course_not_found' }, { status: 404 }); + } + const bytes = await readUniqueMakeloreCoursePackage(courseId); + if (!bytes) return Response.json({ error: 'package_not_found' }, { status: 404 }); + return new Response(bytes as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': String(bytes.byteLength), + 'X-Archive-SHA256': record.archiveSha256, + 'Cache-Control': 'private, no-store', + }, + }); +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/[courseId]/runtime/[...capability]/route.ts b/OpenMAIC/app/api/runtime/v1/courses/[courseId]/runtime/[...capability]/route.ts new file mode 100644 index 0000000..316b077 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/[courseId]/runtime/[...capability]/route.ts @@ -0,0 +1,238 @@ +import { isDeepStrictEqual } from 'node:util'; +import { NextRequest } from 'next/server'; +import { POST as quizGrade } from '@/app/api/quiz-grade/route'; +import { POST as pblTaskUpdate } from '@/app/api/pbl/v2/task/update/route'; +import { POST as pblOpenTask } from '@/app/api/pbl/v2/open-task/route'; +import { POST as pblSimulator } from '@/app/api/pbl/v2/simulator/route'; +import { POST as pblInstructor } from '@/app/api/pbl/v2/instructor/route'; +import { POST as pblEvaluate } from '@/app/api/pbl/v2/evaluate/route'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { + loadMakeloreCourse, + resolveMakeloreCourseModule, + runtimeSceneId, + type RuntimeCourseModule, +} from '@/lib/makelore-runtime/course-store'; +import { stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state'; +import { isPBLProjectV2, type PBLProjectV2 } from '@/lib/pbl/v2/types'; +import type { QuizQuestion, SceneContent } from '@/lib/types/stage'; + +export const runtime = 'nodejs'; +export const maxDuration = 300; + +const MAX_BODY_BYTES = 2 * 1024 * 1024; +const SHA256 = /^[0-9a-f]{64}$/; +const CAPABILITIES = { + 'quiz-grade': quizGrade, + 'pbl/v2/task/update': pblTaskUpdate, + 'pbl/v2/open-task': pblOpenTask, + 'pbl/v2/simulator': pblSimulator, + 'pbl/v2/instructor': pblInstructor, + 'pbl/v2/evaluate': pblEvaluate, +} as const; + +type Capability = keyof typeof CAPABILITIES; +type RuntimeContext = { + moduleId?: string | null; + moduleContentHash?: string | null; + anchor: { sceneId: string; sceneOrder?: number }; +}; + +function object(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function error(code: string, status: number): Response { + return Response.json({ error: code }, { status }); +} + +function parseContext(value: unknown): RuntimeContext | null { + const context = object(value); + const anchor = object(context?.anchor); + const sceneOrder = anchor?.sceneOrder; + if ( + !context || + !anchor || + typeof anchor.sceneId !== 'string' || + anchor.sceneId.length < 1 || + anchor.sceneId.length > 256 || + (sceneOrder !== undefined && + (!Number.isSafeInteger(sceneOrder) || Number(sceneOrder) < 0)) || + (context.moduleId !== undefined && context.moduleId !== null && typeof context.moduleId !== 'string') || + (context.moduleContentHash !== undefined && + context.moduleContentHash !== null && + (typeof context.moduleContentHash !== 'string' || !SHA256.test(context.moduleContentHash))) + ) { + return null; + } + return { + moduleId: context.moduleId as string | null | undefined, + moduleContentHash: context.moduleContentHash as string | null | undefined, + anchor: { + sceneId: anchor.sceneId, + ...(typeof sceneOrder === 'number' ? { sceneOrder } : {}), + }, + }; +} + +function selectedScene( + module: RuntimeCourseModule, + context: RuntimeContext, +): { content: SceneContent; order: number } | null { + if (module.manifest) { + const index = module.manifest.scenes.findIndex((_scene, sceneIndex) => + runtimeSceneId(module.course, module.descriptor, sceneIndex) === context.anchor.sceneId, + ); + if (index < 0) return null; + const scene = module.manifest.scenes[index]!; + if (context.anchor.sceneOrder !== undefined && scene.order !== context.anchor.sceneOrder) return null; + return { content: scene.content, order: scene.order }; + } + const scene = module.classroom?.scenes.find((candidate) => candidate.id === context.anchor.sceneId); + if (!scene || (context.anchor.sceneOrder !== undefined && scene.order !== context.anchor.sceneOrder)) { + return null; + } + return { content: scene.content, order: scene.order }; +} + +function quizBody(scene: SceneContent, value: unknown): Record | null { + if (scene.type !== 'quiz') return null; + const body = object(value); + if (!body || typeof body.question !== 'string' || typeof body.userAnswer !== 'string') return null; + const question = scene.questions.find((candidate: QuizQuestion) => + candidate.type === 'short_answer' && candidate.question === body.question, + ); + if (!question || body.userAnswer.length > 20_000) return null; + return { + question: question.question, + userAnswer: body.userAnswer, + points: question.points ?? 1, + ...(question.commentPrompt ? { commentPrompt: question.commentPrompt } : {}), + ...(body.language === 'zh-CN' ? { language: 'zh-CN' } : {}), + }; +} + +function validatedProject(scene: SceneContent, value: unknown): PBLProjectV2 | null { + if (scene.type !== 'pbl' || !isPBLProjectV2(scene.projectV2) || !isPBLProjectV2(value)) return null; + try { + return isDeepStrictEqual( + stripToDesignTemplate(value), + stripToDesignTemplate(scene.projectV2), + ) + ? value + : null; + } catch { + return null; + } +} + +function pblBody( + capability: Exclude, + scene: SceneContent, + value: unknown, +): Record | null { + const body = object(value); + const project = validatedProject(scene, body?.project); + if (!body || !project) return null; + switch (capability) { + case 'pbl/v2/task/update': + return { + project, + action: body.action, + ...(typeof body.microtaskId === 'string' ? { microtaskId: body.microtaskId } : {}), + }; + case 'pbl/v2/open-task': + return { + project, + phase: body.phase, + ...(Array.isArray(body.priorQuizResults) + ? { priorQuizResults: body.priorQuizResults.slice(0, 100) } + : {}), + }; + case 'pbl/v2/simulator': + return { + project, + ...(typeof body.userMessage === 'string' + ? { userMessage: body.userMessage.slice(0, 20_000) } + : {}), + ...(body.phase === 'greeting' ? { phase: 'greeting' } : {}), + }; + case 'pbl/v2/instructor': + return { + project, + ...(typeof body.userMessage === 'string' + ? { userMessage: body.userMessage.slice(0, 20_000) } + : {}), + ...(typeof body.phase === 'string' ? { phase: body.phase } : {}), + }; + case 'pbl/v2/evaluate': + return { + project, + kind: body.kind, + ...(typeof body.milestoneId === 'string' ? { milestoneId: body.milestoneId } : {}), + ...(typeof body.microtaskId === 'string' ? { microtaskId: body.microtaskId } : {}), + ...(typeof body.recentChatSummary === 'string' + ? { recentChatSummary: body.recentChatSummary.slice(0, 40_000) } + : {}), + }; + } +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ courseId: string; capability: string[] }> }, +) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const owner = request.headers.get('x-owner-user-id')?.trim(); + const aggregateHash = request.headers.get('x-content-hash')?.trim(); + if (!owner || !aggregateHash || !SHA256.test(aggregateHash)) { + return error('invalid_runtime_context', 400); + } + const contentLength = Number(request.headers.get('content-length') ?? '0'); + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + return error('runtime_request_too_large', 413); + } + const { courseId, capability: parts } = await context.params; + const capability = parts.join('/') as Capability; + const handler = CAPABILITIES[capability]; + if (!handler) return error('runtime_capability_not_found', 404); + + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > MAX_BODY_BYTES) { + return error('runtime_request_too_large', 413); + } + let envelope: Record | null; + try { + envelope = object(JSON.parse(text) as unknown); + } catch { + return error('invalid_runtime_request', 400); + } + const runtimeContext = parseContext(envelope?.context); + const rawBody = envelope?.body; + if (!runtimeContext || !object(rawBody)) return error('invalid_runtime_request', 400); + + const course = await loadMakeloreCourse(courseId, aggregateHash); + if (!course) return error('course_not_found', 404); + const module = await resolveMakeloreCourseModule(course, runtimeContext); + if (!module) return error('course_module_not_found', 404); + const scene = selectedScene(module, runtimeContext); + if (!scene) return error('course_scene_not_found', 404); + + const safeBody = capability === 'quiz-grade' + ? quizBody(scene.content, rawBody) + : pblBody(capability, scene.content, rawBody); + if (!safeBody) return error('runtime_capability_context_mismatch', 409); + + const internalRequest = new NextRequest( + `http://learning-engine.internal/api/${capability}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: request.headers.get('accept') ?? '*/*' }, + body: JSON.stringify(safeBody), + signal: request.signal, + }, + ); + return handler(internalRequest); +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/generate/route.ts b/OpenMAIC/app/api/runtime/v1/courses/generate/route.ts new file mode 100644 index 0000000..a2d656c --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/generate/route.ts @@ -0,0 +1,62 @@ +import { after, type NextRequest } from 'next/server'; +import { nanoid } from 'nanoid'; +import { createCourse, runCourseFrameworkGeneration } from '@/lib/course-framework/runner'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { + LEARNING_GENERATION_CONTRACT_VERSION, + LearningGenerationRequestError, + parseLearningGenerationRequest, +} from '@/lib/makelore-runtime/generation-request'; +import { createClassroomGenerationJob } from '@/lib/server/classroom-job-store'; +import { runClassroomGenerationJob } from '@/lib/server/classroom-job-runner'; +import { buildRequestOrigin } from '@/lib/server/classroom-storage'; + +export const maxDuration = 300; + +export async function POST(request: NextRequest) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + try { + const generation = await parseLearningGenerationRequest(request); + const jobId = nanoid(10); + const baseUrl = buildRequestOrigin(request); + + if (generation.mode === 'large') { + await createCourse(jobId, generation.input, { + ownerPrincipalId: generation.ownerUserId, + }); + // Phase 1 only. The original Ops builder reviews the framework and calls + // /api/courses/:id/start before the original per-module runner is used. + after(() => runCourseFrameworkGeneration(jobId, baseUrl)); + } else { + await createClassroomGenerationJob(jobId, generation.input, { + ownerPrincipalId: generation.ownerUserId, + }); + after(() => runClassroomGenerationJob(jobId, generation.input, baseUrl)); + } + + return Response.json( + { + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + status: 'queued', + mode: generation.mode, + done: false, + }, + { status: 202 }, + ); + } catch (error) { + if (error instanceof LearningGenerationRequestError) { + return Response.json( + { error: error.code, message: error.message }, + { status: error.status }, + ); + } + return Response.json( + { + error: 'generation_request_failed', + message: error instanceof Error ? error.message : 'Generation request failed', + }, + { status: 500 }, + ); + } +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/cancel/route.ts b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/cancel/route.ts new file mode 100644 index 0000000..51585fa --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/cancel/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request'; +import { + controlLargeCourse, + largeCourseControlErrorResponse, +} from '@/lib/makelore-runtime/large-course-control'; +import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store'; +import { cancelClassroomGenerationJob } from '@/lib/server/classroom-job-runner'; +import { buildRequestOrigin } from '@/lib/server/classroom-storage'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ jobId: string }> }, +) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const { jobId } = await context.params; + const owner = request.headers.get('x-owner-user-id')?.trim(); + if (!owner || !isValidClassroomJobId(jobId)) { + return Response.json({ error: 'invalid_request' }, { status: 400 }); + } + const job = await readClassroomGenerationJob(jobId); + if (job) { + if (job.ownerPrincipalId !== owner) { + return Response.json({ error: 'job_not_found' }, { status: 404 }); + } + if (!cancelClassroomGenerationJob(jobId)) { + return Response.json({ error: 'job_not_running' }, { status: 409 }); + } + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'single', + status: 'cancelling', + done: false, + }); + } + try { + await controlLargeCourse({ + courseId: jobId, + ownerPrincipalId: owner, + action: 'cancel', + baseUrl: buildRequestOrigin(request), + }); + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'large', + status: 'cancelling', + done: false, + }); + } catch (error) { + return largeCourseControlErrorResponse(error); + } +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/control/route.ts b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/control/route.ts new file mode 100644 index 0000000..aa7a4dc --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/control/route.ts @@ -0,0 +1,76 @@ +import { after, type NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request'; +import { + controlLargeCourse, + LARGE_COURSE_CONTROL_ACTIONS, + largeCourseControlErrorResponse, + type LargeCourseControlAction, +} from '@/lib/makelore-runtime/large-course-control'; +import { buildRequestOrigin } from '@/lib/server/classroom-storage'; + +const MAX_CONTROL_BODY_BYTES = 8 * 1024; + +function parseControlBody(raw: string): { + action: LargeCourseControlAction; + moduleIndex?: number; +} | null { + if (new TextEncoder().encode(raw).byteLength > MAX_CONTROL_BODY_BYTES) return null; + try { + const value = JSON.parse(raw) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (Object.keys(record).some((key) => key !== 'action' && key !== 'moduleIndex')) return null; + const action = record.action as LargeCourseControlAction; + if (typeof record.action !== 'string' || !LARGE_COURSE_CONTROL_ACTIONS.includes(action)) { + return null; + } + if (action === 'module-regenerate') { + if ( + typeof record.moduleIndex !== 'number' + || !Number.isInteger(record.moduleIndex) + || record.moduleIndex < 1 + ) return null; + return { action, moduleIndex: record.moduleIndex }; + } + if (record.moduleIndex !== undefined) return null; + return { action }; + } catch { + return null; + } +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ jobId: string }> }, +) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const ownerPrincipalId = request.headers.get('x-owner-user-id')?.trim() ?? ''; + const { jobId } = await context.params; + const body = parseControlBody(await request.text()); + if (!ownerPrincipalId || !body) { + return Response.json({ error: 'invalid_request' }, { status: 400 }); + } + try { + const result = await controlLargeCourse({ + courseId: jobId, + ownerPrincipalId, + action: body.action, + moduleIndex: body.moduleIndex, + baseUrl: buildRequestOrigin(request), + }); + if (result.run) after(() => result.run!); + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'large', + action: result.action, + status: result.status, + done: false, + message: result.message, + ...(result.moduleIndex === undefined ? {} : { moduleIndex: result.moduleIndex }), + }); + } catch (error) { + return largeCourseControlErrorResponse(error); + } +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/finalize/route.ts b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/finalize/route.ts new file mode 100644 index 0000000..3e7646e --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/finalize/route.ts @@ -0,0 +1,69 @@ +import type { NextRequest } from 'next/server'; +import { readCourseRecordReconciled } from '@/lib/course-framework/store'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store'; +import { buildRequestOrigin, readClassroom } from '@/lib/server/classroom-storage'; +import { + finalizeLargeLearningCourse, + finalizeSingleLearningCourse, + frozenLearningFinalizeResponse, +} from '@/lib/makelore-course/finalize'; +import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability'; + +export async function POST(request: NextRequest, context: { params: Promise<{ jobId: string }> }) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const { jobId } = await context.params; + const owner = request.headers.get('x-owner-user-id')?.trim(); + if (!owner || !isValidClassroomJobId(jobId)) return Response.json({ error: 'invalid_request' }, { status: 400 }); + const job = await readClassroomGenerationJob(jobId); + try { + if (job) { + if (job.ownerPrincipalId !== owner) { + return Response.json({ error: 'job_not_found' }, { status: 404 }); + } + if (job.status !== 'succeeded' || !job.result?.classroomId) { + return Response.json({ error: 'job_not_ready' }, { status: 409 }); + } + const classroom = await readClassroom(job.result.classroomId); + if (!classroom) return Response.json({ error: 'classroom_not_found' }, { status: 404 }); + const record = await finalizeSingleLearningCourse({ + jobId, + classroom, + baseUrl: buildRequestOrigin(request), + requireInteractiveHtml: job.input.interactiveMode === true, + requireNarrationAudio: job.input.enableTTS === true, + }); + return Response.json(frozenLearningFinalizeResponse(record)); + } + + const course = await readCourseRecordReconciled(jobId); + if (!course || course.ownerPrincipalId !== owner) { + return Response.json({ error: 'job_not_found' }, { status: 404 }); + } + if (course.status !== 'completed') { + return Response.json( + { + error: course.status === 'framework_ready' ? 'review_required' : 'job_not_ready', + ...(course.status === 'framework_ready' + ? { reviewUrl: `/learning-ops/courses/${course.id}` } + : {}), + }, + { status: 409 }, + ); + } + const record = await finalizeLargeLearningCourse({ + jobId, + course, + baseUrl: buildRequestOrigin(request), + }); + return Response.json(frozenLearningFinalizeResponse(record)); + } catch (error) { + if (error instanceof FrozenLearningCourseMutationError) { + return Response.json({ error: 'course_frozen', message: error.message }, { status: 409 }); + } + return Response.json({ + error: 'course_not_offline_complete', + message: error instanceof Error ? error.message : 'Course finalization failed', + }, { status: 422 }); + } +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/resume/route.ts b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/resume/route.ts new file mode 100644 index 0000000..45dcea4 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/resume/route.ts @@ -0,0 +1,57 @@ +import { after, type NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request'; +import { + controlLargeCourse, + largeCourseControlErrorResponse, +} from '@/lib/makelore-runtime/large-course-control'; +import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store'; +import { resumeClassroomGenerationJob } from '@/lib/server/classroom-job-runner'; +import { buildRequestOrigin } from '@/lib/server/classroom-storage'; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ jobId: string }> }, +) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const { jobId } = await context.params; + const owner = request.headers.get('x-owner-user-id')?.trim(); + if (!owner || !isValidClassroomJobId(jobId)) { + return Response.json({ error: 'invalid_request' }, { status: 400 }); + } + const baseUrl = buildRequestOrigin(request); + const job = await readClassroomGenerationJob(jobId); + if (job) { + if (job.ownerPrincipalId !== owner) { + return Response.json({ error: 'job_not_found' }, { status: 404 }); + } + if (!(await resumeClassroomGenerationJob(jobId, baseUrl))) { + return Response.json({ error: 'job_not_resumable' }, { status: 409 }); + } + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'single', + status: 'queued', + done: false, + }); + } + try { + const result = await controlLargeCourse({ + courseId: jobId, + ownerPrincipalId: owner, + action: 'resume', + baseUrl, + }); + if (result.run) after(() => result.run!); + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'large', + status: result.status, + done: false, + }); + } catch (error) { + return largeCourseControlErrorResponse(error); + } +} diff --git a/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/route.ts b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/route.ts new file mode 100644 index 0000000..9383b03 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/courses/jobs/[jobId]/route.ts @@ -0,0 +1,61 @@ +import type { NextRequest } from 'next/server'; +import { readCourseRecordReconciled } from '@/lib/course-framework/store'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; +import { LEARNING_GENERATION_CONTRACT_VERSION } from '@/lib/makelore-runtime/generation-request'; +import { isValidClassroomJobId, readClassroomGenerationJob } from '@/lib/server/classroom-job-store'; + +function largeCourseProgress(status: string, succeeded: number, total: number): number { + if (status === 'completed') return 100; + if (status === 'framework_ready') return 25; + if (status === 'framework_generating') return 10; + if (status === 'queued') return 0; + if (total > 0) return Math.min(99, Math.round(25 + (succeeded / total) * 75)); + return 0; +} + +export async function GET(request: NextRequest, context: { params: Promise<{ jobId: string }> }) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + const { jobId } = await context.params; + const owner = request.headers.get('x-owner-user-id')?.trim(); + if (!owner || !isValidClassroomJobId(jobId)) return Response.json({ error: 'invalid_request' }, { status: 400 }); + const job = await readClassroomGenerationJob(jobId); + if (job) { + if (job.ownerPrincipalId !== owner) return Response.json({ error: 'job_not_found' }, { status: 404 }); + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'single', + status: job.status, + step: job.step, + progress: job.progress, + message: job.message, + scenesGenerated: job.scenesGenerated, + totalScenes: job.totalScenes, + courseId: job.result?.classroomId, + error: job.error, + done: ['succeeded', 'failed', 'cancelled'].includes(job.status), + }); + } + + const course = await readCourseRecordReconciled(jobId); + if (!course || course.ownerPrincipalId !== owner) { + return Response.json({ error: 'job_not_found' }, { status: 404 }); + } + const succeeded = course.modules.filter((module) => module.status === 'succeeded').length; + const total = course.modules.length; + return Response.json({ + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + jobId, + mode: 'large', + status: course.status, + step: course.status, + progress: largeCourseProgress(course.status, succeeded, total), + message: + course.status === 'framework_ready' + ? 'Course framework is ready for operations review' + : undefined, + courseId: course.id, + error: course.error, + done: ['completed', 'failed', 'cancelled'].includes(course.status), + }); +} diff --git a/OpenMAIC/app/api/runtime/v1/health/live/route.ts b/OpenMAIC/app/api/runtime/v1/health/live/route.ts new file mode 100644 index 0000000..4e256d1 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/health/live/route.ts @@ -0,0 +1,11 @@ +import { LEARNING_ENGINE_API_VERSION, LEARNING_ENGINE_SERVICE } from '@/lib/makelore-runtime/health'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + return Response.json({ + service: LEARNING_ENGINE_SERVICE, + apiVersion: LEARNING_ENGINE_API_VERSION, + status: 'ok', + }); +} diff --git a/OpenMAIC/app/api/runtime/v1/health/ready/route.ts b/OpenMAIC/app/api/runtime/v1/health/ready/route.ts new file mode 100644 index 0000000..0d75f76 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/health/ready/route.ts @@ -0,0 +1,23 @@ +import { + inspectLearningReadiness, + LEARNING_ENGINE_API_VERSION, + LEARNING_ENGINE_SERVICE, +} from '@/lib/makelore-runtime/health'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const readiness = await inspectLearningReadiness(); + return Response.json( + { + service: LEARNING_ENGINE_SERVICE, + apiVersion: LEARNING_ENGINE_API_VERSION, + status: readiness.ready ? 'ready' : 'not_ready', + checks: readiness.checks, + }, + { + status: readiness.ready ? 200 : 503, + headers: { 'Cache-Control': 'no-store' }, + }, + ); +} diff --git a/OpenMAIC/app/api/runtime/v1/runs/[runId]/cancel/route.ts b/OpenMAIC/app/api/runtime/v1/runs/[runId]/cancel/route.ts new file mode 100644 index 0000000..dcf9320 --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/runs/[runId]/cancel/route.ts @@ -0,0 +1,7 @@ +import type { NextRequest } from 'next/server'; +import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth'; + +export async function POST(request: NextRequest) { + if (!authorizeLearningRuntime(request)) return runtimeUnauthorized(); + return Response.json({ status: 'not_supported', message: 'The current turn finishes independently.' }); +} diff --git a/OpenMAIC/app/api/runtime/v1/runs/route.ts b/OpenMAIC/app/api/runtime/v1/runs/route.ts new file mode 100644 index 0000000..01ee55b --- /dev/null +++ b/OpenMAIC/app/api/runtime/v1/runs/route.ts @@ -0,0 +1,94 @@ +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', + }, + }); +} diff --git a/OpenMAIC/app/api/tts/route.ts b/OpenMAIC/app/api/tts/route.ts index c733bca..cba714c 100644 --- a/OpenMAIC/app/api/tts/route.ts +++ b/OpenMAIC/app/api/tts/route.ts @@ -50,25 +50,25 @@ const KNOWN_TTS_PROVIDERS: TTSProviderId[] = [ ]; /** Resolve the default TTS provider: env override, else first configured. */ -export function resolveQaTtsProvider(): TTSProviderId | undefined { +function resolveQaTtsProvider(): TTSProviderId | undefined { const configured = process.env.QA_TTS_PROVIDER; if (configured) return configured as TTSProviderId; return KNOWN_TTS_PROVIDERS.find((id) => isServerConfiguredProvider('tts', id)); } -export interface TtsCacheKey { +interface TtsCacheKey { providerId: string; voice: string; text: string; } -export function ttsCacheKey(input: TtsCacheKey): string { +function ttsCacheKey(input: TtsCacheKey): string { return createHash('sha256') .update(`${input.providerId}|${input.voice}|${input.text}`) .digest('hex'); } -export function ttsCachePath(key: string, format: string): string { +function ttsCachePath(key: string, format: string): string { return path.join(TTS_CACHE_DIR, `${key}.${format}`); } diff --git a/OpenMAIC/app/courses/[courseId]/page.tsx b/OpenMAIC/app/courses/[courseId]/page.tsx index 1dfef79..d0a8004 100644 --- a/OpenMAIC/app/courses/[courseId]/page.tsx +++ b/OpenMAIC/app/courses/[courseId]/page.tsx @@ -33,6 +33,7 @@ import { Progress } from '@/components/ui/progress'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { CourseDetail } from '@/lib/course-framework/types'; import { toast } from 'sonner'; +import { opsApiFetch } from '@/lib/ops/api-fetch'; const MODULE_STATUS_STYLES: Record = { pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300', @@ -66,7 +67,7 @@ export default function CourseBuilderPage() { const load = useCallback(async () => { if (!courseId) return; try { - const response = await fetch(`/api/courses/${courseId}`); + const response = await opsApiFetch(`/api/courses/${courseId}`); const body = (await response.json()) as { course?: CourseDetail }; if (!response.ok || !body.course) { throw new Error(`HTTP ${response.status}`); @@ -110,7 +111,7 @@ export default function CourseBuilderPage() { const publishCourse = async () => { await act('publish', () => - fetch(`/api/courses/${courseId}/publish`, { + opsApiFetch(`/api/courses/${courseId}/publish`, { method: 'POST', }), ); @@ -199,7 +200,7 @@ export default function CourseBuilderPage() { disabled={acting !== null} onClick={() => void act('cancel', () => - fetch(`/api/courses/${courseId}/cancel`, { method: 'POST' }), + opsApiFetch(`/api/courses/${courseId}/cancel`, { method: 'POST' }), ) } > @@ -214,7 +215,7 @@ export default function CourseBuilderPage() { disabled={acting !== null} onClick={() => void act('resume', () => - fetch(`/api/courses/${courseId}/resume`, { method: 'POST' }), + opsApiFetch(`/api/courses/${courseId}/resume`, { method: 'POST' }), ) } > @@ -415,7 +416,7 @@ export default function CourseBuilderPage() { disabled={acting !== null} onClick={() => void act('start', () => - fetch(`/api/courses/${courseId}/start`, { method: 'POST' }), + opsApiFetch(`/api/courses/${courseId}/start`, { method: 'POST' }), ) } > @@ -428,7 +429,7 @@ export default function CourseBuilderPage() { disabled={acting !== null} onClick={() => void act('frameworkRegenerate', () => - fetch(`/api/courses/${courseId}/framework/regenerate`, { method: 'POST' }), + opsApiFetch(`/api/courses/${courseId}/framework/regenerate`, { method: 'POST' }), ) } > @@ -635,7 +636,7 @@ export default function CourseBuilderPage() { onClick={() => { if (!window.confirm(t('course.regenerateModuleCascadeHint'))) return; void act('regenerate', () => - fetch(`/api/courses/${courseId}/modules/${module.index}/regenerate`, { + opsApiFetch(`/api/courses/${courseId}/modules/${module.index}/regenerate`, { method: 'POST', }), ); diff --git a/OpenMAIC/app/generation-preview/page.tsx b/OpenMAIC/app/generation-preview/page.tsx index cc66494..a909671 100644 --- a/OpenMAIC/app/generation-preview/page.tsx +++ b/OpenMAIC/app/generation-preview/page.tsx @@ -20,7 +20,7 @@ import { generateTTSForScene, useSceneGenerator, } from '@/lib/hooks/use-scene-generator'; -import { isAbortError } from '@openmaic/generation'; +import { isAbortError } from '@openmaic/generation/generation-retry'; import { FOREGROUND_SCENE_RETRY_OPTIONS } from './foreground-retry'; import { loadImageMapping, diff --git a/OpenMAIC/app/layout.tsx b/OpenMAIC/app/layout.tsx index 9165ab3..fec4176 100644 --- a/OpenMAIC/app/layout.tsx +++ b/OpenMAIC/app/layout.tsx @@ -11,6 +11,7 @@ import { Toaster } from '@/components/ui/sonner'; import { ServerProvidersInit } from '@/components/server-providers-init'; import { StorageHealthNotice } from '@/components/storage-health-notice'; import { AccessCodeGuard } from '@/components/access-code-guard'; +import { OpsBrowserBoundary } from '@/components/ops-browser-boundary'; // The UI font is loaded from @fontsource's stylesheet rather than next/font, // because only the stylesheet carries the per-subset `unicode-range` @@ -45,6 +46,7 @@ export default function RootLayout({ > + {children} diff --git a/OpenMAIC/app/makelore-player/page.tsx b/OpenMAIC/app/makelore-player/page.tsx new file mode 100644 index 0000000..e3e76fe --- /dev/null +++ b/OpenMAIC/app/makelore-player/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Stage } from '@/components/stage'; +import { MediaStageProvider } from '@/lib/contexts/media-stage-context'; +import { ThemeProvider } from '@/lib/hooks/use-theme'; +import { + applyClassroomStageAndScenes, + type ClassroomPayload, +} from '@/lib/classroom/load-classroom'; +import { applyGeneratedAgentsToRegistry } from '@/lib/orchestration/registry/store'; +import { useSettingsStore } from '@/lib/store/settings'; +import { useStageStore } from '@/lib/store'; +import { + installMakeloreRuntimeFetchBridge, + type MakeloreCourseRuntimeContext, +} from '@/lib/makelore-runtime/browser-bridge'; +import { + MAKELORE_PLAYBACK_PROGRESS_EVENT, + type MakelorePlaybackProgress, +} from '@/lib/makelore-runtime/progress-events'; +import classroomFixture from '@/fixtures/makelore/python-basics-classroom.json'; + +const FIXTURE = classroomFixture as unknown as ClassroomPayload; + +type CourseLoadMessage = { + type: 'makelore:course:load'; + courseId: string; + /** Aggregate hash (canonical). */ + courseContentHash?: string; + /** Aggregate hash compatibility alias. */ + contentHash?: string; + moduleId?: string | null; + moduleContentHash?: string; + classroom: ClassroomPayload; + progress?: { sceneOrder?: number; actionIndex?: number; positionMs?: number; completed?: boolean }; +}; + +function isClassroomPayload(value: unknown): value is ClassroomPayload { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return Boolean(record.stage) + && typeof record.stage === 'object' + && Array.isArray(record.scenes); +} + +/** + * Playback-only integration fixture for the Makelore desktop player. + * + * This route deliberately renders the production Stage instead of a reduced + * mock, so slides, interactive HTML, quizzes, the whiteboard, playback + * actions, and the assistant chrome are exercised together. The final desktop + * bridge supplies the same payload from a verified local course package. + */ +export default function MakelorePlayerPage() { + const [classroom, setClassroom] = useState(null); + const [error, setError] = useState(null); + const [initialSceneOrder, setInitialSceneOrder] = useState(null); + + useEffect(() => { + const embedded = new URLSearchParams(window.location.search).get('embedded') === '1'; + if (!embedded) { + setClassroom(FIXTURE); + return; + } + window.__MAKELORE_OFFLINE_PLAYER__ = true; + const uninstallRuntimeBridge = installMakeloreRuntimeFetchBridge({ + getAnchor: () => { + const state = useStageStore.getState(); + const scene = state.scenes.find((candidate) => candidate.id === state.currentSceneId); + if (!scene) return null; + return { sceneId: scene.id, sceneOrder: scene.order, sceneTitle: scene.title }; + }, + }); + const onPlaybackProgress = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || typeof detail.sceneId !== 'string') return; + window.parent.postMessage({ + type: 'makelore:progress', + sceneOrder: detail.sceneOrder, + actionIndex: detail.actionIndex, + positionMs: null, + completed: detail.completed, + }, '*'); + }; + window.addEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, onPlaybackProgress); + let timeout = 0; + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; + const message = event.data as Partial; + const courseContentHash = typeof message.courseContentHash === 'string' + ? message.courseContentHash + : message.contentHash; + if (message.type !== 'makelore:course:load' + || typeof message.courseId !== 'string' + || typeof courseContentHash !== 'string' + || !isClassroomPayload(message.classroom)) return; + const moduleContentHash = typeof message.moduleContentHash === 'string' + ? message.moduleContentHash + : courseContentHash; + window.__MAKELORE_COURSE_CONTEXT__ = { + courseId: message.courseId, + courseContentHash, + contentHash: courseContentHash, + moduleId: typeof message.moduleId === 'string' ? message.moduleId : null, + moduleContentHash, + }; + window.clearTimeout(timeout); + setError(null); + setInitialSceneOrder(typeof message.progress?.sceneOrder === 'number' + ? message.progress.sceneOrder + : null); + window.__MAKELORE_INITIAL_PROGRESS__ = { + sceneOrder: typeof message.progress?.sceneOrder === 'number' + ? message.progress.sceneOrder + : undefined, + actionIndex: typeof message.progress?.actionIndex === 'number' + ? message.progress.actionIndex + : null, + completed: message.progress?.completed === true, + }; + setClassroom(message.classroom); + }; + window.addEventListener('message', onMessage); + window.parent.postMessage({ type: 'makelore:player:ready' }, '*'); + timeout = window.setTimeout(() => setError('没有收到本地课件数据'), 15_000); + return () => { + window.clearTimeout(timeout); + window.removeEventListener('message', onMessage); + window.removeEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, onPlaybackProgress); + uninstallRuntimeBridge(); + delete window.__MAKELORE_COURSE_CONTEXT__; + delete window.__MAKELORE_INITIAL_PROGRESS__; + delete window.__MAKELORE_OFFLINE_PLAYER__; + }; + }, []); + + useEffect(() => { + if (!classroom) return; + const { stage, scenes } = classroom; + applyClassroomStageAndScenes(stage, scenes, { persist: false }); + const generatedAgentIds = applyGeneratedAgentsToRegistry( + stage.id, + stage.generatedAgentConfigs ?? [], + ); + const settings = useSettingsStore.getState(); + settings.setAgentMode('auto'); + settings.setSelectedAgentIds(generatedAgentIds); + settings.setAgentSelectionIsUserSet(false); + const resumedScene = initialSceneOrder === null + ? undefined + : scenes.find((scene) => scene.order === initialSceneOrder); + useStageStore.setState({ + generationComplete: true, + mode: 'playback', + ...(resumedScene ? { currentSceneId: resumedScene.id } : {}), + }); + window.parent.postMessage({ type: 'makelore:player:loaded', stageId: stage.id }, '*'); + let lastSceneId = useStageStore.getState().currentSceneId; + return useStageStore.subscribe((state) => { + if (!state.currentSceneId || state.currentSceneId === lastSceneId) return; + lastSceneId = state.currentSceneId; + const scene = state.scenes.find((candidate) => candidate.id === state.currentSceneId); + if (scene) window.parent.postMessage({ + type: 'makelore:progress', + sceneOrder: scene.order, + actionIndex: 0, + positionMs: null, + completed: false, + }, '*'); + }); + }, [classroom, initialSceneOrder]); + + return ( + + +
+ {classroom ? ( + + ) : ( +
+ {error ?? '正在打开课件…'} +
+ )} +
+
+
+ ); +} + +declare global { + interface Window { + __MAKELORE_OFFLINE_PLAYER__?: boolean; + __MAKELORE_COURSE_CONTEXT__?: MakeloreCourseRuntimeContext; + __MAKELORE_INITIAL_PROGRESS__?: { + sceneOrder?: number; + actionIndex?: number | null; + completed?: boolean; + }; + } +} diff --git a/OpenMAIC/app/page.tsx b/OpenMAIC/app/page.tsx index 439c161..2553846 100644 --- a/OpenMAIC/app/page.tsx +++ b/OpenMAIC/app/page.tsx @@ -28,6 +28,7 @@ import { type GenerationMode, } from '@/components/generation/generation-mode-tabs'; import { LargeCourseWorkbench } from '@/components/generation/large-course-workbench'; +import { opsApiPath } from '@/lib/ops/api-fetch'; import { storeDocumentBlob } from '@/lib/utils/image-storage'; import { createForegroundGenerationSession } from '@/lib/generation/foreground-session'; import { isOpsWorkbenchEnabled, isSettingsManagementEnabled } from '@/lib/config/deployment-role'; @@ -387,7 +388,7 @@ function HomePage() { > {/* ── Logo ── */} ({ enabled: false, authenticated: false, loading: true }); useEffect(() => { + if (isOpsDeployment || new URLSearchParams(window.location.search).get('embedded') === '1') { + setStatus({ enabled: false, authenticated: true, loading: false }); + return; + } let cancelled = false; fetch('/api/access-code/status') .then((res) => res.json()) @@ -33,7 +38,7 @@ export function AccessCodeGuard({ children }: { children: ReactNode }) { return () => { cancelled = true; }; - }, []); + }, [isOpsDeployment]); const needsAuth = !status.loading && status.enabled && !status.authenticated; diff --git a/OpenMAIC/components/chat/use-chat-sessions.ts b/OpenMAIC/components/chat/use-chat-sessions.ts index 6daa88e..9625c91 100644 --- a/OpenMAIC/components/chat/use-chat-sessions.ts +++ b/OpenMAIC/components/chat/use-chat-sessions.ts @@ -28,6 +28,7 @@ import { USER_AVATAR } from '@/lib/types/roundtable'; import { StreamBuffer } from '@/lib/buffer/stream-buffer'; import type { AgentStartItem, ActionItem } from '@/lib/buffer/stream-buffer'; import { runAgentLoop, type AgentLoopStoreState } from '@/lib/chat/agent-loop'; +import { fetchMakeloreLearningAgent } from '@/lib/makelore-runtime/browser-bridge'; import { ActionEngine } from '@/lib/action/engine'; import { buildQuizResultsForStoreState, @@ -1142,6 +1143,33 @@ export function useChatSessions(options: UseChatSessionsOptions = {}) { requestTemplate.config.agentConfigs = generatedConfigs; } + if (window.__MAKELORE_OFFLINE_PLAYER__) { + const streamConsumer = createStatelessStreamConsumer(sessionId, controller, sessionType); + const storeState = await buildFreshAgentLoopStoreState(); + const response = await fetchMakeloreLearningAgent({ + messages: requestTemplate.messages, + storeState, + }, controller.signal); + if (!response.ok) throw new Error(await response.text()); + const reader = response.body?.getReader(); + if (!reader) throw new Error('助教没有返回内容'); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + for (const frame of frames) { + const data = frame.split('\n').find((line) => line.startsWith('data: ')); + if (data) streamConsumer.onEvent(JSON.parse(data.slice(6)) as StatelessEvent); + } + } + await streamConsumer.onIterationEnd(); + return; + } + if (isPiChatEnabled()) { // Pi bypasses runAgentLoop's per-iteration getStoreState, so its single // request needs the snapshot built here — /api/chat/pi rejects bodies diff --git a/OpenMAIC/components/edit/PlaybackChromeRoot.tsx b/OpenMAIC/components/edit/PlaybackChromeRoot.tsx index 58bf66a..0df8863 100644 --- a/OpenMAIC/components/edit/PlaybackChromeRoot.tsx +++ b/OpenMAIC/components/edit/PlaybackChromeRoot.tsx @@ -54,6 +54,7 @@ import { } from '@/components/ui/alert-dialog'; import { AlertTriangle } from 'lucide-react'; import { VisuallyHidden } from 'radix-ui'; +import { emitMakelorePlaybackProgress } from '@/lib/makelore-runtime/progress-events'; /** * Imperative handle exposed via `ref` so the parent (`Stage`) can tear @@ -221,10 +222,21 @@ export const PlaybackChromeRoot = forwardRef { + const updateCurrentPlaybackActionIndex = useCallback(( + actionIndex: number | null, + completed = false, + ) => { currentPlaybackActionIndexRef.current = actionIndex; setCurrentPlaybackActionIndex(actionIndex); - }, []); + if (currentScene) { + emitMakelorePlaybackProgress({ + sceneId: currentScene.id, + sceneOrder: currentScene.order, + actionIndex, + completed, + }); + } + }, [currentScene]); const persistCursorSafely = useCallback( ({ stageId, cursor }: { stageId: string; cursor: PlaybackCursor }) => { @@ -598,8 +610,33 @@ export const PlaybackChromeRoot = forwardRef= 0 + && currentScene.actions?.[offlineProgress.actionIndex!] + && canJumpWithinReconstructablePrefix( + currentScene.actions, + 0, + offlineProgress.actionIndex!, + ) + ) { + savedResumeActionIndex = offlineProgress.actionIndex!; + restoredOfflineProgress = true; + delete window.__MAKELORE_INITIAL_PROGRESS__; + } const playbackStageId = stage?.id ?? currentScene?.stageId; - if (currentScene && playbackStageId && !sessionResumeCursor.position) { + if ( + currentScene + && playbackStageId + && !sessionResumeCursor.position + && !restoredOfflineProgress + ) { try { const cursor = await loadCursor(playbackStageId); if ( @@ -766,6 +803,7 @@ export const PlaybackChromeRoot = forwardRef - 麦洛学习 + 麦洛学习 )}
diff --git a/OpenMAIC/components/generation/large-course-workbench.tsx b/OpenMAIC/components/generation/large-course-workbench.tsx index f755ced..6596335 100644 --- a/OpenMAIC/components/generation/large-course-workbench.tsx +++ b/OpenMAIC/components/generation/large-course-workbench.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'; import { MediaPopover, type MediaCapabilityOverride } from '@/components/generation/media-popover'; import type { SettingsSection } from '@/lib/types/settings'; import { cn } from '@/lib/utils'; +import { opsApiFetch } from '@/lib/ops/api-fetch'; const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; @@ -64,16 +65,13 @@ export function LargeCourseWorkbench({ const [webSearch, setWebSearch] = useState(false); const [enableImageGeneration, setEnableImageGeneration] = useState(false); const [enableVideoGeneration, setEnableVideoGeneration] = useState(false); + const [enableTTS, setEnableTTS] = useState(true); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - // Large courses always keep narration enabled because publishing requires - // persisted speech audio for every module. - const enableTTS = true; - useEffect(() => { try { if (localStorage.getItem(WEB_SEARCH_STORAGE_KEY) === 'true') setWebSearch(true); @@ -95,7 +93,7 @@ export function LargeCourseWorkbench({ setLoading(true); setLoadError(null); try { - const response = await fetch('/api/courses'); + const response = await opsApiFetch('/api/courses'); const body = (await response.json()) as { items?: CourseListItem[] }; if (!response.ok || !Array.isArray(body.items)) { throw new Error(`HTTP ${response.status}`); @@ -119,7 +117,7 @@ export function LargeCourseWorkbench({ setCreating(true); setCreateError(null); try { - const response = await fetch('/api/courses', { + const response = await opsApiFetch('/api/courses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -127,7 +125,7 @@ export function LargeCourseWorkbench({ enableWebSearch: webSearch || undefined, enableImageGeneration: enableImageGeneration || undefined, enableVideoGeneration: enableVideoGeneration || undefined, - enableTTS: enableTTS || undefined, + enableTTS, }), }); const body = (await response.json()) as { @@ -151,9 +149,7 @@ export function LargeCourseWorkbench({ > = { image: { enabled: enableImageGeneration, onToggle: setEnableImageGeneration }, video: { enabled: enableVideoGeneration, onToggle: setEnableVideoGeneration }, - // Narration is a large-course publishing requirement, so keep it visible - // in the shared settings bar without allowing it to be switched off. - tts: { enabled: enableTTS, disabled: true }, + tts: { enabled: enableTTS, onToggle: setEnableTTS }, asr: { enabled: false, disabled: true }, }; diff --git a/OpenMAIC/components/generation/outlines-editor.tsx b/OpenMAIC/components/generation/outlines-editor.tsx index c4d4cf0..2ac0cdb 100644 --- a/OpenMAIC/components/generation/outlines-editor.tsx +++ b/OpenMAIC/components/generation/outlines-editor.tsx @@ -28,7 +28,7 @@ import { useI18n } from '@/lib/hooks/use-i18n'; import { cn } from '@/lib/utils'; import type { SceneOutline } from '@/lib/types/generation'; import type { WidgetType } from '@/lib/types/widgets'; -import { changeOutlineType } from '@openmaic/generation'; +import { changeOutlineType } from '@openmaic/generation/outline-type'; import { countBlockingOutlines, validateOutline } from '@/lib/edit/content-validation'; type SceneType = SceneOutline['type']; diff --git a/OpenMAIC/components/ops-browser-boundary.tsx b/OpenMAIC/components/ops-browser-boundary.tsx new file mode 100644 index 0000000..933feaa --- /dev/null +++ b/OpenMAIC/components/ops-browser-boundary.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useLayoutEffect } from 'react'; +import { installOpsBrowserBoundary } from '@/lib/ops/api-fetch'; + +/** Base-path and Works-session adapter for the original operations UI. */ +export function OpsBrowserBoundary() { + useLayoutEffect(() => installOpsBrowserBoundary(), []); + return null; +} diff --git a/OpenMAIC/components/scene-renderers/interactive-renderer.tsx b/OpenMAIC/components/scene-renderers/interactive-renderer.tsx index a6e6245..c0f85d1 100644 --- a/OpenMAIC/components/scene-renderers/interactive-renderer.tsx +++ b/OpenMAIC/components/scene-renderers/interactive-renderer.tsx @@ -31,7 +31,9 @@ export function InteractiveRenderer({ content, sceneId }: InteractiveRendererPro const setActive = useInteractiveIframePool((s) => s.setActive); const patchedHtml = useMemo( - () => (content.html ? patchHtmlForIframe(content.html) : undefined), + () => (content.html ? patchHtmlForIframe(content.html, { + offline: typeof window !== 'undefined' && window.__MAKELORE_OFFLINE_PLAYER__ === true, + }) : undefined), [content.html], ); diff --git a/OpenMAIC/components/scene-renderers/pbl/v2/workspace.tsx b/OpenMAIC/components/scene-renderers/pbl/v2/workspace.tsx index 9c02296..0165274 100644 --- a/OpenMAIC/components/scene-renderers/pbl/v2/workspace.tsx +++ b/OpenMAIC/components/scene-renderers/pbl/v2/workspace.tsx @@ -28,6 +28,7 @@ import { PBLV2SubmissionPanel, type SubmissionEvaluationStatus } from './submiss import { PBLV2RightPanelTabs } from './right-panel-tabs'; import { shouldShowScenarioBriefing } from './scenario-briefing-gate'; import { cn } from '@/lib/utils/cn'; +import { opsApiPath } from '@/lib/ops/api-fetch'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { CSSProperties } from 'react'; import { runOneStream, type StreamDisplayState, type StreamStatus } from './use-instructor-stream'; @@ -442,7 +443,7 @@ function WorkspaceTopBar({
OpenMAIC state.fetchServerProviders); useEffect(() => { + if (new URLSearchParams(window.location.search).get('embedded') === '1') return; fetchServerProviders(); }, [fetchServerProviders]); diff --git a/OpenMAIC/components/stage/scene-sidebar.tsx b/OpenMAIC/components/stage/scene-sidebar.tsx index 43cd56a..2418f79 100644 --- a/OpenMAIC/components/stage/scene-sidebar.tsx +++ b/OpenMAIC/components/stage/scene-sidebar.tsx @@ -20,6 +20,7 @@ import { useStageStore, useCanvasStore } from '@/lib/store'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { SceneType, SlideContent, InteractiveContent } from '@/lib/types/stage'; import { PENDING_SCENE_ID } from '@/lib/store/stage'; +import { opsApiPath } from '@/lib/ops/api-fetch'; interface SceneSidebarProps { readonly collapsed: boolean; @@ -130,7 +131,7 @@ export function SceneSidebar({ className="flex items-center gap-2 cursor-pointer rounded-lg px-1.5 -mx-1.5 py-1 -my-1 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 active:scale-[0.97] transition-all duration-150" title={t('generation.backToHome')} > - 麦洛学习 + 麦洛学习 \n \n \n
\n\n \n
\n
\n \n 输出\n
\n
代码运行结果将显示在这里...
\n
\n\n \n
\n
\n 🧪 测试用例 \n \n
\n
\n
运行代码后自动检查
\n
\n
\n\n \n
\n
💡 提示(逐步显示)
\n
\n
点击\"提示\"按钮查看提示
\n
\n
\n\n \n
\n
📖 参考答案
\n
\n        
\n\n \n
\n 📋 查看组件配置 (JSON)\n
\n        
\n
\n\n \n \n\n \n \n \n \n \n\n \n \n\n \n\n", + "widgetType": "code", + "widgetConfig": { + "type": "code", + "language": "python", + "description": "通过交互式代码练习理解变量赋值与常见数据类型。掌握变量命名、字符串/整数/浮点数/布尔值,以及 type() 函数的使用。", + "starterCode": "# === 变量与数据类型探索 ===\n# 请完成以下练习:\n\n# 1. 创建一个字符串变量 name,值为你的名字\n# 例如:name = \"小明\"\n\n# 2. 创建一个整数变量 age,值为你的年龄\n# 例如:age = 15\n\n# 3. 创建一个浮点数变量 height,值为你的身高(米)\n# 例如:height = 1.75\n\n# 4. 创建一个布尔值变量 is_student,表示你是否是学生\n# 例如:is_student = True\n\n# 5. 使用 type() 函数查看这些变量的类型\n# 例如:print(type(name))\n\n# 6. 尝试修改其中一个变量的值,并打印修改前后的值\n\n# 请在下方编写你的代码:\n", + "testCases": [ + { + "id": "t1", + "input": "type(name).__name__", + "expected": "str", + "description": "变量 name 是字符串 (str) 类型" + }, + { + "id": "t2", + "input": "type(age).__name__", + "expected": "int", + "description": "变量 age 是整数 (int) 类型" + }, + { + "id": "t3", + "input": "type(height).__name__", + "expected": "float", + "description": "变量 height 是浮点数 (float) 类型" + }, + { + "id": "t4", + "input": "type(is_student).__name__", + "expected": "bool", + "description": "变量 is_student 是布尔 (bool) 类型" + }, + { + "id": "t5", + "input": "__code_contains_type__", + "expected": "True", + "description": "使用了 type() 函数查看类型" + } + ], + "hints": [ + "变量赋值使用等号 =,左边是变量名,右边是值。例如:name = '小明'", + "Python 中基本数据类型:字符串用引号 '...' 或 \"...\" 括起来;整数直接写数字如 15;浮点数带小数点如 1.75;布尔值是 True 或 False(注意首字母大写)", + "使用 type() 函数可以查看变量类型。例如:print(type(name)) 会输出 ,表示字符串类型", + "修改变量值只需重新赋值。例如:age = 16 会将 age 的值从 15 改为 16。修改后打印可以观察变化" + ], + "solution": "# 创建不同类型的变量\nname = \"小明\" # 字符串 (str)\nage = 15 # 整数 (int)\nheight = 1.75 # 浮点数 (float)\nis_student = True # 布尔值 (bool)\n\n# 使用 type() 查看类型\nprint(type(name))\nprint(type(age))\nprint(type(height))\nprint(type(is_student))\n\n# 修改变量的值\nage = 16\nprint(f\"修改后的年龄: {age}\")" + } + } + }, + { + "id": "scene_CFJoA09QKZ", + "stageId": "DZynj92UuU", + "title": "条件判断:if/elif/else", + "order": 4, + "actions": [ + { + "id": "action_DzeZjLaG", + "type": "spotlight", + "elementId": "text_6oz4loS5" + }, + { + "id": "action_xMld6KcY", + "type": "speech", + "text": "同学们,我们来看 Python 中的条件判断。简单说,就是让程序根据不同的情况,做出不同的决定。" + }, + { + "id": "action_5hwJzukL", + "type": "spotlight", + "elementId": "text_j9aDmPG7" + }, + { + "id": "action_JOL3tmIn", + "type": "speech", + "text": "先来看比较运算符,比如大于、小于、等于、不等于。它们是条件判断的基础,用来比较两个值的大小或是否相等。" + }, + { + "id": "action_dCmpg8AA", + "type": "spotlight", + "elementId": "text_UrNhk5YC" + }, + { + "id": "action_m8a-LRpe", + "type": "speech", + "text": "接着看条件语法:if 后面写条件,条件成立就执行下面的代码块;不成立再看 elif 做进一步判断;最后 else 用来兜底执行。" + }, + { + "id": "action_zo9N1k_A", + "type": "spotlight", + "elementId": "text_F3BTEsGr" + }, + { + "id": "action_Sg5ltw7e", + "type": "speech", + "text": "这里要特别注意缩进规则:冒号之后要换行并缩进,同一个代码块的缩进必须保持一致,推荐使用 4 个空格。" + }, + { + "id": "action_YZATKLOr", + "type": "spotlight", + "elementId": "text_SONpzAAQ" + }, + { + "id": "action_Gao5qJrN", + "type": "speech", + "text": "最后看这个成绩等级判断的例子:score 等于 85,先判断是否大于等于 90,不成立;再判断是否大于等于 80,成立了,所以 grade 就是 B。这样分支逻辑就很清楚了。" + } + ], + "outlineId": "scene_4", + "createdAt": 1786855608706, + "updatedAt": 1786855608706, + "type": "slide", + "content": { + "type": "slide", + "canvas": { + "id": "AYV9PCKePYILsXx4K2swr", + "viewportSize": 1000, + "viewportRatio": 0.5625, + "theme": { + "backgroundColor": "#ffffff", + "themeColors": [ + "#5b9bd5", + "#ed7d31", + "#a5a5a5", + "#ffc000", + "#4472c4" + ], + "fontColor": "#333333", + "fontName": "Microsoft YaHei", + "outline": { + "color": "#d14424", + "width": 2, + "style": "solid" + }, + "shadow": { + "h": 0, + "v": 0, + "blur": 10, + "color": "#000000" + } + }, + "elements": [ + { + "id": "text_6oz4loS5", + "type": "text", + "left": 60, + "top": 50, + "width": 880, + "height": 70, + "content": "

条件判断:if / elif / else

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + }, + { + "id": "shape_-JfvAZI8", + "type": "shape", + "left": 70, + "top": 128, + "width": 860, + "height": 3, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#5b9bd5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_G_weEpFV", + "type": "shape", + "left": 60, + "top": 150, + "width": 280, + "height": 170, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#e8f4fd", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_j9aDmPG7", + "type": "text", + "left": 80, + "top": 164, + "width": 240, + "height": 142, + "content": "

1. 比较运算符

> 大于 < 小于

== 等于 != 不等于

>= 大等于 <= 小等于

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + }, + { + "id": "shape_Lg3MT9dD", + "type": "shape", + "left": 360, + "top": 150, + "width": 280, + "height": 170, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#e9f9e9", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_UrNhk5YC", + "type": "text", + "left": 380, + "top": 164, + "width": 240, + "height": 142, + "content": "

2. 条件语法

if 条件: 执行块

elif 条件: 再次判断

else 兜底执行

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + }, + { + "id": "shape_mw6McQbH", + "type": "shape", + "left": 660, + "top": 150, + "width": 280, + "height": 170, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#fff4e0", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_F3BTEsGr", + "type": "text", + "left": 680, + "top": 164, + "width": 240, + "height": 142, + "content": "

3. 缩进规则

冒号后换行缩进

同块缩进一致

推荐 4 个空格

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + }, + { + "id": "shape_JN99a8Af", + "type": "shape", + "left": 60, + "top": 340, + "width": 880, + "height": 170, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#fdf6e3", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_SONpzAAQ", + "type": "text", + "left": 80, + "top": 347, + "width": 840, + "height": 157, + "content": "

示例:成绩等级判断

score = 85

if score >= 90: grade = "A"

elif score >= 60: grade = "B"

else: grade = "C"

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + } + ], + "background": { + "type": "solid", + "color": "#ffffff" + } + } + } + }, + { + "id": "scene_ENRWe7NiKc", + "stageId": "DZynj92UuU", + "title": "循环:for 与 while", + "order": 5, + "actions": [ + { + "id": "action__v_wtb8X", + "type": "spotlight", + "elementId": "text_weL70gS3" + }, + { + "id": "action_dUFPj2M_", + "type": "speech", + "text": "接下来我们来看循环结构——for 与 while。循环可以让我们重复执行同一段代码,是编程中非常重要的工具。" + }, + { + "id": "action_u-SDrWHk", + "type": "spotlight", + "elementId": "text_bJz63DPQ" + }, + { + "id": "action_tRaNhwqR", + "type": "speech", + "text": "先看 for 循环。它适用于固定次数或者遍历一个范围的情况,写法是 for i in range(n):。其中 i 会从 0 开始,依次取到 n-1。" + }, + { + "id": "action_8_E47Mjf", + "type": "spotlight", + "elementId": "text_YFrKWduW" + }, + { + "id": "action__I-P_WAU", + "type": "speech", + "text": "再看 while 循环。它是在条件为真时重复执行,适合事先不知道要循环多少次、但知道结束条件的情况。写法是 while 条件:,下面缩进的代码就是循环体。" + }, + { + "id": "action_pAmJQ4oM", + "type": "spotlight", + "elementId": "text__cJwQ97C" + }, + { + "id": "action_JqlmCscI", + "type": "speech", + "text": "循环中还有两个关键字需要掌握:break 用来提前结束整个循环;continue 用来跳过本次循环,直接进入下一轮。它们常常配合条件判断使用。" + }, + { + "id": "action_Cz_3TGsG", + "type": "spotlight", + "elementId": "text_yEOWBMRo" + }, + { + "id": "action_OwabOoiu", + "type": "speech", + "text": "最后看一个示例:计算 1 到 100 的和。用 for 循环累加,最终结果是 5050。大家可以自己动手写一写,感受循环带来的便利。" + } + ], + "outlineId": "scene_5", + "createdAt": 1786855734450, + "updatedAt": 1786855734450, + "type": "slide", + "content": { + "type": "slide", + "canvas": { + "id": "3F6DuHqvanDwQLqYuBM-T", + "viewportSize": 1000, + "viewportRatio": 0.5625, + "theme": { + "backgroundColor": "#ffffff", + "themeColors": [ + "#5b9bd5", + "#ed7d31", + "#a5a5a5", + "#ffc000", + "#4472c4" + ], + "fontColor": "#333333", + "fontName": "Microsoft YaHei", + "outline": { + "color": "#d14424", + "width": 2, + "style": "solid" + }, + "shadow": { + "h": 0, + "v": 0, + "blur": 10, + "color": "#000000" + } + }, + "elements": [ + { + "id": "text_weL70gS3", + "type": "text", + "left": 60, + "top": 60, + "width": 880, + "height": 76, + "content": "

循环:for 与 while

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#333333", + "rotate": 0 + }, + { + "id": "shape_rPnb5fb0", + "type": "shape", + "left": 70, + "top": 142, + "width": 860, + "height": 3, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#5b9bd5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_Zdju2wpK", + "type": "shape", + "left": 60, + "top": 160, + "width": 430, + "height": 220, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#e8f0fe", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_bJz63DPQ", + "type": "text", + "left": 100, + "top": 197, + "width": 350, + "height": 58, + "content": "

for 循环

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#1e3a8a", + "rotate": 0 + }, + { + "id": "text_q05YkStM", + "type": "text", + "left": 80, + "top": 267, + "width": 390, + "height": 76, + "content": "

• 固定次数 / 遍历范围

• for i in range(n)

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#1e3a8a", + "rotate": 0 + }, + { + "id": "shape_Lu21Mi07", + "type": "shape", + "left": 510, + "top": 160, + "width": 430, + "height": 220, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#d9f2e6", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_YFrKWduW", + "type": "text", + "left": 550, + "top": 197, + "width": 350, + "height": 58, + "content": "

while 循环

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#14532d", + "rotate": 0 + }, + { + "id": "text_h1Yrn1t9", + "type": "text", + "left": 530, + "top": 267, + "width": 390, + "height": 76, + "content": "

• 条件为真时重复执行

• while 条件:

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#14532d", + "rotate": 0 + }, + { + "id": "shape_pRDH2t5H", + "type": "shape", + "left": 60, + "top": 395, + "width": 430, + "height": 110, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#fff4e5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text__cJwQ97C", + "type": "text", + "left": 80, + "top": 412, + "width": 390, + "height": 76, + "content": "

break:提前结束整个循环

continue:跳过本次,继续下一轮

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#7c2d12", + "rotate": 0 + }, + { + "id": "shape_C4Csf2Hm", + "type": "shape", + "left": 510, + "top": 395, + "width": 430, + "height": 110, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#f3e8ff", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_yEOWBMRo", + "type": "text", + "left": 530, + "top": 399, + "width": 390, + "height": 49, + "content": "

示例:计算 1 到 100 之和

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#4c1d95", + "rotate": 0 + }, + { + "id": "latex_RmwCjtpY", + "type": "latex", + "left": 625, + "top": 451, + "width": 200, + "height": 50, + "latex": "\\sum_{i=1}^{100} i = 5050", + "color": "#4c1d95", + "align": "center", + "html": "i=1100i=5050", + "fixedRatio": true, + "rotate": 0 + } + ], + "background": { + "type": "solid", + "color": "#ffffff" + } + } + } + }, + { + "id": "scene_DhO9LXwi6z", + "stageId": "DZynj92UuU", + "title": "阶段测验:基础语法", + "order": 6, + "actions": [ + { + "id": "action_W8D8eCSz", + "type": "speech", + "text": "同学们好!现在我们来做一个阶段小测验,看看刚才学的内容掌握得怎么样。请你独立完成每一道题,提交后我会和你一起逐题梳理,帮你把不明白的地方弄清楚。" + } + ], + "outlineId": "scene_6", + "createdAt": 1786855762417, + "updatedAt": 1786855762417, + "type": "quiz", + "content": { + "type": "quiz", + "questions": [ + { + "id": "q1", + "type": "single", + "question": "在 Python 中,以下哪个是正确的变量赋值语句?", + "options": [ + { + "value": "A", + "label": "x = 5" + }, + { + "value": "B", + "label": "int x = 5" + }, + { + "value": "C", + "label": "x == 5" + }, + { + "value": "D", + "label": "let x = 5" + } + ], + "answer": [ + "A" + ], + "analysis": "Python 中变量赋值直接使用等号 =,不需要声明类型,所以 A 正确。B 是 C/C++ 风格的声明方式,C 中的 == 是比较运算符,D 是 JavaScript 的声明语法。", + "points": 10, + "hasAnswer": true + }, + { + "id": "q2", + "type": "single", + "question": "在 Python 中,以下哪个代码能正确判断变量 age 是否大于等于 18?", + "options": [ + { + "value": "A", + "label": "if age >= 18:" + }, + { + "value": "B", + "label": "if age > 18:" + }, + { + "value": "C", + "label": "if age == 18:" + }, + { + "value": "D", + "label": "if age <= 18:" + } + ], + "answer": [ + "A" + ], + "analysis": "大于等于用 >= 表示,A 正确。B 只判断大于,C 只判断相等,D 是小于等于,均不符合题意。", + "points": 10, + "hasAnswer": true + }, + { + "id": "q3", + "type": "multiple", + "question": "以下哪些是 Python 中合法的循环语句?(多选)", + "options": [ + { + "value": "A", + "label": "for i in range(5):" + }, + { + "value": "B", + "label": "while x < 10:" + }, + { + "value": "C", + "label": "loop i from 1 to 5:" + }, + { + "value": "D", + "label": "repeat 5 times:" + } + ], + "answer": [ + "A", + "B" + ], + "analysis": "Python 支持 for 循环和 while 循环,因此 A 和 B 正确。C 和 D 是其他编程语言的循环语法,Python 中不存在。", + "points": 15, + "hasAnswer": true + } + ] + } + }, + { + "id": "scene_ve19tbMFb3", + "stageId": "DZynj92UuU", + "title": "列表与字典", + "order": 7, + "actions": [ + { + "id": "action_-l2GasN4", + "type": "spotlight", + "elementId": "text_P9KwtIrg" + }, + { + "id": "action_6Zi_h0gc", + "type": "speech", + "text": "今天我们来认识 Python 中两种非常常用的数据结构——列表(List)和字典(Dictionary)。它们能帮我们同时存储和操作多个数据。" + }, + { + "id": "action_xkI5XeiE", + "type": "spotlight", + "elementId": "text_AkYFuc4U" + }, + { + "id": "action_17aQxn9Y", + "type": "speech", + "text": "先看列表。列表是一个有序集合,用方括号创建,可以通过索引访问元素,比如 nums[0] 获取第一个元素。" + }, + { + "id": "action_5StC0IRc", + "type": "spotlight", + "elementId": "text_lNsk5VRw" + }, + { + "id": "action_8A-3okeK", + "type": "speech", + "text": "列表支持 append 在末尾添加元素,remove 删除指定元素。右边代码先创建 [1,2,3],再添加 4,最后移除 2。" + }, + { + "id": "action_lF2oiWcZ", + "type": "spotlight", + "elementId": "text_5NqZR0Ev" + }, + { + "id": "action_5M60QVKy", + "type": "speech", + "text": "接下来看字典。字典是键值对结构,用花括号创建,通过键来访问对应的值,还可以灵活添加或修改。" + }, + { + "id": "action_p2X63K9O", + "type": "spotlight", + "elementId": "text_kt2ZAsK5" + }, + { + "id": "action_iNutTaAG", + "type": "speech", + "text": "这个例子用 stu 存储学生信息,设置姓名和年龄。字典特别适合保存一组相关的数据。大家动手试试吧!" + } + ], + "outlineId": "scene_7", + "createdAt": 1786855907985, + "updatedAt": 1786855907985, + "type": "slide", + "content": { + "type": "slide", + "canvas": { + "id": "ZhRAx-gy-efZp84gyC_D4", + "viewportSize": 1000, + "viewportRatio": 0.5625, + "theme": { + "backgroundColor": "#ffffff", + "themeColors": [ + "#5b9bd5", + "#ed7d31", + "#a5a5a5", + "#ffc000", + "#4472c4" + ], + "fontColor": "#333333", + "fontName": "Microsoft YaHei", + "outline": { + "color": "#d14424", + "width": 2, + "style": "solid" + }, + "shadow": { + "h": 0, + "v": 0, + "blur": 10, + "color": "#000000" + } + }, + "elements": [ + { + "id": "text_P9KwtIrg", + "type": "text", + "left": 60, + "top": 50, + "width": 880, + "height": 70, + "content": "

列表与字典

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#1F2937", + "rotate": 0 + }, + { + "id": "shape_kkjoBNa7", + "type": "shape", + "left": 150, + "top": 128, + "width": 700, + "height": 3, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#5B9BD5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_qe6oADPC", + "type": "shape", + "left": 60, + "top": 150, + "width": 430, + "height": 340, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#EEF2FF", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_8vTkV5C_", + "type": "shape", + "left": 510, + "top": 150, + "width": 430, + "height": 340, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#ECFDF5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_AkYFuc4U", + "type": "text", + "left": 80, + "top": 170, + "width": 390, + "height": 58, + "content": "

列表(List)

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#312E81", + "rotate": 0 + }, + { + "id": "text_5NqZR0Ev", + "type": "text", + "left": 530, + "top": 170, + "width": 390, + "height": 58, + "content": "

字典(Dictionary)

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#065F46", + "rotate": 0 + }, + { + "id": "text_lNsk5VRw", + "type": "text", + "left": 80, + "top": 240, + "width": 390, + "height": 118, + "content": "

• 有序集合,用 [] 创建

• 索引访问:nums[0]

append() 末尾添加

remove() 删除指定值

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#374151", + "rotate": 0 + }, + { + "id": "text_rXEiopsW", + "type": "text", + "left": 530, + "top": 240, + "width": 390, + "height": 118, + "content": "

• 键值对结构:{key: value}

• 通过键访问:stu['name']

• 可灵活添加、修改值

• 示例:存储学生信息

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#374151", + "rotate": 0 + }, + { + "id": "shape__Dxr3KVF", + "type": "shape", + "left": 80, + "top": 372, + "width": 390, + "height": 94, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#1F2937", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_2XpO-yfC", + "type": "shape", + "left": 530, + "top": 372, + "width": 390, + "height": 94, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#1F2937", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_1jRYbwkK", + "type": "text", + "left": 100, + "top": 372, + "width": 350, + "height": 94, + "content": "

nums = [1, 2, 3]

nums.append(4)

nums.remove(2)

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#F3F4F6", + "rotate": 0 + }, + { + "id": "text_kt2ZAsK5", + "type": "text", + "left": 550, + "top": 372, + "width": 350, + "height": 94, + "content": "

stu = {}

stu['name'] = '小明'

stu['age'] = 12

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#F3F4F6", + "rotate": 0 + } + ], + "background": { + "type": "solid", + "color": "#ffffff" + } + } + } + }, + { + "id": "scene_fYjgMlYTCD", + "stageId": "DZynj92UuU", + "title": "函数:复用代码", + "order": 8, + "actions": [ + { + "id": "action_Vmu6FnaT", + "type": "speech", + "text": "今天我们来学习函数,它能让我们复用代码,让程序更简洁高效。" + }, + { + "id": "action_v-9h-W8d", + "type": "spotlight", + "elementId": "text_V3a1Dc9e" + }, + { + "id": "action_3FAVyO4U", + "type": "speech", + "text": "函数就像是一个可重复使用的小工具箱:我们把一段常用代码放进去,给它起个名字,需要时直接调用,不用每次重新编写。" + }, + { + "id": "action_AVAcCsC7", + "type": "spotlight", + "elementId": "text_-iZv1pDj" + }, + { + "id": "action_o0siZTu4", + "type": "speech", + "text": "在Python中,我们用 def 关键字来定义函数。def 后面跟函数名和括号,最后以冒号结尾,下面缩进的代码就是函数体。" + }, + { + "id": "action__s0uh7dR", + "type": "spotlight", + "elementId": "text_-JJPa8Ub" + }, + { + "id": "action_OPN6TQoi", + "type": "speech", + "text": "函数可以接收参数——也就是从外部传入的数据,处理后再通过 return 返回结果。同时要注意作用域:函数内部定义的变量是局部变量,只在函数内有效,外面访问不到。" + }, + { + "id": "action_bpdBEwmc", + "type": "spotlight", + "elementId": "text_KxU01Iev" + }, + { + "id": "action_nUSD9QmZ", + "type": "speech", + "text": "请看这个求平方的示例:def square(x) 定义了一个函数,参数是 x,返回 x 的平方。调用 square(5) 就得到 25。通过这个例子,大家能更直观地理解函数的定义、参数和返回值。" + }, + { + "id": "action_JJXATX_Z", + "type": "speech", + "text": "课后请大家自己动手写几个简单的函数,比如计算两个数的和,体会一下代码复用的好处。" + } + ], + "outlineId": "scene_8", + "createdAt": 1786856057496, + "updatedAt": 1786856057496, + "type": "slide", + "content": { + "type": "slide", + "canvas": { + "id": "PyQVKF3tY62AXNbcCg7rq", + "viewportSize": 1000, + "viewportRatio": 0.5625, + "theme": { + "backgroundColor": "#ffffff", + "themeColors": [ + "#5b9bd5", + "#ed7d31", + "#a5a5a5", + "#ffc000", + "#4472c4" + ], + "fontColor": "#333333", + "fontName": "Microsoft YaHei", + "outline": { + "color": "#d14424", + "width": 2, + "style": "solid" + }, + "shadow": { + "h": 0, + "v": 0, + "blur": 10, + "color": "#000000" + } + }, + "elements": [ + { + "id": "text_V3a1Dc9e", + "type": "text", + "left": 60, + "top": 50, + "width": 880, + "height": 76, + "content": "

函数:复用代码

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#1f2937", + "rotate": 0 + }, + { + "id": "shape_2CLvMapY", + "type": "shape", + "left": 70, + "top": 136, + "width": 860, + "height": 3, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#5b9bd5", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_yjI-sq_S", + "type": "shape", + "left": 60, + "top": 155, + "width": 280, + "height": 140, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#dbeafe", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_dO0bhqbn", + "type": "shape", + "left": 360, + "top": 155, + "width": 280, + "height": 140, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#dcfce7", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_G84O_a8X", + "type": "shape", + "left": 660, + "top": 155, + "width": 280, + "height": 140, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#fef3c7", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_-iZv1pDj", + "type": "text", + "left": 80, + "top": 187, + "width": 240, + "height": 76, + "content": "

def 定义函数

用 def 关键字创建函数

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#1e40af", + "rotate": 0 + }, + { + "id": "text_-JJPa8Ub", + "type": "text", + "left": 380, + "top": 187, + "width": 240, + "height": 76, + "content": "

参数与返回值

传入数据 → 返回结果

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#166534", + "rotate": 0 + }, + { + "id": "text_u4PbtfBp", + "type": "text", + "left": 680, + "top": 187, + "width": 240, + "height": 76, + "content": "

作用域基本概念

局部变量仅在函数内有效

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#92400e", + "rotate": 0 + }, + { + "id": "shape_ri_xpXgH", + "type": "shape", + "left": 60, + "top": 310, + "width": 880, + "height": 195, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#1e293b", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "shape_t08kw2Ez", + "type": "shape", + "left": 60, + "top": 310, + "width": 6, + "height": 195, + "path": "M 0 0 L 1 0 L 1 1 L 0 1 Z", + "viewBox": [ + 1, + 1 + ], + "fill": "#f59e0b", + "fixedRatio": false, + "rotate": 0 + }, + { + "id": "text_ep8lV1v0", + "type": "text", + "left": 90, + "top": 320, + "width": 830, + "height": 49, + "content": "

示例:编写求平方函数

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#f8fafc", + "rotate": 0 + }, + { + "id": "text_KxU01Iev", + "type": "text", + "left": 90, + "top": 373, + "width": 830, + "height": 118, + "content": "

def square(x):        # 定义函数

    return x * x      # 返回平方值

result = square(5)    # 调用函数

print(result)         # 输出 25

", + "defaultFontName": "Microsoft YaHei", + "defaultColor": "#e2e8f0", + "rotate": 0 + } + ], + "background": { + "type": "solid", + "color": "#ffffff" + } + } + } + }, + { + "id": "scene_zFQYy-p9ru", + "stageId": "DZynj92UuU", + "title": "函数调试练习", + "order": 9, + "actions": [ + { + "id": "action_I8ABXVys", + "type": "speech", + "text": "同学们,今天我们来练习函数调试。先看左侧的代码编辑器,里面有一个带参数的函数。" + }, + { + "id": "action_qrGRKhvS", + "type": "widget_highlight", + "target": "#editor-card", + "content": "代码编辑区:函数定义和调用都在这里。" + }, + { + "id": "action_UANequfS", + "type": "speech", + "text": "注意函数定义中的参数,它决定了调用时我们可以传入哪些值。运行后 print 会打印出对应的结果。" + }, + { + "id": "action_lI1aWORq", + "type": "widget_highlight", + "target": "#run-btn", + "content": "点击运行按钮,查看当前代码的输出。" + }, + { + "id": "action_-1d5g6cx", + "type": "speech", + "text": "先运行一次看看返回值。然后试着修改参数,比如换一个数字,再运行一次,观察输出有什么不同。" + }, + { + "id": "action__b8EHgCt", + "type": "widget_annotation", + "target": "#output-card", + "content": "输出区域:运行后这里会显示结果,修改参数后重点对比变化。" + }, + { + "id": "action_pyEJb1ee", + "type": "speech", + "text": "如果卡住了可以查看提示或参考代码,但先自己动手改一改,调试的过程更有收获。完成后我们来总结函数参数的作用。" + } + ], + "outlineId": "scene_9", + "createdAt": 1786856200904, + "updatedAt": 1786856200904, + "type": "interactive", + "content": { + "type": "interactive", + "url": "", + "html": "\n\n\n \n \n 函数调试练习 - Python 代码练习\n\n \n \n \n \n\n \n \n\n \n\n\n\n\n\n\n\n
\n \n
\n

🐍 函数调试练习

\n

通过交互式代码练习修改函数参数并观察返回值。亲手尝试、观察变化、理解函数!

\n
\n 📝 定义带参数的函数\n 🔧 调用函数并打印结果\n 🔍 修改参数观察输出变化\n
\n
\n\n \n
\n \n
\n
\n \n \n \n \n \n Python 环境加载中...\n \n
\n
\n
\n\n \n
\n
📤 输出结果
\n
点击「运行代码」查看输出...
\n
\n\n \n
\n
\n 🧪 测试用例\n \n
\n
\n
\n \n 运行代码后自动验证\n
\n
\n
\n\n \n
\n

💡 学习提示

\n
\n
\n\n \n
\n

✅ 参考解答

\n
\n            
\n
\n
\n\n \n\n", + "widgetType": "code" + } + }, + { + "id": "scene_nyqpYDSNXz", + "stageId": "DZynj92UuU", + "title": "课程总结测验", + "order": 10, + "actions": [ + { + "id": "action_Hgob6KaW", + "type": "speech", + "text": "现在来做课程总结小测验,看看这节课的内容掌握得怎么样。请独立尝试每一道题,提交后我会和你一起逐题回顾分析。" + } + ], + "outlineId": "scene_10", + "createdAt": 1786856253251, + "updatedAt": 1786856253251, + "type": "quiz", + "content": { + "type": "quiz", + "questions": [ + { + "id": "q1", + "type": "single", + "question": "在Python中,以下哪个数据类型最适合用来存储一个学生的姓名和对应的年龄信息?", + "options": [ + { + "value": "A", + "label": "列表(list)" + }, + { + "value": "B", + "label": "元组(tuple)" + }, + { + "value": "C", + "label": "字典(dict)" + }, + { + "value": "D", + "label": "集合(set)" + } + ], + "answer": [ + "C" + ], + "analysis": "字典(dict)以键值对形式存储数据,适合存储姓名和年龄这种一一对应的信息。列表和元组只能按顺序存储多个值,集合不能存储键值对。", + "points": 10, + "hasAnswer": true + }, + { + "id": "q2", + "type": "single", + "question": "运行以下Python代码后,屏幕上会依次显示哪些数字?\nfor i in range(1, 4):\n print(i)", + "options": [ + { + "value": "A", + "label": "1 2 3" + }, + { + "value": "B", + "label": "1 2 3 4" + }, + { + "value": "C", + "label": "0 1 2" + }, + { + "value": "D", + "label": "2 3" + } + ], + "answer": [ + "A" + ], + "analysis": "range(1, 4)从1开始到4之前结束,生成1、2、3。循环依次打印每个数字,所以屏幕上依次显示1、2、3。", + "points": 10, + "hasAnswer": true + }, + { + "id": "q3", + "type": "multiple", + "question": "关于Python中的列表(list)和字典(dict),以下哪些说法是正确的?(多选)", + "options": [ + { + "value": "A", + "label": "列表是有序的,可以通过索引访问元素" + }, + { + "value": "B", + "label": "字典中的键必须是唯一的" + }, + { + "value": "C", + "label": "列表只能存储数字类型的数据" + }, + { + "value": "D", + "label": "字典的元素可以通过append()方法添加" + } + ], + "answer": [ + "A", + "B" + ], + "analysis": "A正确:列表是有序的,支持索引访问。B正确:字典的键必须唯一,否则后面的会覆盖前面的。C错误:列表可以存储任意类型的数据。D错误:append()是列表的方法,字典添加键值对通常使用赋值或update()方法。", + "points": 15, + "hasAnswer": true + }, + { + "id": "q4", + "type": "single", + "question": "在Python中,定义函数时需要使用哪个关键字?", + "options": [ + { + "value": "A", + "label": "def" + }, + { + "value": "B", + "label": "function" + }, + { + "value": "C", + "label": "define" + }, + { + "value": "D", + "label": "fun" + } + ], + "answer": [ + "A" + ], + "analysis": "Python中定义函数使用def关键字,后面跟函数名和参数列表。function、define、fun都不是Python定义函数的关键字。", + "points": 10, + "hasAnswer": true + }, + { + "id": "q5", + "type": "short_answer", + "question": "请用中文解释什么是函数(function),并说明在程序中使用函数的好处。最后请写出一个简单的Python函数示例(可以只写代码,也可以加上解释)。", + "commentPrompt": "评分标准:(1)正确解释函数的概念:函数是一段可重复使用的代码块,用于完成特定任务,占40%;(2)至少说出两个使用函数的好处,如复用代码、模块化、易于维护、减少重复等,占30%;(3)给出一个正确的Python函数定义示例(包含def关键字、函数名、括号、冒号、函数体),占30%。如果学生没有写出代码,但前两部分正确,可给至多70%的分。", + "analysis": "参考答案:函数是一段有名字的、可重复调用的代码块,用于完成特定任务。好处包括:避免代码重复,提高代码复用性;使程序结构清晰,易于理解和维护;便于调试和修改。示例:def greet(name):\n print('你好, ' + name)\n调用greet('小明')会输出'你好, 小明'。", + "points": 20, + "hasAnswer": false + } + ] + } + } + ], + "createdAt": "2026-08-16T04:57:33.260Z" +} diff --git a/OpenMAIC/lib/agent/client/use-agent-runtime.ts b/OpenMAIC/lib/agent/client/use-agent-runtime.ts index ee89351..1a12230 100644 --- a/OpenMAIC/lib/agent/client/use-agent-runtime.ts +++ b/OpenMAIC/lib/agent/client/use-agent-runtime.ts @@ -25,7 +25,7 @@ import type { AgentEvent } from '@earendil-works/pi-agent-core'; import { flushStageSave, useStageStore } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { getCurrentModelConfig } from '@/lib/utils/model-config'; -import type { SceneContextMap } from '@/app/api/agent/edit/route'; +import type { SceneContextMap } from '@/lib/agent/runtime/types'; import { mergeAssistantParts, type PiPart } from './merge-assistant-parts'; import { resolveSceneOutline } from './resolve-scene-outline'; import { planRegenerateApply, type RegenerateDetails } from './apply-regenerate'; diff --git a/OpenMAIC/lib/agent/runtime/types.ts b/OpenMAIC/lib/agent/runtime/types.ts new file mode 100644 index 0000000..032eb26 --- /dev/null +++ b/OpenMAIC/lib/agent/runtime/types.ts @@ -0,0 +1,4 @@ +import type { SceneContext } from '@/lib/agent/tools/regenerate-scene-actions'; + +/** Scene/stage context keyed by scene id for one editor Agent turn. */ +export type SceneContextMap = Record; diff --git a/OpenMAIC/lib/bundle/packager.ts b/OpenMAIC/lib/bundle/packager.ts index a9e731f..8778773 100644 --- a/OpenMAIC/lib/bundle/packager.ts +++ b/OpenMAIC/lib/bundle/packager.ts @@ -62,6 +62,8 @@ export interface PackageSources { appVersion?: string; /** Fail the whole publish on any missing resource. Default false (report only). */ requireComplete?: boolean; + /** Require generated bytes for narration scripts. Disable only when TTS was intentionally off. */ + requireNarrationAudio?: boolean; /** Require at least one portable agent persona snapshot. */ requireAgentRoster?: boolean; /** Require at least one interactive Scene with generated, non-empty HTML. */ @@ -147,6 +149,22 @@ function missing( return { kind, ref, reason, ...(sceneId ? { sceneId } : {}) }; } +function residualExecutableNetworkDependencies(html: string): string[] { + const dependencies = new Set(); + for (const match of html.matchAll(/]*>([\s\S]*?)<\/script>/gi)) { + const script = match[1] ?? ''; + for (const url of script.match(/https?:\/\/[^\s'"`<>\\)]+/gi) ?? []) { + dependencies.add(url); + } + // Inlining pyodide.js does not inline its wasm/stdlib downloads. Until the + // player ships a frozen local Pyodide distribution, treating loadPyodide + // as complete would create a package that predictably fails under the + // offline CSP (`connect-src 'none'`). + if (/\bloadPyodide\s*\(/.test(script)) dependencies.add('dynamic:loadPyodide'); + } + return [...dependencies]; +} + /** * Package a classroom into a frozen bundle. See module doc for policy. */ @@ -167,6 +185,7 @@ export async function packageCourseware(sources: PackageSources): Promise { const content = scene.content; if ( @@ -279,10 +302,35 @@ export async function packageCourseware(sources: PackageSources): Promise { + if (requireNarrationAudio || !scene.actions?.length) return scene; + const actions = scene.actions.map((action) => { + if (action.type !== 'speech' || (action.audioId && audioIdToPath.has(action.audioId))) { + return action; + } + // A deliberately silent bundle must not retain a remote audio URL. The + // player will use its narration-duration fallback and never reach the + // network for a resource the operator explicitly chose not to generate. + const { audioUrl: _audioUrl, ...silentSpeech } = action; + return silentSpeech; + }); + return { ...scene, actions } as Scene; + }); for (const failure of inlineReport.failed) { missingResources.push( missing('interactive', failure.url, 'interactive asset could not be inlined'), @@ -315,14 +363,16 @@ export async function packageCourseware(sources: PackageSources): Promise 0, + media: collectedMedia.length > 0, + interactiveHtml: preparedScenes.some((scene) => scene.content?.type === 'interactive'), + pblTemplate: preparedScenes.some((scene) => scene.content?.type === 'pbl'), + objectiveQuizGrading: preparedScenes.some((scene) => scene.content?.type === 'quiz'), + }, + hostBridges: { + agent: 'online-optional', + asr: 'online-optional', + pblEvaluation: 'online-optional', + subjectiveQuizGrading: 'online-optional', + }, + }, }; zip.file(FROZEN_BUNDLE_MANIFEST_FILE, JSON.stringify({ meta, completeness }, null, 2)); diff --git a/OpenMAIC/lib/bundle/types.ts b/OpenMAIC/lib/bundle/types.ts index 43fc304..d9ada15 100644 --- a/OpenMAIC/lib/bundle/types.ts +++ b/OpenMAIC/lib/bundle/types.ts @@ -101,6 +101,29 @@ export interface FrozenBundleMeta { /** Hash algorithm marker. Missing means legacy v1. */ contentHashVersion?: number; knowledgeVersion: number; + /** Explicit offline/host boundary consumed by Makelore's capability bridge. */ + runtimeCapabilities?: FrozenBundleRuntimeCapabilities; +} + +export interface FrozenBundleRuntimeCapabilities { + offlinePlayback: true; + htmlSandbox: { + externalNetwork: false; + hostBridgeOnly: true; + }; + packaged: { + audio: boolean; + media: boolean; + interactiveHtml: boolean; + pblTemplate: boolean; + objectiveQuizGrading: boolean; + }; + hostBridges: { + agent: 'online-optional'; + asr: 'online-optional'; + pblEvaluation: 'online-optional'; + subjectiveQuizGrading: 'online-optional'; + }; } /** Content of `bundle.json` inside the ZIP. */ diff --git a/OpenMAIC/lib/config/server-request-boundary.ts b/OpenMAIC/lib/config/server-request-boundary.ts index b3a9d75..e079f78 100644 --- a/OpenMAIC/lib/config/server-request-boundary.ts +++ b/OpenMAIC/lib/config/server-request-boundary.ts @@ -1,74 +1,31 @@ import type { DeploymentRole } from '@/lib/config/deployment-role'; -const SERVER_HIDDEN_API_ROOTS = [ - '/api/courses', - '/api/ops', - '/api/access-code', - // This browser-side CORS convenience proxy has no authenticated server-tier - // contract. Keeping it off the headless public API also closes DNS-rebinding - // and cloud-metadata SSRF paths until learner identity is introduced. - '/api/proxy-media', - '/api/usage', - '/api/provider/probe-models', - '/api/azure-voices', - '/api/verify-model', - '/api/verify-image-provider', - '/api/verify-video-provider', - '/api/verify-pdf-provider', - '/api/export-video', - // The current persistence token is a development convenience compiled into - // the client. It is not a tenant/owner boundary and must stay off L2. - '/api/persistence', -] as const; - export function isApiPath(pathname: string): boolean { return pathname === '/api' || pathname.startsWith('/api/'); } -function isPathFamily(pathname: string, root: string): boolean { - return pathname === root || pathname.startsWith(`${root}/`); -} - function withoutTrailingSlash(pathname: string): string { return pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname; } /** - * A server deployment is a headless data/API service. It deliberately hides - * every page plus APIs owned by the operations workbench. Other APIs continue - * to their route-level authentication until learner ownership is defined. + * The Learning Engine exposes one token-protected runtime surface. Original + * browser APIs remain callable internally by runtime route handlers, but are + * never reachable as unauthenticated ClusterIP endpoints. */ export function shouldReturnNotFoundAtServerBoundary( role: DeploymentRole, pathname: string, method = 'GET', ): boolean { - if (role !== 'server') { + if (role !== 'server') return false; + const normalized = withoutTrailingSlash(pathname); + if (!isApiPath(normalized)) return true; + if (normalized === '/api/runtime/v1' || normalized.startsWith('/api/runtime/v1/')) { return false; } - - const normalizedPathname = withoutTrailingSlash(pathname); - - if (!isApiPath(normalizedPathname)) { - return true; + if (normalized === '/api/internal/course-publish' && method.toUpperCase() === 'POST') { + return false; } - - if (SERVER_HIDDEN_API_ROOTS.some((root) => isPathFamily(normalizedPathname, root))) { - return true; - } - - const normalizedMethod = method.toUpperCase(); - if ( - normalizedPathname === '/api/generate-classroom' && - (normalizedMethod === 'GET' || normalizedMethod === 'HEAD') - ) { - // A global job list has no owner filter. Per-job and POST generation stay - // available until the learner capability contract is introduced. HEAD is - // GET-equivalent in Next.js and must not bypass this list boundary. - return true; - } - if (normalizedPathname === '/api/internal/course-publish' && normalizedMethod !== 'POST') { - return true; - } - return false; + return true; } diff --git a/OpenMAIC/lib/course-framework/runner.ts b/OpenMAIC/lib/course-framework/runner.ts index c62c532..c84bf03 100644 --- a/OpenMAIC/lib/course-framework/runner.ts +++ b/OpenMAIC/lib/course-framework/runner.ts @@ -47,6 +47,10 @@ import { generateCourseFramework, type FrameworkAICallFn } from './generate-fram import { buildModuleRequirement, emptyModuleRecord } from './types'; import { buildCourseModuleOutputDigest, formatPreviousModuleContext } from './module-digest'; import { acquireCourseGenerationActivity } from './publish-state'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; import type { CourseCreateInput, CourseModuleContinuityInputRef, @@ -119,7 +123,11 @@ function trackRun( try { await fn(controller.signal); } catch (error) { - if (isAbortError(error)) { + if (error instanceof FrozenLearningCourseMutationError) { + // An immutable-course preflight is not a failed generation attempt and + // must not mutate the already-frozen source record to `failed`. + throw error; + } else if (isAbortError(error)) { log.info(`Course run cancelled: ${courseId} (${key})`); await updateCourseRecord(courseId, { status: 'cancelled' }).catch(() => {}); } else { @@ -145,6 +153,7 @@ function trackRun( /** Phase 1: generate the course framework only. Ends `framework_ready`. */ export function runCourseFrameworkGeneration(courseId: string, baseUrl: string): Promise { return trackRun(courseId, courseId, async (signal) => { + await assertLearningCourseMutable(courseId); const record = await readCourseRecordReconciled(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); if (record.framework) return; // nothing to do — framework already exists @@ -158,6 +167,7 @@ export function runCourseFrameworkGeneration(courseId: string, baseUrl: string): */ export function runCourseModuleGeneration(courseId: string, baseUrl: string): Promise { return trackRun(`${courseId}:modules`, courseId, async (signal) => { + await assertLearningCourseMutable(courseId); await runModulePhase(courseId, baseUrl, { signal }); }); } @@ -171,6 +181,7 @@ export function runCourseGenerationJob( const index = options.onlyModuleIndex; if (index === undefined) throw new Error('runCourseGenerationJob requires onlyModuleIndex'); return trackRun(`${courseId}:module:${index}`, courseId, async (signal) => { + await assertLearningCourseMutable(courseId); await runModulePhase(courseId, baseUrl, { onlyModuleIndex: index, signal }); }); } @@ -178,6 +189,7 @@ export function runCourseGenerationJob( /** Re-run the framework phase from scratch (resets module state). */ export function regenerateCourseFramework(courseId: string, baseUrl: string): Promise { return trackRun(courseId, courseId, async (signal) => { + await assertLearningCourseMutable(courseId); const record = await readCourseRecordReconciled(courseId); if (!record) throw new Error(`Course not found: ${courseId}`); if (record.modules.some((m) => m.status === 'generating')) { @@ -187,6 +199,7 @@ export function regenerateCourseFramework(courseId: string, baseUrl: string): Pr framework: undefined, modules: [], publication: undefined, + externalPublication: undefined, }); await runFrameworkPhase(courseId, baseUrl, signal); }); @@ -195,7 +208,7 @@ export function regenerateCourseFramework(courseId: string, baseUrl: string): Pr // ─── Phase implementations ───────────────────────────────────────────────── /** Resolve the Layer-1 model + build the framework aiCall closure. */ -async function buildFrameworkAiCall(): Promise { +async function buildFrameworkAiCall(signal: AbortSignal): Promise { const { model: languageModel, modelInfo, @@ -221,6 +234,7 @@ async function buildFrameworkAiCall(): Promise { ], maxOutputTokens: modelInfo?.outputWindow ?? 8192, maxRetries: 0, + abortSignal: signal, }, 'course-framework', undefined, @@ -272,7 +286,7 @@ async function runFrameworkPhase( await updateCourseRecord(courseId, { status: 'framework_generating', error: undefined }); log.info(`Course ${courseId}: generating framework (Layer 1)`); - const aiCall = await buildFrameworkAiCall(); + const aiCall = await buildFrameworkAiCall(signal); const researchContext = await runFrameworkResearch(record, aiCall); if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); @@ -286,6 +300,8 @@ async function runFrameworkPhase( aiCall, ); + if (signal.aborted) throw new DOMException('Course generation cancelled', 'AbortError'); + if (!result.success || !result.data) { throw new Error(`课程框架生成失败:${result.error ?? 'unknown'}`); } @@ -441,14 +457,15 @@ async function generateModule( ...(record.enableWebSearch ? { enableWebSearch: true } : {}), ...(record.enableImageGeneration ? { enableImageGeneration: true } : {}), ...(record.enableVideoGeneration ? { enableVideoGeneration: true } : {}), - // Published large-course modules are frozen bundles: every narration cue - // must have durable audio bytes before the publish gate can pass. - enableTTS: true, - interactiveMode: true, + enableTTS: record.enableTTS ?? true, + interactiveMode: record.interactiveMode ?? true, + ...(record.taskEngineMode ? { taskEngineMode: true } : {}), }; const jobId = nanoid(10); - await createClassroomGenerationJob(jobId, input); + await createClassroomGenerationJob(jobId, input, { + ownerPrincipalId: record.ownerPrincipalId, + }); const jobs = moduleJobsByCourse.get(courseId) ?? new Set(); jobs.add(jobId); moduleJobsByCourse.set(courseId, jobs); @@ -527,12 +544,19 @@ async function finalizeCourse(courseId: string, signal: AbortSignal): Promise { +export async function createCourse( + courseId: string, + input: CourseCreateInput, + ownership?: { ownerPrincipalId?: string }, +): Promise { await createCourseRecord(courseId, input.requirement, { + ownerPrincipalId: ownership?.ownerPrincipalId, enableWebSearch: input.enableWebSearch, enableImageGeneration: input.enableImageGeneration, enableVideoGeneration: input.enableVideoGeneration, - enableTTS: true, + enableTTS: input.enableTTS ?? true, + interactiveMode: input.interactiveMode, + taskEngineMode: input.taskEngineMode, pdfText: input.pdfContent?.text, }); } diff --git a/OpenMAIC/lib/course-framework/store.ts b/OpenMAIC/lib/course-framework/store.ts index 3ffc88f..ca92c6c 100644 --- a/OpenMAIC/lib/course-framework/store.ts +++ b/OpenMAIC/lib/course-framework/store.ts @@ -17,7 +17,8 @@ import { buildCourseModuleOutputDigest } from './module-digest'; const log = createLogger('CourseStore'); export const COURSE_FRAMEWORK_DIR = - process.env.COURSE_FRAMEWORK_DIR ?? path.join(process.cwd(), 'data', 'course-frameworks'); + process.env.COURSE_FRAMEWORK_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'course-frameworks'); export function isValidCourseId(id: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(id); @@ -146,10 +147,13 @@ export async function createCourseRecord( courseId: string, requirement: string, options: { + ownerPrincipalId?: string; enableWebSearch?: boolean; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; + interactiveMode?: boolean; + taskEngineMode?: boolean; pdfText?: string; } = {}, ): Promise { @@ -159,10 +163,15 @@ export async function createCourseRecord( status: 'queued', requirement, modules: [], + ...(options.ownerPrincipalId ? { ownerPrincipalId: options.ownerPrincipalId } : {}), ...(options.enableWebSearch ? { enableWebSearch: true } : {}), ...(options.enableImageGeneration ? { enableImageGeneration: true } : {}), ...(options.enableVideoGeneration ? { enableVideoGeneration: true } : {}), - ...(options.enableTTS ? { enableTTS: true } : {}), + // Persist the explicit false value: finalization uses it to decide whether + // narration bytes are mandatory or intentionally omitted. + enableTTS: options.enableTTS ?? true, + interactiveMode: options.interactiveMode ?? true, + ...(options.taskEngineMode ? { taskEngineMode: true } : {}), ...(options.pdfText ? { pdfText: options.pdfText } : {}), createdAt: now, updatedAt: now, @@ -306,6 +315,7 @@ export async function invalidateCourseModulesFrom( ...existing, modules, publication: undefined, + externalPublication: undefined, status: 'generating', error: undefined, updatedAt: new Date().toISOString(), diff --git a/OpenMAIC/lib/course-framework/types.ts b/OpenMAIC/lib/course-framework/types.ts index a79ed65..7f8aef3 100644 --- a/OpenMAIC/lib/course-framework/types.ts +++ b/OpenMAIC/lib/course-framework/types.ts @@ -157,6 +157,8 @@ export interface CoursePublicationReceipt { export interface CourseRecord { id: string; status: CourseStatus; + /** Works account that owns runtime-created courses; absent on legacy ops records. */ + ownerPrincipalId?: string; /** Original requirement — persisted so unfinished modules can be re-run. */ requirement: string; /** Course-level failure reason (status === 'failed'). */ @@ -167,11 +169,20 @@ export interface CourseRecord { modules: CourseModuleRecord[]; /** Exact remote/local publish result; private to ops and invalidated on regeneration. */ publication?: CoursePublicationReceipt; + /** Works catalog receipt for the aggregate package (new Learning Ops path). */ + externalPublication?: { + courseId: string; + status: 'ready' | 'published'; + publishedAt: string; + }; /** Ops flags forwarded to each module's classroom generation. */ enableWebSearch?: boolean; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; + /** Module generation affordances forwarded to the original classroom runner. */ + interactiveMode?: boolean; + taskEngineMode?: boolean; /** PDF text used as research context by the Layer-1 framework agent. */ pdfText?: string; createdAt: string; @@ -185,6 +196,8 @@ export interface CourseCreateInput { enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; + interactiveMode?: boolean; + taskEngineMode?: boolean; pdfContent?: { text: string; images: string[] }; } diff --git a/OpenMAIC/lib/course-manifest-repo/store.ts b/OpenMAIC/lib/course-manifest-repo/store.ts index 0d04767..6801ab5 100644 --- a/OpenMAIC/lib/course-manifest-repo/store.ts +++ b/OpenMAIC/lib/course-manifest-repo/store.ts @@ -9,7 +9,8 @@ import path from 'path'; import type { CourseManifestRecord, CourseManifestRepo } from './types'; export const COURSE_MANIFEST_DIR = - process.env.COURSE_MANIFEST_DIR ?? path.join(process.cwd(), 'data', 'course-manifests'); + process.env.COURSE_MANIFEST_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'course-manifests'); export function isValidCourseManifestId(courseId: unknown): courseId is string { return typeof courseId === 'string' && /^[a-zA-Z0-9_-]+$/.test(courseId); diff --git a/OpenMAIC/lib/courseware-repo/bundle-store.ts b/OpenMAIC/lib/courseware-repo/bundle-store.ts index 3e1fb39..f956b12 100644 --- a/OpenMAIC/lib/courseware-repo/bundle-store.ts +++ b/OpenMAIC/lib/courseware-repo/bundle-store.ts @@ -10,7 +10,8 @@ import path from 'path'; import { assertCoursewareId, assertCoursewareVersion, resolveDirectChildPath } from './identity'; export const COURSEWARE_BUNDLES_DIR = - process.env.COURSEWARE_BUNDLE_DIR ?? path.join(process.cwd(), 'data', 'courseware-bundles'); + process.env.COURSEWARE_BUNDLE_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'courseware-bundles'); export interface BundleByteStore { save(coursewareId: string, version: number, bytes: Uint8Array): Promise; diff --git a/OpenMAIC/lib/courseware-repo/store.ts b/OpenMAIC/lib/courseware-repo/store.ts index 33daa0d..72a1a29 100644 --- a/OpenMAIC/lib/courseware-repo/store.ts +++ b/OpenMAIC/lib/courseware-repo/store.ts @@ -12,7 +12,8 @@ import { assertCoursewareId, assertCoursewareVersion, resolveDirectChildPath } f import { writeJsonFileAtomic } from '@/lib/server/classroom-storage'; export const COURSEWARES_DIR = - process.env.COURSEWARE_DATA_DIR ?? path.join(process.cwd(), 'data', 'coursewares'); + process.env.COURSEWARE_DATA_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'coursewares'); function recordsPath(dir: string, coursewareId: string): string { assertCoursewareId(coursewareId); diff --git a/OpenMAIC/lib/hooks/use-asr-available.ts b/OpenMAIC/lib/hooks/use-asr-available.ts index fe25663..33df74c 100644 --- a/OpenMAIC/lib/hooks/use-asr-available.ts +++ b/OpenMAIC/lib/hooks/use-asr-available.ts @@ -46,5 +46,6 @@ export function useASRAvailable(): boolean { const modelOk = !!builtIn || (providerConfig?.customModels?.length ?? 0) > 0; const browserOk = asrProviderId !== 'browser-native' || browserSpeechSupported; + if (typeof window !== 'undefined' && window.__MAKELORE_COURSE_CONTEXT__) return true; return asrEnabled && keyOk && modelOk && browserOk; } diff --git a/OpenMAIC/lib/hooks/use-audio-recorder.ts b/OpenMAIC/lib/hooks/use-audio-recorder.ts index e1001d1..be36317 100644 --- a/OpenMAIC/lib/hooks/use-audio-recorder.ts +++ b/OpenMAIC/lib/hooks/use-audio-recorder.ts @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react'; import { ASR_PROVIDERS } from '@/lib/audio/constants'; import { normalizeASRUploadAudio } from '@/lib/audio/wav-utils'; import { createLogger } from '@/lib/logger'; +import { transcribeMakeloreLearningAudio } from '@/lib/makelore-runtime/browser-bridge'; const log = createLogger('AudioRecorder'); @@ -45,6 +46,14 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { const { useSettingsStore } = await import('@/lib/store/settings'); const { asrProviderId, asrLanguage, asrProvidersConfig } = useSettingsStore.getState(); const uploadAudio = await normalizeASRUploadAudio(asrProviderId, audioBlob); + if (window.__MAKELORE_COURSE_CONTEXT__) { + const text = await transcribeMakeloreLearningAudio(uploadAudio.blob, { + fileName: uploadAudio.fileName, + language: asrLanguage, + }); + onTranscription?.(text); + return; + } formData.append('audio', uploadAudio.blob, uploadAudio.fileName); formData.append('providerId', asrProviderId); @@ -105,7 +114,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { const { asrProviderId, asrLanguage } = useSettingsStore.getState(); // Use browser native ASR if configured - if (asrProviderId === 'browser-native') { + if (asrProviderId === 'browser-native' && !window.__MAKELORE_COURSE_CONTEXT__) { // Check if Speech Recognition is supported if (!window.SpeechRecognition && !window.webkitSpeechRecognition) { onError?.('您的浏览器不支持语音识别功能'); diff --git a/OpenMAIC/lib/hooks/use-scene-generator.ts b/OpenMAIC/lib/hooks/use-scene-generator.ts index 0ceca8f..9c79bf3 100644 --- a/OpenMAIC/lib/hooks/use-scene-generator.ts +++ b/OpenMAIC/lib/hooks/use-scene-generator.ts @@ -31,7 +31,7 @@ import { isAbortError, withGenerationRetry, type GenerationRetryOptions, -} from '@openmaic/generation'; +} from '@openmaic/generation/generation-retry'; const log = createLogger('SceneGenerator'); diff --git a/OpenMAIC/lib/makelore-course/finalize.ts b/OpenMAIC/lib/makelore-course/finalize.ts new file mode 100644 index 0000000..018af19 --- /dev/null +++ b/OpenMAIC/lib/makelore-course/finalize.ts @@ -0,0 +1,411 @@ +import { createHash, createHmac } from 'node:crypto'; +import JSZip from 'jszip'; +import type { CourseRecord } from '@/lib/course-framework/types'; +import { readCourseRecord } from '@/lib/course-framework/store'; +import { runCoursePublishExclusive } from '@/lib/course-framework/publish-state'; +import { FrozenLearningCourseMutationError } from '@/lib/makelore-course/immutability'; +import { + persistFrozenLearningPackage, + readFrozenLearningPackageRecord, + readUniqueMakeloreCoursePackage, + type FrozenLearningPackageRecord, +} from '@/lib/makelore-course/package'; +import { packagePersistedClassroom } from '@/lib/server/classroom-courseware-publish'; +import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage'; + +const finalizations = new Map>(); + +function sha256(bytes: Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function canonicalJson(_key: string, value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + return Object.fromEntries( + Object.entries(value as Record).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); +} + +function stableSourceDigest(value: unknown): string { + const serialized = JSON.stringify(value, canonicalJson); + if (serialized === undefined) throw new Error('Learning course source cannot be serialized'); + return sha256(serialized); +} + +function assertSafeBundleEntry(name: string): void { + if ( + !name || + name.startsWith('/') || + name.includes('\\') || + name.includes('\0') || + name.split('/').some((part) => part === '.' || part === '..') + ) { + throw new Error(`Unsafe frozen bundle entry: ${name}`); + } +} + +function archiveSignature(bytes: Uint8Array): Record | undefined { + const key = process.env.LEARNING_PACKAGE_SIGNING_KEY; + if (!key) return undefined; + return { + algorithm: 'hmac-sha256', + keyId: process.env.LEARNING_PACKAGE_SIGNING_KEY_ID?.trim() || 'default', + value: createHmac('sha256', key).update(bytes).digest('base64url'), + }; +} + +function sceneKinds(classroom: PersistedClassroomData) { + return [...new Set(classroom.scenes.map((scene) => scene.type))].filter((kind) => + ['slide', 'interactive', 'quiz', 'pbl'].includes(kind), + ); +} + +function classroomCapabilities( + classroom: PersistedClassroomData, + runtimeCapabilities: unknown, +): Record { + return { + sceneKinds: sceneKinds(classroom), + hasAudio: classroom.scenes.some((scene) => + scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)), + ), + hasWhiteboard: Boolean( + classroom.stage.whiteboard?.length || + classroom.scenes.some((scene) => Boolean(scene.whiteboards?.length)), + ), + hasAgent: Boolean(classroom.stage.generatedAgentConfigs?.length), + runtime: runtimeCapabilities, + }; +} + +async function existingFinalization( + courseId: string, + sourceJobId: string, + sourceDigest: string, + mode: FrozenLearningPackageRecord['mode'], +) { + const record = await readFrozenLearningPackageRecord(courseId); + if (!record) return null; + const bytes = await readUniqueMakeloreCoursePackage(courseId); + if ( + !bytes || + bytes.byteLength !== record.archiveBytes || + sha256(bytes) !== record.archiveSha256 || + record.sourceJobId !== sourceJobId || + record.mode !== mode || + record.sourceDigest !== sourceDigest + ) { + throw new FrozenLearningCourseMutationError(courseId); + } + return record; +} + +async function finalizeSerially( + key: string, + factory: () => Promise, +): Promise { + // Different source revisions of the same course must never share an + // in-flight promise. Serialize them, then re-evaluate the persisted cache + // against each call's sourceDigest inside the factory. + const previous = finalizations.get(key); + const ready = + previous?.then( + () => undefined, + () => undefined, + ) ?? Promise.resolve(); + const promise = ready.then(factory); + finalizations.set(key, promise); + const cleanup = () => { + if (finalizations.get(key) === promise) finalizations.delete(key); + }; + void promise.then(cleanup, cleanup); + return promise; +} + +export async function finalizeSingleLearningCourse(input: { + jobId: string; + classroom: PersistedClassroomData; + baseUrl: string; + requireInteractiveHtml: boolean; + requireNarrationAudio: boolean; +}): Promise { + return finalizeSerially(`single:${input.classroom.id}`, async () => { + const sourceDigest = stableSourceDigest({ + sourceDigestVersion: 1, + mode: 'single', + courseId: input.classroom.id, + classroom: { + stage: input.classroom.stage, + scenes: input.classroom.scenes, + }, + requireInteractiveHtml: input.requireInteractiveHtml, + requireNarrationAudio: input.requireNarrationAudio, + }); + const existing = await existingFinalization( + input.classroom.id, + input.jobId, + sourceDigest, + 'single', + ); + if (existing) return existing; + const packaged = await packagePersistedClassroom(input.classroom, { + coursewareId: input.classroom.id, + version: 1, + baseUrl: input.baseUrl, + requireInteractiveHtml: input.requireInteractiveHtml, + requireNarrationAudio: input.requireNarrationAudio, + }); + const bytes = new Uint8Array(await packaged.zip.arrayBuffer()); + const signature = archiveSignature(bytes); + const record: FrozenLearningPackageRecord = { + // Schema v1 keeps the existing runtime Q&A record reader compatible; + // mode and exact frozen-archive metadata are additive. + schemaVersion: 1, + courseId: input.classroom.id, + mode: 'single', + sourceJobId: input.jobId, + sourceDigest, + contentHash: packaged.contentHash, + archiveSha256: sha256(bytes), + archiveBytes: bytes.byteLength, + formatVersion: packaged.meta.formatVersion, + minPlayerVersion: '2.0.0', + title: input.classroom.stage.name, + language: input.classroom.stage.languageDirective, + sceneCount: input.classroom.scenes.length, + moduleCount: 1, + capabilities: { + ...classroomCapabilities(input.classroom, packaged.meta.runtimeCapabilities), + ...(signature ? { archiveSignature: signature } : {}), + }, + classroom: { stage: input.classroom.stage, scenes: input.classroom.scenes }, + createdAt: packaged.meta.publishedAt, + }; + await persistFrozenLearningPackage(record, bytes); + return record; + }); +} + +export async function finalizeLargeLearningCourse(input: { + jobId: string; + course: CourseRecord; + baseUrl: string; +}): Promise { + return finalizeSerially(`large:${input.course.id}`, () => + runCoursePublishExclusive(input.course.id, async () => { + // The persisted record is authoritative in production. The fallback keeps + // the pure finalizer usable for isolated packaging tests. + const course = (await readCourseRecord(input.course.id)) ?? input.course; + if (course.status !== 'completed' || !course.framework || course.modules.length === 0) { + throw new Error('Large course is not complete'); + } + + const moduleSources = [] as Array<{ + moduleRecord: CourseRecord['modules'][number]; + classroom: PersistedClassroomData; + }>; + for (const moduleRecord of [...course.modules].sort((a, b) => a.index - b.index)) { + if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) { + throw new Error(`Large course module ${moduleRecord.index} is not complete`); + } + const classroom = await readClassroom(moduleRecord.classroomId); + if (!classroom) throw new Error(`Classroom ${moduleRecord.classroomId} is unavailable`); + moduleSources.push({ moduleRecord, classroom }); + } + const sourceDigest = stableSourceDigest({ + sourceDigestVersion: 1, + mode: 'large', + courseId: course.id, + framework: course.framework, + requireInteractiveHtml: course.interactiveMode ?? true, + requireNarrationAudio: course.enableTTS ?? true, + modules: moduleSources.map(({ moduleRecord, classroom }) => ({ + index: moduleRecord.index, + title: moduleRecord.title, + description: moduleRecord.description, + classroomId: moduleRecord.classroomId, + classroom: { stage: classroom.stage, scenes: classroom.scenes }, + })), + }); + const existing = await existingFinalization(course.id, input.jobId, sourceDigest, 'large'); + if (existing) return existing; + + const modulePackages = [] as Array<{ + index: number; + title: string; + description: string; + coursewareId: string; + classroomId: string; + contentHash: string; + archiveSha256: string; + archiveBytes: number; + archivePath: string; + sceneCount: number; + formatVersion: number; + bytes: Uint8Array; + capabilities: unknown; + }>; + for (const { moduleRecord, classroom } of moduleSources) { + const coursewareId = `${course.id}_m${moduleRecord.index}`; + const packaged = await packagePersistedClassroom(classroom, { + coursewareId, + version: 1, + baseUrl: input.baseUrl, + requireInteractiveHtml: course.interactiveMode ?? true, + requireNarrationAudio: course.enableTTS ?? true, + }); + const bytes = new Uint8Array(await packaged.zip.arrayBuffer()); + modulePackages.push({ + index: moduleRecord.index, + title: moduleRecord.title, + description: moduleRecord.description, + coursewareId, + classroomId: classroom.id, + contentHash: packaged.contentHash, + archiveSha256: sha256(bytes), + archiveBytes: bytes.byteLength, + archivePath: `modules/${coursewareId}/`, + sceneCount: classroom.scenes.length, + formatVersion: packaged.meta.formatVersion, + bytes, + capabilities: packaged.meta.runtimeCapabilities, + }); + } + + const structureModules = modulePackages.map(({ bytes: _bytes, capabilities, ...module }) => ({ + ...module, + capabilities, + })); + const contentHash = sha256( + JSON.stringify([ + 'learning-course-aggregate-v1', + course.id, + structureModules.map((module) => [ + module.index, + module.coursewareId, + module.contentHash, + module.archiveSha256, + ]), + ]), + ); + const courseStructure = { + schemaVersion: 1, + kind: 'learning-course-aggregate', + courseId: course.id, + title: course.framework.courseTitle, + summary: course.framework.summary, + language: course.framework.languageDirective, + targetAudience: course.framework.targetAudience, + contentHash, + moduleCount: structureModules.length, + modules: structureModules, + runtimeBoundary: { + offline: [ + 'playback', + 'media', + 'audio', + 'interactive-html', + 'objective-quiz', + 'pbl-template', + ], + hostBridge: ['agent', 'asr', 'pbl-evaluation', 'subjective-quiz-grading'], + htmlNetworkAccess: false, + }, + }; + const courseDocument = { + schemaVersion: 1, + kind: 'learning-course-aggregate', + courseId: course.id, + title: course.framework.courseTitle, + summary: course.framework.summary, + language: course.framework.languageDirective, + targetAudience: course.framework.targetAudience, + contentHash, + moduleCount: structureModules.length, + modules: structureModules.map((module) => ({ + moduleId: module.coursewareId, + index: module.index, + title: module.title, + summary: module.description, + sceneCount: module.sceneCount, + contentHash: module.contentHash, + path: module.archivePath, + })), + runtimeBoundary: courseStructure.runtimeBoundary, + }; + const zip = new JSZip(); + zip.file('course.json', JSON.stringify(courseDocument, null, 2)); + for (const modulePackage of modulePackages) { + const source = await JSZip.loadAsync(modulePackage.bytes); + for (const required of [ + 'manifest.json', + 'bundle.json', + 'quiz/quiz.json', + 'knowledge/knowledge.json', + ]) { + if (!source.file(required)) { + throw new Error(`Module ${modulePackage.coursewareId} is missing ${required}`); + } + } + for (const [name, entry] of Object.entries(source.files)) { + if (entry.dir) continue; + assertSafeBundleEntry(name); + zip.file(`${modulePackage.archivePath}${name}`, await entry.async('uint8array'), { + binary: true, + }); + } + } + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const signature = archiveSignature(bytes); + const record: FrozenLearningPackageRecord = { + schemaVersion: 2, + courseId: course.id, + mode: 'large', + sourceJobId: input.jobId, + sourceDigest, + contentHash, + archiveSha256: sha256(bytes), + archiveBytes: bytes.byteLength, + formatVersion: 2, + // Aggregate module playback ships in the current Makelore 2.0.0 + // consumer; do not advertise a nonexistent 3.x compatibility floor. + minPlayerVersion: '2.0.0', + title: course.framework.courseTitle, + language: course.framework.languageDirective, + sceneCount: modulePackages.reduce((total, module) => total + module.sceneCount, 0), + moduleCount: modulePackages.length, + capabilities: { + modular: true, + orderedModules: true, + productionStagePerModule: true, + ...(signature ? { archiveSignature: signature } : {}), + }, + courseStructure, + createdAt: new Date().toISOString(), + }; + await persistFrozenLearningPackage(record, bytes); + return record; + }), + ); +} + +export function frozenLearningFinalizeResponse(record: FrozenLearningPackageRecord) { + return { + contractVersion: 2, + mode: record.mode, + courseId: record.courseId, + title: record.title, + language: record.language, + contentHash: record.contentHash, + archiveSha256: record.archiveSha256, + archiveBytes: record.archiveBytes, + formatVersion: record.formatVersion, + minPlayerVersion: record.minPlayerVersion, + sceneCount: record.sceneCount, + moduleCount: record.moduleCount, + capabilities: record.capabilities, + courseStructure: record.courseStructure, + }; +} diff --git a/OpenMAIC/lib/makelore-course/immutability.ts b/OpenMAIC/lib/makelore-course/immutability.ts new file mode 100644 index 0000000..2e1291e --- /dev/null +++ b/OpenMAIC/lib/makelore-course/immutability.ts @@ -0,0 +1,34 @@ +import type { CourseRecord } from '@/lib/course-framework/types'; +import { readCourseRecord } from '@/lib/course-framework/store'; +import { readFrozenLearningPackageRecord } from '@/lib/makelore-course/package'; + +type CoursePublicationState = Pick; + +/** A frozen course id is a one-shot immutable product identity. */ +export class FrozenLearningCourseMutationError extends Error { + constructor(public readonly courseId: string) { + super(`课程 ${courseId} 已冻结,不能继续生成或重新生成;如需修改,请新建课程`); + this.name = 'FrozenLearningCourseMutationError'; + } +} + +/** + * Enforce the single immutable-course rule at every source mutation boundary. + * + * The frozen record is authoritative: Works can successfully finalize an + * archive and then fail before writing the later external-publication receipt. + * Legacy publication receipts remain additional evidence for courses created + * before aggregate finalization was introduced. + */ +export async function assertLearningCourseMutable( + courseId: string, + knownRecord?: CoursePublicationState | null, +): Promise { + const [frozen, record] = await Promise.all([ + readFrozenLearningPackageRecord(courseId), + knownRecord === undefined ? readCourseRecord(courseId) : Promise.resolve(knownRecord), + ]); + if (frozen || record?.publication || record?.externalPublication) { + throw new FrozenLearningCourseMutationError(courseId); + } +} diff --git a/OpenMAIC/lib/makelore-course/package.ts b/OpenMAIC/lib/makelore-course/package.ts new file mode 100644 index 0000000..e158407 --- /dev/null +++ b/OpenMAIC/lib/makelore-course/package.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import JSZip from 'jszip'; +import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract'; +import { inlineHtmlAssets } from '@/lib/export/inline-assets'; +import type { Scene, Stage } from '@/lib/types/stage'; + +export type MakeloreCoursePackage = { + bytes: Uint8Array; + contentHash: string; + archiveSha256: string; + archiveBytes: number; + sceneCount: number; + title: string; + language?: string; + capabilities: { + sceneKinds: Array<'slide' | 'interactive' | 'quiz' | 'pbl'>; + hasAudio: boolean; + hasWhiteboard: boolean; + hasAgent: boolean; + }; + classroom: { stage: Stage; scenes: Scene[] }; +}; + +const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex'); + +export async function packageUniqueMakeloreCourse(input: { + courseId: string; + stage: Stage; + scenes: Scene[]; + fetchImpl?: typeof fetch; +}): Promise { + const scenes = await Promise.all( + input.scenes.map(async (scene) => { + if (scene.content.type !== 'interactive' || !scene.content.html) return scene; + const inlined = await inlineHtmlAssets(scene.content.html, { + fetchImpl: input.fetchImpl, + keepImportmapFallbacks: false, + }); + if (inlined.report.failed.length || inlined.unresolved.length) { + throw new Error(`Interactive scene ${scene.id} is not offline-complete`); + } + return { ...scene, content: { ...scene.content, html: inlined.html } } as Scene; + }), + ); + const classroom = { stage: input.stage, scenes }; + const knowledge = extractKnowledgePack(input.courseId, input.stage, scenes); + const quiz = extractQuizPack(scenes); + const classroomJson = JSON.stringify(classroom); + const knowledgeJson = JSON.stringify(knowledge); + const quizJson = JSON.stringify(quiz); + const contentHash = sha256( + JSON.stringify([ + ['classroom.json', sha256(classroomJson)], + ['knowledge/knowledge.json', sha256(knowledgeJson)], + ['quiz/quiz.json', sha256(quizJson)], + ]), + ); + const sceneKinds = [...new Set(scenes.map((scene) => scene.type))].filter( + (kind): kind is 'slide' | 'interactive' | 'quiz' | 'pbl' => + ['slide', 'interactive', 'quiz', 'pbl'].includes(kind), + ); + const capabilities = { + sceneKinds, + hasAudio: scenes.some((scene) => + scene.actions?.some((action) => action.type === 'speech' && Boolean(action.audioId)), + ), + hasWhiteboard: Boolean( + input.stage.whiteboard?.length || scenes.some((scene) => Boolean(scene.whiteboards?.length)), + ), + hasAgent: Boolean(input.stage.generatedAgentConfigs?.length), + }; + const descriptor = { + schemaVersion: 1, + kind: 'makelore-course-package', + courseId: input.courseId, + contentHash, + formatVersion: 1, + minPlayerVersion: '2.0.0', + title: input.stage.name, + language: input.stage.languageDirective, + sceneCount: scenes.length, + capabilities, + createdAt: new Date().toISOString(), + }; + const zip = new JSZip(); + zip.file('descriptor.json', JSON.stringify(descriptor, null, 2)); + zip.file('classroom.json', classroomJson); + zip.file('knowledge/knowledge.json', knowledgeJson); + zip.file('quiz/quiz.json', quizJson); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + return { + bytes, + contentHash, + archiveSha256: sha256(bytes), + archiveBytes: bytes.byteLength, + sceneCount: scenes.length, + title: input.stage.name, + language: input.stage.languageDirective, + capabilities, + classroom, + }; +} + +function packageRoot(): string { + const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); + return resolve( + process.env.LEARNING_COURSE_PACKAGE_DIR || + process.env.MAKELORE_COURSE_PACKAGE_DIR || + resolve(dataRoot, 'makelore-packages'), + ); +} + +function courseStoreRoot(): string { + const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); + return resolve( + process.env.LEARNING_COURSE_STORE_DIR || + process.env.MAKELORE_COURSE_STORE_DIR || + resolve(dataRoot, 'makelore-courses'), + ); +} + +function assertLearningCourseId(courseId: string): void { + if (!/^[A-Za-z0-9_-]{1,64}$/.test(courseId)) throw new Error('Invalid learning course id'); +} + +export async function persistUniqueMakeloreCourse( + courseId: string, + course: MakeloreCoursePackage, +): Promise { + const packages = packageRoot(); + const records = courseStoreRoot(); + await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]); + const packagePath = resolve(packages, `${courseId}.zip`); + const packageTemp = `${packagePath}.${process.pid}.tmp`; + const recordPath = resolve(records, `${courseId}.json`); + const recordTemp = `${recordPath}.${process.pid}.tmp`; + await writeFile(packageTemp, course.bytes); + await writeFile( + recordTemp, + JSON.stringify( + { + schemaVersion: 1, + courseId, + contentHash: course.contentHash, + title: course.title, + language: course.language, + classroom: course.classroom, + }, + null, + 2, + ), + ); + await rename(packageTemp, packagePath); + await rename(recordTemp, recordPath); + return packagePath; +} + +export async function readUniqueMakeloreCoursePackage( + courseId: string, +): Promise { + assertLearningCourseId(courseId); + try { + return new Uint8Array(await readFile(resolve(packageRoot(), `${courseId}.zip`))); + } catch { + return null; + } +} + +export interface FrozenLearningPackageRecord { + schemaVersion: 1 | 2; + courseId: string; + mode: 'single' | 'large'; + sourceJobId: string; + /** + * Stable digest of the exact mutable source snapshot used to build this + * archive. Legacy records omit it and must never be reused solely by job id. + */ + sourceDigest?: string; + contentHash: string; + archiveSha256: string; + archiveBytes: number; + formatVersion: number; + minPlayerVersion: string; + title: string; + language?: string; + sceneCount: number; + moduleCount: number; + capabilities: Record; + courseStructure?: Record; + classroom?: { stage: Stage; scenes: Scene[] }; + createdAt: string; +} + +/** Persist exact frozen bytes plus the metadata Works uses for idempotent finalize. */ +export async function persistFrozenLearningPackage( + record: FrozenLearningPackageRecord, + bytes: Uint8Array, +): Promise { + assertLearningCourseId(record.courseId); + const packages = packageRoot(); + const records = courseStoreRoot(); + await Promise.all([mkdir(packages, { recursive: true }), mkdir(records, { recursive: true })]); + const packagePath = resolve(packages, `${record.courseId}.zip`); + const recordPath = resolve(records, `${record.courseId}.json`); + const suffix = `${process.pid}.${Date.now()}.tmp`; + const packageTemp = `${packagePath}.${suffix}`; + const recordTemp = `${recordPath}.${suffix}`; + await writeFile(packageTemp, bytes); + await writeFile(recordTemp, JSON.stringify(record, null, 2)); + await rename(packageTemp, packagePath); + await rename(recordTemp, recordPath); +} + +export async function readFrozenLearningPackageRecord( + courseId: string, +): Promise { + assertLearningCourseId(courseId); + try { + const value = JSON.parse( + await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8'), + ) as Partial; + if ( + value.courseId !== courseId || + (value.mode !== 'single' && value.mode !== 'large') || + (value.sourceDigest !== undefined && !/^[0-9a-f]{64}$/.test(value.sourceDigest)) || + typeof value.contentHash !== 'string' || + !/^[0-9a-f]{64}$/.test(value.contentHash) || + typeof value.archiveSha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(value.archiveSha256) || + typeof value.archiveBytes !== 'number' + ) { + return null; + } + return value as FrozenLearningPackageRecord; + } catch { + return null; + } +} diff --git a/OpenMAIC/lib/makelore-runtime/auth.ts b/OpenMAIC/lib/makelore-runtime/auth.ts new file mode 100644 index 0000000..f67b17b --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/auth.ts @@ -0,0 +1,34 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { NextRequest } from 'next/server'; + +export const LEARNING_ENGINE_TOKEN_ENV = 'LEARNING_ENGINE_TOKEN'; +export const LEGACY_LEARNING_ENGINE_TOKEN_ENV = 'MAKELORE_RUNTIME_TOKEN'; + +/** + * Server-to-server credential for the private Learning Engine API. + * + * LEARNING_ENGINE_TOKEN is the canonical deployment name shared with Works + * Square. MAKELORE_RUNTIME_TOKEN remains a migration alias so existing private + * deployments can rotate without downtime. + */ +export function configuredLearningRuntimeToken(): string | undefined { + return ( + process.env[LEARNING_ENGINE_TOKEN_ENV]?.trim() || + process.env[LEGACY_LEARNING_ENGINE_TOKEN_ENV]?.trim() || + undefined + ); +} + +export function authorizeLearningRuntime(request: NextRequest): boolean { + const expected = configuredLearningRuntimeToken(); + const header = request.headers.get('authorization') || ''; + const received = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; + if (!expected || !received) return false; + const expectedBytes = Buffer.from(expected); + const receivedBytes = Buffer.from(received); + return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes); +} + +export function runtimeUnauthorized(): Response { + return Response.json({ error: 'unauthorized' }, { status: 401 }); +} diff --git a/OpenMAIC/lib/makelore-runtime/browser-bridge.ts b/OpenMAIC/lib/makelore-runtime/browser-bridge.ts new file mode 100644 index 0000000..6d9f20a --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/browser-bridge.ts @@ -0,0 +1,369 @@ +type ChatTurn = { role: 'user' | 'assistant'; content: string }; + +export type MakeloreRuntimeAnchor = { + sceneId: string; + sceneOrder?: number; + sceneTitle?: string; + actionIndex?: number; +}; + +export type MakeloreCourseRuntimeContext = { + courseId: string; + /** Aggregate package hash used by Works entitlement and session binding. */ + courseContentHash: string; + /** Compatibility alias; always the aggregate package hash. */ + contentHash: string; + moduleId: string | null; + /** Selected module hash; equals courseContentHash for a single course. */ + moduleContentHash: string; +}; + +const RUNTIME_CAPABILITY_BY_PATH = new Map([ + ['/api/quiz-grade', 'quiz-grade'], + ['/api/pbl/v2/task/update', 'pbl/v2/task/update'], + ['/api/pbl/v2/open-task', 'pbl/v2/open-task'], + ['/api/pbl/v2/simulator', 'pbl/v2/simulator'], + ['/api/pbl/v2/instructor', 'pbl/v2/instructor'], + ['/api/pbl/v2/evaluate', 'pbl/v2/evaluate'], +]); + +type RuntimeBridgeOptions = { + timeoutMs?: number; + getAnchor: () => MakeloreRuntimeAnchor | null; +}; + +type PendingRuntimeResponse = { + controller: ReadableStreamDefaultController; + resolve: (status: number, headers: Headers) => void; + reject: (error: Error) => void; + started: boolean; + settled: boolean; + timeout: number; + signal?: AbortSignal; + abort?: () => void; +}; + +function textOfMessage(value: unknown): string { + if (!value || typeof value !== 'object') return ''; + const message = value as Record; + if (typeof message.content === 'string') return message.content; + if (!Array.isArray(message.parts)) return ''; + return message.parts.map((part) => { + if (!part || typeof part !== 'object') return ''; + const value = part as Record; + return value.type === 'text' && typeof value.text === 'string' ? value.text : ''; + }).filter(Boolean).join('\n'); +} + +function requestFromChatBody(body: Record) { + const context = window.__MAKELORE_COURSE_CONTEXT__; + if (!context) throw new Error('课程助教上下文尚未就绪'); + const messages = Array.isArray(body.messages) ? body.messages : []; + const history: ChatTurn[] = messages.flatMap((item): ChatTurn[] => { + if (!item || typeof item !== 'object') return []; + const role = (item as Record).role; + const content = textOfMessage(item).trim(); + return (role === 'user' || role === 'assistant') && content ? [{ role, content }] : []; + }).slice(-8); + const lastUser = [...history].reverse().find((turn) => turn.role === 'user'); + if (!lastUser) throw new Error('没有可发送的问题'); + const storeState = body.storeState && typeof body.storeState === 'object' + ? body.storeState as Record + : {}; + const scenes = Array.isArray(storeState.scenes) ? storeState.scenes : []; + const currentSceneId = typeof storeState.currentSceneId === 'string' ? storeState.currentSceneId : undefined; + const currentScene = scenes.find((scene) => scene && typeof scene === 'object' + && (scene as Record).id === currentSceneId) as Record | undefined; + return { + courseId: context.courseId, + contentHash: context.contentHash, + message: lastUser.content, + history: history.slice(0, -1), + anchor: { + sceneId: currentSceneId, + sceneOrder: typeof currentScene?.order === 'number' ? currentScene.order : undefined, + sceneTitle: typeof currentScene?.title === 'string' ? currentScene.title : undefined, + moduleId: context.moduleId, + moduleContentHash: context.moduleContentHash, + }, + }; +} + +function sseResponse(text: string): Response { + const messageId = crypto.randomUUID(); + const events = [ + { type: 'agent_start', data: { messageId, agentId: 'default-1', agentName: 'AI Teacher' } }, + { type: 'text_delta', data: { messageId, content: text } }, + { type: 'agent_end', data: { messageId, agentId: 'default-1' } }, + { type: 'cue_user', data: { fromAgentId: 'default-1' } }, + { type: 'done', data: { totalActions: 0, totalAgents: 1, agentHadContent: Boolean(text), cueUserReceived: true } }, + ]; + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +export function fetchMakeloreLearningAgent(body: Record, signal: AbortSignal): Promise { + const requestId = crypto.randomUUID(); + const request = requestFromChatBody(body); + return new Promise((resolve, reject) => { + const cleanup = () => { + window.removeEventListener('message', onMessage); + signal.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + cleanup(); + reject(new DOMException('Aborted', 'AbortError')); + }; + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; + const message = event.data as { type?: string; requestId?: string; ok?: boolean; text?: string; error?: string }; + if (message.type !== 'makelore:agent:response' || message.requestId !== requestId) return; + cleanup(); + resolve(message.ok + ? sseResponse(message.text || '') + : new Response(message.error || '助教回答失败', { status: 502 })); + }; + window.addEventListener('message', onMessage); + signal.addEventListener('abort', onAbort, { once: true }); + window.parent.postMessage({ type: 'makelore:agent:request', requestId, request }, '*'); + }); +} + +export async function transcribeMakeloreLearningAudio( + audioBlob: Blob, + options: { fileName?: string; language?: string } = {}, +): Promise { + if (!window.__MAKELORE_COURSE_CONTEXT__) throw new Error('课程语音上下文尚未就绪'); + const requestId = crypto.randomUUID(); + const audio = await audioBlob.arrayBuffer(); + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + cleanup(); + reject(new Error('语音识别超时')); + }, 60_000); + const cleanup = () => { + window.clearTimeout(timeout); + window.removeEventListener('message', onMessage); + }; + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; + const message = event.data as { type?: string; requestId?: string; ok?: boolean; text?: string; error?: string }; + if (message.type !== 'makelore:transcription:response' || message.requestId !== requestId) return; + cleanup(); + if (message.ok && message.text) resolve(message.text); + else reject(new Error(message.error || '语音识别失败')); + }; + window.addEventListener('message', onMessage); + window.parent.postMessage({ + type: 'makelore:transcription:request', + requestId, + audio, + fileName: options.fileName || 'voice.webm', + mimeType: audioBlob.type || 'audio/webm', + language: options.language, + }, '*', [audio]); + }); +} + +function runtimeCapability(input: RequestInfo | URL, init?: RequestInit): string | null { + // Only literal relative paths are capabilities. Absolute/same-origin URLs, + // Request objects, query strings, and lookalike prefixes stay on native fetch. + if (typeof input !== 'string') return null; + const capability = RUNTIME_CAPABILITY_BY_PATH.get(input); + if (!capability) return null; + const method = (init?.method ?? 'GET').toUpperCase(); + return method === 'POST' ? capability : null; +} + +function structuredRuntimeBody(body: BodyInit | null | undefined): unknown { + if (body == null || body === '') return {}; + if (typeof body !== 'string') { + throw new TypeError('课堂能力桥只接受 JSON 请求体'); + } + try { + return JSON.parse(body) as unknown; + } catch { + throw new TypeError('课堂能力桥收到无效 JSON'); + } +} + +function runtimeChunk(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) return new Uint8Array(value); + if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + return null; +} + +/** + * Replace fetch only inside the verified offline player and only for the six + * frozen-course host capabilities. Returns an idempotent cleanup function. + */ +export function installMakeloreRuntimeFetchBridge(options: RuntimeBridgeOptions): () => void { + if (!window.__MAKELORE_OFFLINE_PLAYER__) return () => undefined; + const nativeFetch = window.fetch.bind(window); + const pending = new Map(); + const timeoutMs = Math.max(1, options.timeoutMs ?? 300_000); + + const cleanupPending = (requestId: string, state: PendingRuntimeResponse) => { + window.clearTimeout(state.timeout); + if (state.signal && state.abort) state.signal.removeEventListener('abort', state.abort); + pending.delete(requestId); + }; + + const failPending = (requestId: string, state: PendingRuntimeResponse, error: Error) => { + if (state.settled) return; + state.settled = true; + cleanupPending(requestId, state); + if (state.started) state.controller.error(error); + else state.reject(error); + }; + + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; + const message = event.data as { + type?: string; + requestId?: string; + status?: number; + contentType?: string; + chunk?: unknown; + message?: string; + code?: string; + }; + if (typeof message.requestId !== 'string') return; + const state = pending.get(message.requestId); + if (!state || state.settled) return; + + if (message.type === 'makelore:runtime:start') { + if (state.started) { + failPending(message.requestId, state, new Error('课堂能力桥收到重复响应头')); + return; + } + const status = Number(message.status); + if (!Number.isInteger(status) || status < 100 || status > 599) { + failPending(message.requestId, state, new Error('课堂能力桥响应状态无效')); + return; + } + state.started = true; + const headers = new Headers(); + if (typeof message.contentType === 'string' && message.contentType.trim()) { + headers.set('Content-Type', message.contentType); + } + state.resolve(status, headers); + return; + } + + if (message.type === 'makelore:runtime:chunk') { + if (!state.started) { + failPending(message.requestId, state, new Error('课堂能力桥在响应头前收到数据')); + return; + } + const chunk = runtimeChunk(message.chunk); + if (!chunk) { + failPending(message.requestId, state, new Error('课堂能力桥收到无效数据块')); + return; + } + state.controller.enqueue(chunk); + return; + } + + if (message.type === 'makelore:runtime:end') { + if (!state.started) { + failPending(message.requestId, state, new Error('课堂能力桥响应缺少开始事件')); + return; + } + state.settled = true; + cleanupPending(message.requestId, state); + state.controller.close(); + return; + } + + if (message.type === 'makelore:runtime:error') { + const prefix = typeof message.code === 'string' ? `${message.code}: ` : ''; + failPending( + message.requestId, + state, + new Error(`${prefix}${message.message || '课堂联网能力暂时不可用'}`), + ); + } + }; + + window.addEventListener('message', onMessage); + + const bridgedFetch: typeof window.fetch = (input, init) => { + const capability = runtimeCapability(input, init); + if (!capability) return nativeFetch(input, init); + const context = window.__MAKELORE_COURSE_CONTEXT__; + const anchor = options.getAnchor(); + if (!context || !anchor?.sceneId) { + return Promise.reject(new TypeError('课堂运行上下文尚未就绪')); + } + let body: unknown; + try { + body = structuredRuntimeBody(init?.body); + } catch (error) { + return Promise.reject(error); + } + const signal = init?.signal ?? undefined; + if (signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + + const requestId = crypto.randomUUID(); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + const response = new Promise((resolve, reject) => { + const state: PendingRuntimeResponse = { + controller: streamController, + resolve: (status, headers) => resolve(new Response( + status === 204 || status === 205 || status === 304 ? null : stream, + { status, headers }, + )), + reject, + started: false, + settled: false, + timeout: 0, + signal, + }; + state.timeout = window.setTimeout(() => { + failPending(requestId, state, new Error('课堂联网能力请求超时')); + }, timeoutMs); + if (signal) { + state.abort = () => failPending(requestId, state, new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', state.abort, { once: true }); + } + pending.set(requestId, state); + window.parent.postMessage({ + type: 'makelore:runtime:request', + requestId, + capability, + method: 'POST', + body, + context: { + courseId: context.courseId, + courseContentHash: context.courseContentHash, + moduleId: context.moduleId, + moduleContentHash: context.moduleContentHash, + anchor, + }, + }, '*'); + }); + return response; + }; + + window.fetch = bridgedFetch; + let uninstalled = false; + return () => { + if (uninstalled) return; + uninstalled = true; + if (window.fetch === bridgedFetch) window.fetch = nativeFetch; + window.removeEventListener('message', onMessage); + for (const [requestId, state] of pending) { + failPending(requestId, state, new Error('课堂能力桥已关闭')); + } + }; +} diff --git a/OpenMAIC/lib/makelore-runtime/course-store.ts b/OpenMAIC/lib/makelore-runtime/course-store.ts new file mode 100644 index 0000000..296fcf9 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/course-store.ts @@ -0,0 +1,373 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import JSZip from 'jszip'; +import { extractKnowledgePack, extractQuizPack } from '@/lib/bundle/extract'; +import type { FrozenBundleDocument, KnowledgePack, QuizPack } from '@/lib/bundle/types'; +import type { ClassroomManifest } from '@/lib/export/classroom-zip-types'; +import { + readFrozenLearningPackageRecord, + readUniqueMakeloreCoursePackage, + type FrozenLearningPackageRecord, +} from '@/lib/makelore-course/package'; +import type { CoursewareKnowledge } from '@/lib/qa/knowledge'; +import type { Scene, Stage } from '@/lib/types/stage'; + +const SAFE_ID = /^[A-Za-z0-9_-]{1,96}$/; +const SAFE_COURSE_ID = /^[A-Za-z0-9_-]{1,64}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const MAX_JSON_CHARS = 64 * 1024 * 1024; + +export interface RuntimeCourseModuleDescriptor { + moduleId: string; + index: number; + title: string; + summary: string; + contentHash: string; + sceneCount: number; + root: string; +} + +export interface MakeloreCourseRecord { + schemaVersion: 1 | 2; + courseId: string; + contentHash: string; + title: string; + language?: string; + mode: 'single' | 'large'; + sceneCount: number; + moduleCount: number; + modules: RuntimeCourseModuleDescriptor[]; + /** Only retained for pre-frozen compatibility records. */ + classroom?: { stage: Stage; scenes: Scene[] }; + frozenRecord?: FrozenLearningPackageRecord; + archive?: JSZip; +} + +export interface RuntimeCourseModule { + course: MakeloreCourseRecord; + descriptor: RuntimeCourseModuleDescriptor; + manifest?: ClassroomManifest; + knowledge: KnowledgePack; + quiz: QuizPack; + bundle?: FrozenBundleDocument; + /** Compatibility path for legacy classroom.json packages. */ + classroom?: { stage: Stage; scenes: Scene[] }; +} + +function courseStoreRoot(): string { + const dataRoot = process.env.LEARNING_DATA_DIR || resolve(process.cwd(), 'data'); + return resolve( + process.env.LEARNING_COURSE_STORE_DIR || + process.env.MAKELORE_COURSE_STORE_DIR || + resolve(dataRoot, 'makelore-courses'), + ); +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function positiveInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function safeRoot(value: unknown, moduleId: string): string | null { + if (typeof value !== 'string') return null; + const normalized = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + if (!normalized || normalized.split('/').some((part) => part === '.' || part === '..')) return null; + const expected = `modules/${moduleId}`; + return normalized === expected ? `${expected}/` : null; +} + +async function zipJson(zip: JSZip, path: string): Promise { + const entry = zip.file(path); + if (!entry) throw new Error(`Frozen course is missing ${path}`); + const text = await entry.async('string'); + if (text.length > MAX_JSON_CHARS) throw new Error(`Frozen course document is too large: ${path}`); + return JSON.parse(text) as T; +} + +function singleDescriptor( + courseId: string, + contentHash: string, + title: string, + sceneCount: number, + moduleId = courseId, +): RuntimeCourseModuleDescriptor { + return { moduleId, index: 1, title, summary: '', contentHash, sceneCount, root: '' }; +} + +async function loadFrozenCourse( + courseId: string, + contentHash: string, +): Promise { + const frozen = await readFrozenLearningPackageRecord(courseId); + if (!frozen || frozen.contentHash !== contentHash) return null; + const bytes = await readUniqueMakeloreCoursePackage(courseId); + if ( + !bytes || + bytes.byteLength !== frozen.archiveBytes || + sha256(bytes) !== frozen.archiveSha256 + ) { + return null; + } + let archive: JSZip; + try { + archive = await JSZip.loadAsync(bytes, { checkCRC32: true }); + } catch { + return null; + } + + if (frozen.mode === 'single') { + let bundle: FrozenBundleDocument; + try { + bundle = await zipJson(archive, 'bundle.json'); + } catch { + return null; + } + const moduleId = bundle.meta?.coursewareId; + if ( + typeof moduleId !== 'string' || + !SAFE_ID.test(moduleId) || + bundle.meta.contentHash !== contentHash || + !archive.file('manifest.json') || + !archive.file('knowledge/knowledge.json') || + !archive.file('quiz/quiz.json') + ) { + return null; + } + return { + schemaVersion: frozen.schemaVersion, + courseId, + contentHash, + title: frozen.title, + language: frozen.language, + mode: 'single', + sceneCount: frozen.sceneCount, + moduleCount: 1, + modules: [singleDescriptor(courseId, contentHash, frozen.title, frozen.sceneCount, moduleId)], + frozenRecord: frozen, + archive, + }; + } + + const structure = record(frozen.courseStructure); + const structureModules = Array.isArray(structure?.modules) + ? structure.modules.map(record).filter((item): item is Record => item !== null) + : []; + let courseDocument: Record; + try { + courseDocument = record(await zipJson(archive, 'course.json')) ?? {}; + } catch { + return null; + } + const courseModules = Array.isArray(courseDocument.modules) + ? courseDocument.modules.map(record).filter((item): item is Record => item !== null) + : []; + if ( + structure?.kind !== 'learning-course-aggregate' || + structure.contentHash !== contentHash || + courseDocument.kind !== 'learning-course-aggregate' || + courseDocument.courseId !== courseId || + courseDocument.contentHash !== contentHash || + structureModules.length === 0 || + courseModules.length !== structureModules.length + ) { + return null; + } + + const modules: RuntimeCourseModuleDescriptor[] = []; + for (const [position, source] of structureModules.entries()) { + const moduleId = source.coursewareId; + const moduleHash = source.contentHash; + const index = positiveInteger(source.index); + const declared = courseModules.find((candidate) => candidate.moduleId === moduleId); + const root = safeRoot(declared?.path, typeof moduleId === 'string' ? moduleId : ''); + if ( + typeof moduleId !== 'string' || + !SAFE_ID.test(moduleId) || + typeof moduleHash !== 'string' || + !SHA256.test(moduleHash) || + index === null || + !declared || + declared.contentHash !== moduleHash || + declared.index !== index || + !root || + !archive.file(`${root}manifest.json`) || + !archive.file(`${root}bundle.json`) || + !archive.file(`${root}knowledge/knowledge.json`) || + !archive.file(`${root}quiz/quiz.json`) + ) { + return null; + } + const sceneCount = positiveInteger(source.sceneCount); + modules.push({ + moduleId, + index, + title: typeof source.title === 'string' ? source.title : `Module ${position + 1}`, + summary: typeof source.description === 'string' ? source.description : '', + contentHash: moduleHash, + sceneCount: sceneCount ?? 0, + root, + }); + } + if ( + new Set(modules.map((module) => module.moduleId)).size !== modules.length || + modules.some((module, index) => index > 0 && module.index <= modules[index - 1]!.index) + ) { + return null; + } + return { + schemaVersion: frozen.schemaVersion, + courseId, + contentHash, + title: frozen.title, + language: frozen.language, + mode: 'large', + sceneCount: frozen.sceneCount, + moduleCount: modules.length, + modules, + frozenRecord: frozen, + archive, + }; +} + +async function loadLegacyCourse( + courseId: string, + contentHash: string, +): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(resolve(courseStoreRoot(), `${courseId}.json`), 'utf8')); + } catch { + return null; + } + const legacy = record(value); + const classroom = record(legacy?.classroom); + if ( + legacy?.schemaVersion !== 1 || + legacy.courseId !== courseId || + legacy.contentHash !== contentHash || + typeof legacy.title !== 'string' || + !classroom || + !record(classroom.stage) || + !Array.isArray(classroom.scenes) + ) { + return null; + } + const typedClassroom = classroom as unknown as { stage: Stage; scenes: Scene[] }; + return { + schemaVersion: 1, + courseId, + contentHash, + title: legacy.title, + ...(typeof legacy.language === 'string' ? { language: legacy.language } : {}), + mode: 'single', + sceneCount: typedClassroom.scenes.length, + moduleCount: 1, + modules: [singleDescriptor(courseId, contentHash, legacy.title, typedClassroom.scenes.length)], + classroom: typedClassroom, + }; +} + +export function parseLearningBinding(key: string): { courseId: string; contentHash: string } | null { + const split = key.lastIndexOf(':'); + if (split <= 0) return null; + const courseId = key.slice(0, split); + const contentHash = key.slice(split + 1); + return SAFE_COURSE_ID.test(courseId) && SHA256.test(contentHash) + ? { courseId, contentHash } + : null; +} + +/** Resolve only an exact aggregate course ID + content hash. */ +export async function loadMakeloreCourse( + courseId: string, + contentHash: string, +): Promise { + if (!SAFE_COURSE_ID.test(courseId) || !SHA256.test(contentHash)) return null; + return (await loadFrozenCourse(courseId, contentHash)) ?? loadLegacyCourse(courseId, contentHash); +} + +export async function resolveMakeloreCourseModule( + course: MakeloreCourseRecord, + selection: { moduleId?: string | null; moduleContentHash?: string | null } = {}, +): Promise { + let descriptor: RuntimeCourseModuleDescriptor | undefined; + if (course.mode === 'large') { + if (!selection.moduleId || !selection.moduleContentHash) return null; + descriptor = course.modules.find((module) => module.moduleId === selection.moduleId); + if (!descriptor || descriptor.contentHash !== selection.moduleContentHash) return null; + } else { + descriptor = course.modules[0]; + if (!descriptor) return null; + if ( + selection.moduleId && + selection.moduleId !== descriptor.moduleId && + selection.moduleId !== course.courseId + ) { + return null; + } + if (selection.moduleContentHash && selection.moduleContentHash !== course.contentHash) return null; + } + + if (!course.archive) { + const classroom = course.classroom; + if (!classroom) return null; + return { + course, + descriptor, + classroom, + knowledge: extractKnowledgePack(descriptor.moduleId, classroom.stage, classroom.scenes), + quiz: extractQuizPack(classroom.scenes), + }; + } + + try { + const [bundle, manifest, knowledge, quiz] = await Promise.all([ + zipJson(course.archive, `${descriptor.root}bundle.json`), + zipJson(course.archive, `${descriptor.root}manifest.json`), + zipJson(course.archive, `${descriptor.root}knowledge/knowledge.json`), + zipJson(course.archive, `${descriptor.root}quiz/quiz.json`), + ]); + if ( + bundle.meta?.coursewareId !== descriptor.moduleId || + bundle.meta.contentHash !== descriptor.contentHash || + !Array.isArray(manifest.scenes) || + !Array.isArray(knowledge.scenes) || + !Array.isArray(quiz.scenes) || + manifest.scenes.length !== descriptor.sceneCount + ) { + return null; + } + return { course, descriptor, manifest, knowledge, quiz, bundle }; + } catch { + return null; + } +} + +export function courseKnowledge(module: RuntimeCourseModule): CoursewareKnowledge { + return { + coursewareId: module.descriptor.moduleId, + title: module.descriptor.title, + language: module.bundle?.meta.language ?? module.course.language, + knowledge: module.knowledge, + quiz: module.quiz, + }; +} + +/** Synthetic scene identity used by Makelore when materializing a frozen manifest. */ +export function runtimeSceneId( + course: MakeloreCourseRecord, + module: RuntimeCourseModuleDescriptor, + sceneIndex: number, +): string { + return `learning_${course.courseId}_${module.moduleId}_s${sceneIndex}`; +} diff --git a/OpenMAIC/lib/makelore-runtime/generation-request.ts b/OpenMAIC/lib/makelore-runtime/generation-request.ts new file mode 100644 index 0000000..6c9a298 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/generation-request.ts @@ -0,0 +1,193 @@ +import type { GenerateClassroomInput } from '@/lib/server/classroom-generation'; +import { + CourseMaterialError, + extractManagedCourseMaterials, +} from '@/lib/server/managed-course-materials'; + +export const LEARNING_GENERATION_CONTRACT_VERSION = 2 as const; +export type LearningGenerationMode = 'single' | 'large'; + +export interface LearningGenerationRequest { + contractVersion: typeof LEARNING_GENERATION_CONTRACT_VERSION; + ownerUserId: string; + mode: LearningGenerationMode; + input: GenerateClassroomInput; + materialCount: number; +} + +export class LearningGenerationRequestError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string, + ) { + super(message); + this.name = 'LearningGenerationRequestError'; + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function compatibilityBoolean( + value: unknown, + snakeCaseValue: unknown, + fallback = false, +): boolean { + const selected = value ?? snakeCaseValue; + if (selected === undefined) return fallback; + if (typeof selected !== 'boolean') { + throw new LearningGenerationRequestError( + 'invalid_request', + 400, + 'Generation option values must be booleans', + ); + } + return selected; +} + +async function requestPayload( + request: Request, +): Promise<{ body: Record; files: File[] }> { + const contentType = request.headers.get('content-type')?.toLowerCase() ?? ''; + if (contentType.includes('multipart/form-data')) { + const contentLength = Number(request.headers.get('content-length') ?? ''); + if (Number.isFinite(contentLength) && contentLength > 155 * 1024 * 1024) { + throw new LearningGenerationRequestError( + 'materials_too_large', + 413, + 'Multipart request exceeds the course-material upload limit', + ); + } + const form = await request.formData().catch(() => null); + if (!form) { + throw new LearningGenerationRequestError('invalid_request', 400, 'Malformed multipart body'); + } + const rawOptions = form.get('options'); + if (typeof rawOptions !== 'string') { + throw new LearningGenerationRequestError( + 'invalid_request', + 400, + 'Multipart generation requires an options JSON field', + ); + } + const body = asRecord(JSON.parse(rawOptions) as unknown); + if (!body) { + throw new LearningGenerationRequestError('invalid_request', 400, 'Invalid options JSON'); + } + const files = form + .getAll('materials') + .filter((part): part is File => part instanceof File); + if (files.length !== form.getAll('materials').length) { + throw new LearningGenerationRequestError( + 'invalid_request', + 400, + 'Every materials field must be a file', + ); + } + return { body, files }; + } + + if (!contentType.includes('application/json')) { + throw new LearningGenerationRequestError( + 'unsupported_media_type', + 415, + 'Expected application/json or multipart/form-data', + ); + } + const body = asRecord(await request.json().catch(() => null)); + if (!body) throw new LearningGenerationRequestError('invalid_request', 400, 'Invalid JSON body'); + return { body, files: [] }; +} + +export async function parseLearningGenerationRequest( + request: Request, +): Promise { + const { body, files } = await requestPayload(request); + const version = body.contractVersion ?? body.contract_version; + if (version !== undefined && version !== LEARNING_GENERATION_CONTRACT_VERSION) { + throw new LearningGenerationRequestError( + 'unsupported_contract_version', + 400, + `Only generation contract version ${LEARNING_GENERATION_CONTRACT_VERSION} is supported`, + ); + } + const owner = body.owner_user_id; + if ( + typeof owner !== 'string' || + !owner.trim() || + owner.trim().length > 256 || + /[\u0000-\u001f\u007f]/.test(owner) + ) { + throw new LearningGenerationRequestError('invalid_owner', 400, 'Invalid owner_user_id'); + } + if (body.mode !== 'single' && body.mode !== 'large') { + throw new LearningGenerationRequestError( + 'invalid_mode', + 400, + 'mode must be "single" or "large"', + ); + } + if ( + typeof body.requirement !== 'string' || + !body.requirement.trim() || + body.requirement.trim().length > 20_000 + ) { + throw new LearningGenerationRequestError( + 'invalid_requirement', + 400, + 'requirement must contain 1-20000 characters', + ); + } + const forbidden = [ + 'providerId', + 'apiKey', + 'baseUrl', + 'storageKey', + 'webSearchApiKey', + 'webSearchProviderId', + ].find((field) => body[field] !== undefined); + if (forbidden) { + throw new LearningGenerationRequestError( + 'caller_provider_configuration_forbidden', + 400, + `${forbidden} is server-managed and cannot be supplied by the caller`, + ); + } + + let pdfContent: { text: string; images: string[] } | undefined; + try { + if (files.length > 0) pdfContent = await extractManagedCourseMaterials(files); + } catch (error) { + if (error instanceof CourseMaterialError) { + throw new LearningGenerationRequestError(error.code, error.status, error.message); + } + throw error; + } + + return { + contractVersion: LEARNING_GENERATION_CONTRACT_VERSION, + ownerUserId: owner.trim(), + mode: body.mode, + materialCount: files.length, + input: { + requirement: body.requirement.trim(), + enableWebSearch: compatibilityBoolean(body.enableWebSearch, body.enable_web_search), + enableImageGeneration: compatibilityBoolean( + body.enableImageGeneration, + body.enable_image_generation, + ), + enableVideoGeneration: compatibilityBoolean( + body.enableVideoGeneration, + body.enable_video_generation, + ), + enableTTS: compatibilityBoolean(body.enableTTS, body.enable_tts), + interactiveMode: compatibilityBoolean(body.interactiveMode, body.interactive_mode), + taskEngineMode: compatibilityBoolean(body.taskEngineMode, body.task_engine_mode), + ...(pdfContent ? { pdfContent } : {}), + }, + }; +} diff --git a/OpenMAIC/lib/makelore-runtime/health.ts b/OpenMAIC/lib/makelore-runtime/health.ts new file mode 100644 index 0000000..ab1d51d --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/health.ts @@ -0,0 +1,126 @@ +import { constants } from 'node:fs'; +import { access, mkdir, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { nanoid } from 'nanoid'; +import { COURSE_FRAMEWORK_DIR } from '@/lib/course-framework/store'; +import { COURSE_MANIFEST_DIR } from '@/lib/course-manifest-repo/store'; +import { COURSEWARES_DIR } from '@/lib/courseware-repo/store'; +import { getServerProviders, getServerTTSProviders } from '@/lib/server/provider-config'; +import { CLASSROOM_JOBS_DIR, CLASSROOMS_DIR } from '@/lib/server/classroom-storage'; +import { configuredLearningRuntimeToken } from './auth'; +import { configuredLearningEngineRuntimeBaseUrl } from './ops-engine-control'; +import { resolveDeploymentRole } from '@/lib/config/deployment-role'; + +export const LEARNING_ENGINE_SERVICE = 'openmaic-learning-engine'; +export const LEARNING_ENGINE_API_VERSION = 'v1'; + +export type LearningReadinessCheck = { + ok: boolean; + detail?: string; +}; + +function dataRoot(): string { + return path.resolve(process.env.LEARNING_DATA_DIR?.trim() || path.join(process.cwd(), 'data')); +} + +export function learningPersistenceDirectories(): Record { + const root = dataRoot(); + return { + classrooms: process.env.CLASSROOM_DATA_DIR || CLASSROOMS_DIR, + classroomJobs: process.env.CLASSROOM_JOBS_DIR || CLASSROOM_JOBS_DIR, + courseFrameworks: process.env.COURSE_FRAMEWORK_DIR || COURSE_FRAMEWORK_DIR, + courseManifests: process.env.COURSE_MANIFEST_DIR || COURSE_MANIFEST_DIR, + coursewares: process.env.COURSEWARE_DATA_DIR || COURSEWARES_DIR, + coursewareBundles: + process.env.COURSEWARE_BUNDLE_DIR || path.join(root, 'courseware-bundles'), + learningPackages: + process.env.LEARNING_COURSE_PACKAGE_DIR || + process.env.MAKELORE_COURSE_PACKAGE_DIR || + path.join(root, 'makelore-packages'), + learningCourses: + process.env.LEARNING_COURSE_STORE_DIR || + process.env.MAKELORE_COURSE_STORE_DIR || + path.join(root, 'makelore-courses'), + }; +} + +async function verifyWritableDirectory(directory: string): Promise { + await mkdir(directory, { recursive: true }); + await access(directory, constants.R_OK | constants.W_OK); + const probe = path.join(directory, `.learning-ready-${process.pid}-${nanoid(6)}.tmp`); + await writeFile(probe, 'ready', { flag: 'wx' }); + await unlink(probe); +} + +function configuredOrigin(name: string): boolean { + const raw = process.env[name]?.trim(); + if (!raw) return false; + try { + const url = new URL(raw); + return ( + (url.protocol === 'https:' || url.protocol === 'http:') && + !url.username && + !url.password && + !url.search && + !url.hash && + (url.pathname === '' || url.pathname === '/') + ); + } catch { + return false; + } +} + +export async function inspectLearningReadiness(): Promise<{ + ready: boolean; + checks: Record; +}> { + const role = resolveDeploymentRole( + process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE, + process.env.NODE_ENV, + ); + const checks: Record = role === 'ops' + ? { + worksIdentityOrigin: configuredOrigin('WORKS_SQUARE_API_BASE_URL') + ? { ok: true } + : { ok: false, detail: 'WORKS_SQUARE_API_BASE_URL is not configured as an origin' }, + opsPublicOrigin: configuredOrigin('OPS_PUBLIC_ORIGIN') + ? { ok: true } + : { ok: false, detail: 'OPS_PUBLIC_ORIGIN is not configured as an origin' }, + engineControlEndpoint: configuredLearningEngineRuntimeBaseUrl() + ? { ok: true } + : { + ok: false, + detail: 'LEARNING_ENGINE_BASE_URL or COURSE_PUBLISH_SERVER_BASE_URL is not configured', + }, + runtimeToken: configuredLearningRuntimeToken() + ? { ok: true } + : { ok: false, detail: 'LEARNING_ENGINE_TOKEN is not configured' }, + } + : { + runtimeToken: configuredLearningRuntimeToken() + ? { ok: true } + : { ok: false, detail: 'LEARNING_ENGINE_TOKEN is not configured' }, + llmProvider: + Object.keys(getServerProviders()).length > 0 + ? { ok: true } + : { ok: false, detail: 'No server-managed LLM provider is configured' }, + ttsProvider: + Object.values(getServerTTSProviders()).some((provider) => !provider.disabled) + ? { ok: true } + : { ok: false, detail: 'No enabled server-managed TTS provider is configured' }, + }; + + for (const [name, directory] of Object.entries(learningPersistenceDirectories())) { + try { + await verifyWritableDirectory(directory); + checks[`storage.${name}`] = { ok: true }; + } catch (error) { + checks[`storage.${name}`] = { + ok: false, + detail: error instanceof Error ? error.message : 'Storage is not writable', + }; + } + } + + return { ready: Object.values(checks).every((check) => check.ok), checks }; +} diff --git a/OpenMAIC/lib/makelore-runtime/large-course-control.ts b/OpenMAIC/lib/makelore-runtime/large-course-control.ts new file mode 100644 index 0000000..9dcc3d7 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/large-course-control.ts @@ -0,0 +1,222 @@ +import { + cancelCourseGenerationJob, + isCourseGenerationRunning, + regenerateCourseFramework, + runCourseFrameworkGeneration, + runCourseGenerationJob, + runCourseModuleGeneration, +} from '@/lib/course-framework/runner'; +import { + isValidCourseId, + readCourseRecordReconciled, + updateCourseRecord, +} from '@/lib/course-framework/store'; +import { + CourseMutationInProgressError, + isCoursePublishing, +} from '@/lib/course-framework/publish-state'; +import { + assertLearningCourseMutable, + FrozenLearningCourseMutationError, +} from '@/lib/makelore-course/immutability'; + +export const LARGE_COURSE_CONTROL_ACTIONS = [ + 'start', + 'cancel', + 'resume', + 'framework-regenerate', + 'module-regenerate', +] as const; + +export type LargeCourseControlAction = (typeof LARGE_COURSE_CONTROL_ACTIONS)[number]; + +export type LargeCourseControlResult = { + courseId: string; + action: LargeCourseControlAction; + status: 'framework_generating' | 'generating' | 'cancelling'; + message: string; + moduleIndex?: number; + run?: Promise; +}; + +export class LargeCourseControlError extends Error { + constructor( + public readonly code: string, + public readonly status: number, + message: string, + public readonly details?: Record, + ) { + super(message); + this.name = 'LargeCourseControlError'; + } +} + +function conflict(message: string, code = 'invalid_course_state'): never { + throw new LargeCourseControlError(code, 409, message); +} + +/** + * The only process-local entry point for large-course runner mutations. + * + * Engine runtime routes, including the legacy Works cancel/resume paths, call + * this function so the controller maps and their cancellation signals always + * belong to the same Node process. + */ +export async function controlLargeCourse(options: { + courseId: string; + ownerPrincipalId: string; + action: LargeCourseControlAction; + baseUrl: string; + moduleIndex?: number; +}): Promise { + const { courseId, ownerPrincipalId, action, baseUrl, moduleIndex } = options; + if (!isValidCourseId(courseId) || !ownerPrincipalId.trim()) { + throw new LargeCourseControlError('invalid_request', 400, 'Invalid course or owner'); + } + const record = await readCourseRecordReconciled(courseId); + if (!record || record.ownerPrincipalId !== ownerPrincipalId) { + throw new LargeCourseControlError('job_not_found', 404, 'Course generation job not found'); + } + + try { + // Cancellation remains available to stop an in-flight finalize race. All + // actions that can create new source state are forbidden after freezing. + if (action !== 'cancel') await assertLearningCourseMutable(courseId, record); + switch (action) { + case 'cancel': { + if (!isCourseGenerationRunning(courseId) || !cancelCourseGenerationJob(courseId)) { + conflict('Course generation is not running', 'job_not_running'); + } + await updateCourseRecord(courseId, { status: 'cancelled' }); + return { + courseId, + action, + status: 'cancelling', + message: 'Cancellation requested', + }; + } + + case 'start': { + if (!record.framework) conflict('课程框架尚未生成,请等待框架生成完成'); + if (record.status !== 'framework_ready') { + conflict(`当前状态(${record.status})不允许启动模块生成`); + } + if ( + isCourseGenerationRunning(courseId) + || isCoursePublishing(courseId) + || record.modules.some((entry) => entry.status === 'generating') + ) { + conflict('该课程已有生成任务正在运行', 'job_already_running'); + } + return { + courseId, + action, + status: 'generating', + message: '模块生成已开始', + run: runCourseModuleGeneration(courseId, baseUrl), + }; + } + + case 'resume': { + if (record.status === 'framework_ready') { + throw new LargeCourseControlError( + 'review_required', + 409, + 'Course framework requires operations review', + { reviewUrl: `/learning-ops/courses/${record.id}` }, + ); + } + if (record.status === 'completed') conflict('Course is already completed', 'job_not_resumable'); + if (isCourseGenerationRunning(courseId) || isCoursePublishing(courseId)) { + conflict('该课程已有生成任务正在运行', 'job_not_resumable'); + } + const hasFramework = Boolean(record.framework); + return { + courseId, + action, + status: hasFramework ? 'generating' : 'framework_generating', + message: hasFramework ? '模块生成已继续' : '框架生成已继续', + run: hasFramework + ? runCourseModuleGeneration(courseId, baseUrl) + : runCourseFrameworkGeneration(courseId, baseUrl), + }; + } + + case 'framework-regenerate': { + if ( + isCourseGenerationRunning(courseId) + || isCoursePublishing(courseId) + || record.modules.some((entry) => entry.status === 'generating') + ) { + conflict('模块正在生成中,无法重新生成框架', 'job_already_running'); + } + return { + courseId, + action, + status: 'framework_generating', + message: '框架重新生成已开始', + run: regenerateCourseFramework(courseId, baseUrl), + }; + } + + case 'module-regenerate': { + if (!Number.isInteger(moduleIndex) || moduleIndex! < 1) { + throw new LargeCourseControlError('invalid_request', 400, 'Invalid module index'); + } + if (!record.framework) { + conflict('Course framework not generated yet; cannot regenerate a module'); + } + if (!record.modules.some((entry) => entry.index === moduleIndex)) { + throw new LargeCourseControlError( + 'module_not_found', + 404, + `Module ${moduleIndex} not found`, + ); + } + if ( + isCourseGenerationRunning(courseId) + || isCoursePublishing(courseId) + || record.modules.some((entry) => entry.status === 'generating') + ) { + conflict('该课程已有生成任务正在运行', 'job_already_running'); + } + return { + courseId, + action, + moduleIndex, + status: 'generating', + message: `Module ${moduleIndex} and downstream regeneration started`, + run: runCourseGenerationJob(courseId, baseUrl, { onlyModuleIndex: moduleIndex }), + }; + } + } + } catch (error) { + if (error instanceof LargeCourseControlError) throw error; + if (error instanceof FrozenLearningCourseMutationError) { + throw new LargeCourseControlError('course_frozen', 409, error.message); + } + if (error instanceof CourseMutationInProgressError) { + throw new LargeCourseControlError('job_already_running', 409, error.message); + } + if (error instanceof Error && error.message.includes('already has an active generation run')) { + throw new LargeCourseControlError('job_already_running', 409, error.message); + } + throw error; + } +} + +export function largeCourseControlErrorResponse(error: unknown): Response { + if (error instanceof LargeCourseControlError) { + return Response.json( + { error: error.code, message: error.message, ...error.details }, + { status: error.status }, + ); + } + return Response.json( + { + error: 'internal_error', + message: error instanceof Error ? error.message : 'Large-course control failed', + }, + { status: 500 }, + ); +} diff --git a/OpenMAIC/lib/makelore-runtime/ops-engine-control.ts b/OpenMAIC/lib/makelore-runtime/ops-engine-control.ts new file mode 100644 index 0000000..93b1d40 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/ops-engine-control.ts @@ -0,0 +1,104 @@ +import { getServerDeploymentRole } from '@/lib/server/ops-access'; +import { configuredLearningRuntimeToken } from '@/lib/makelore-runtime/auth'; +import type { CourseRecord } from '@/lib/course-framework/types'; +import type { LargeCourseControlAction } from '@/lib/makelore-runtime/large-course-control'; + +export const LEARNING_ENGINE_BASE_URL_ENV = 'LEARNING_ENGINE_BASE_URL'; +export const COURSE_PUBLISH_SERVER_BASE_URL_ENV = 'COURSE_PUBLISH_SERVER_BASE_URL'; + +function validatedRuntimeBaseUrl(raw: string, appendRuntimePath: boolean): string | null { + try { + const url = new URL(raw); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') + || url.username + || url.password + || url.search + || url.hash + ) return null; + const pathname = url.pathname.replace(/\/+$/, '') || '/'; + if (appendRuntimePath) { + if (pathname !== '/') return null; + url.pathname = '/api/runtime/v1'; + } else if (pathname !== '/api/runtime/v1') { + return null; + } + return url.href.replace(/\/+$/, ''); + } catch { + return null; + } +} +/** Resolve one environment-owned Engine destination; request data never selects it. */ +export function configuredLearningEngineRuntimeBaseUrl(): string | null { + const runtimeBase = process.env[LEARNING_ENGINE_BASE_URL_ENV]?.trim(); + if (runtimeBase) return validatedRuntimeBaseUrl(runtimeBase, false); + const publishBase = process.env[COURSE_PUBLISH_SERVER_BASE_URL_ENV]?.trim(); + if (publishBase) return validatedRuntimeBaseUrl(publishBase, true); + return null; +} + +function unavailable(message: string): Response { + return Response.json({ error: 'engine_control_unavailable', message }, { status: 503 }); +} + +/** + * Proxy an Ops runner mutation to Engine. Only non-production `all` mode may + * return null and use the legacy in-process development runner. + */ +export async function proxyLargeCourseControlToEngine(options: { + request: Request; + course: CourseRecord; + action: LargeCourseControlAction; + moduleIndex?: number; + fetchImpl?: typeof fetch; +}): Promise { + const runtimeBase = configuredLearningEngineRuntimeBaseUrl(); + if (!runtimeBase) { + if (process.env.NODE_ENV !== 'production' && getServerDeploymentRole() === 'all') return null; + return unavailable( + `${LEARNING_ENGINE_BASE_URL_ENV} or ${COURSE_PUBLISH_SERVER_BASE_URL_ENV} is not configured`, + ); + } + const owner = options.course.ownerPrincipalId?.trim(); + if (!owner) { + return Response.json( + { error: 'course_owner_missing', message: 'Course has no Works owner binding' }, + { status: 409 }, + ); + } + const token = configuredLearningRuntimeToken(); + if (!token) return unavailable('LEARNING_ENGINE_TOKEN is not configured'); + + try { + const response = await (options.fetchImpl ?? fetch)( + `${runtimeBase}/courses/jobs/${encodeURIComponent(options.course.id)}/control`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'X-Owner-User-Id': owner, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + action: options.action, + ...(options.moduleIndex === undefined ? {} : { moduleIndex: options.moduleIndex }), + }), + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }, + ); + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > 1024 * 1024) return unavailable('Engine control response is too large'); + return new Response(bytes, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('content-type') ?? 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } catch { + return unavailable('Learning Engine control endpoint is unavailable'); + } +} diff --git a/OpenMAIC/lib/makelore-runtime/progress-events.ts b/OpenMAIC/lib/makelore-runtime/progress-events.ts new file mode 100644 index 0000000..70d3031 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/progress-events.ts @@ -0,0 +1,17 @@ +export const MAKELORE_PLAYBACK_PROGRESS_EVENT = 'makelore:playback-progress'; + +export type MakelorePlaybackProgress = { + sceneId: string; + sceneOrder: number; + actionIndex: number | null; + completed: boolean; +}; + +/** Publish playback detail only inside the verified offline player surface. */ +export function emitMakelorePlaybackProgress(detail: MakelorePlaybackProgress): void { + if (typeof window === 'undefined' || !window.__MAKELORE_OFFLINE_PLAYER__) return; + window.dispatchEvent(new CustomEvent( + MAKELORE_PLAYBACK_PROGRESS_EVENT, + { detail }, + )); +} diff --git a/OpenMAIC/lib/makelore-runtime/protocol.ts b/OpenMAIC/lib/makelore-runtime/protocol.ts new file mode 100644 index 0000000..721ec83 --- /dev/null +++ b/OpenMAIC/lib/makelore-runtime/protocol.ts @@ -0,0 +1,56 @@ +export type RuntimeBinding = { kind: string; key: string }; + +export type RuntimeOpenRequest = { + session_id: string; + owner_user_id: string; + runtime: string; + runtime_version: string; + binding: RuntimeBinding; +}; + +export type LearningAnchor = { + sceneId?: string; + sceneOrder?: number; + sceneTitle?: string; + actionIndex?: number; + /** Selected module; course binding remains courseId:aggregateContentHash. */ + moduleId?: string | null; + moduleContentHash?: string; +}; + +export type RuntimeRunRequest = RuntimeOpenRequest & { + command_id: string; + run_id: string; + client_command_id: string; + name: string; + input: { + message?: string; + history?: Array<{ role: 'user' | 'assistant'; content: string }>; + anchor?: LearningAnchor; + }; +}; + +export function isRuntimeOpenRequest(value: unknown): value is RuntimeOpenRequest { + if (!value || typeof value !== 'object') return false; + const request = value as Partial; + return request.runtime === 'learning' + && request.runtime_version === 'v1' + && typeof request.session_id === 'string' + && typeof request.owner_user_id === 'string' + && request.binding?.kind === 'learning-course' + && typeof request.binding.key === 'string'; +} + +export function isRuntimeRunRequest(value: unknown): value is RuntimeRunRequest { + if (!isRuntimeOpenRequest(value)) return false; + const request = value as Partial; + const moduleHash = request.input?.anchor?.moduleContentHash; + return typeof request.command_id === 'string' + && typeof request.run_id === 'string' + && typeof request.client_command_id === 'string' + && request.name === 'turn.submit' + && Boolean(request.input) + && typeof request.input?.message === 'string' + && request.input.message.trim().length > 0 + && (moduleHash === undefined || /^[0-9a-f]{64}$/.test(moduleHash)); +} diff --git a/OpenMAIC/lib/ops/api-fetch.ts b/OpenMAIC/lib/ops/api-fetch.ts new file mode 100644 index 0000000..eea58b8 --- /dev/null +++ b/OpenMAIC/lib/ops/api-fetch.ts @@ -0,0 +1,100 @@ +'use client'; + +const WORKS_OPERATIONS_AUTH_STORAGE_KEY = 'works-square.operations.auth.v1'; + +export function worksAccessToken(): string | null { + try { + const raw = window.localStorage.getItem(WORKS_OPERATIONS_AUTH_STORAGE_KEY); + if (!raw) return null; + const session = JSON.parse(raw) as { accessToken?: unknown; expiresAt?: unknown }; + if (typeof session.accessToken !== 'string' || !session.accessToken.trim()) return null; + if (typeof session.expiresAt === 'number' && session.expiresAt <= Date.now()) return null; + return session.accessToken.trim(); + } catch { + return null; + } +} + +export function opsApiPath(pathname: string): string { + const path = pathname.startsWith('/') ? pathname : `/${pathname}`; + return `${process.env.NEXT_PUBLIC_OPENMAIC_BASE_PATH ?? ''}${path}`; +} + +export function opsApiFetch(pathname: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = worksAccessToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + headers.set('Accept', headers.get('Accept') || 'application/json'); + return fetch(opsApiPath(pathname), { ...init, headers }); +} + +const OPS_PUBLIC_ASSET_ROOTS = [ + '/avatars/', + '/logos/', + '/vendor/', + '/mailuo-logo.png', + '/openmaic-mark.png', + '/logo-horizontal.png', +] as const; + +function opsRootRelativePath(pathname: string): string | null { + if (pathname === '/api' || pathname.startsWith('/api/')) return opsApiPath(pathname); + if (OPS_PUBLIC_ASSET_ROOTS.some((root) => pathname === root || pathname.startsWith(root))) { + return opsApiPath(pathname); + } + return null; +} + +/** Install before child effects so legacy root-relative API calls remain basePath-aware. */ +export function installOpsBrowserBoundary(): () => void { + if (process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE !== 'ops') return () => undefined; + const nativeFetch = window.fetch.bind(window); + const wrappedFetch: typeof window.fetch = (input, init = {}) => { + const mapped = typeof input === 'string' ? opsRootRelativePath(input) : null; + if (!mapped) return nativeFetch(input, init); + if (typeof input !== 'string' || !input.startsWith('/api')) return nativeFetch(mapped, init); + const headers = new Headers(init.headers); + const token = worksAccessToken(); + if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`); + return nativeFetch(mapped, { ...init, headers }); + }; + window.fetch = wrappedFetch; + + const rewriteElement = (element: Element) => { + for (const attribute of ['src', 'poster']) { + const value = element.getAttribute(attribute); + if (!value) continue; + const mapped = opsRootRelativePath(value); + if (mapped && mapped !== value && !value.startsWith('/api')) { + element.setAttribute(attribute, mapped); + } + } + }; + const rewriteTree = (root: ParentNode) => { + if (root instanceof Element) rewriteElement(root); + root.querySelectorAll('[src], [poster]').forEach(rewriteElement); + }; + rewriteTree(document); + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') rewriteElement(mutation.target as Element); + for (const node of mutation.addedNodes) { + if (node instanceof Element) rewriteTree(node); + } + } + }); + observer.observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['src', 'poster'], + }); + + let closed = false; + return () => { + if (closed) return; + closed = true; + observer.disconnect(); + if (window.fetch === wrappedFetch) window.fetch = nativeFetch; + }; +} diff --git a/OpenMAIC/lib/pbl/v2/operations/kernel/engagement.ts b/OpenMAIC/lib/pbl/v2/operations/kernel/engagement.ts index 61022a0..4e354d4 100644 --- a/OpenMAIC/lib/pbl/v2/operations/kernel/engagement.ts +++ b/OpenMAIC/lib/pbl/v2/operations/kernel/engagement.ts @@ -1,2 +1,2 @@ /** Compatibility barrel for package-owned PBL engagement primitives. */ -export * from '@openmaic/generation'; +export * from '@openmaic/generation/pbl/operations/kernel/engagement'; diff --git a/OpenMAIC/lib/pbl/v2/operations/kernel/proficiency.ts b/OpenMAIC/lib/pbl/v2/operations/kernel/proficiency.ts index 2bb30c6..d2020fb 100644 --- a/OpenMAIC/lib/pbl/v2/operations/kernel/proficiency.ts +++ b/OpenMAIC/lib/pbl/v2/operations/kernel/proficiency.ts @@ -1,2 +1,2 @@ /** Compatibility barrel for package-owned PBL proficiency primitives. */ -export * from '@openmaic/generation'; +export * from '@openmaic/generation/pbl/operations/kernel/proficiency'; diff --git a/OpenMAIC/lib/pbl/v2/operations/kernel/progress.ts b/OpenMAIC/lib/pbl/v2/operations/kernel/progress.ts index d8c5648..1e3a78a 100644 --- a/OpenMAIC/lib/pbl/v2/operations/kernel/progress.ts +++ b/OpenMAIC/lib/pbl/v2/operations/kernel/progress.ts @@ -1,2 +1,2 @@ /** Compatibility barrel for package-owned PBL progress primitives. */ -export * from '@openmaic/generation'; +export * from '@openmaic/generation/pbl/operations/kernel/progress'; diff --git a/OpenMAIC/lib/pbl/v2/operations/kernel/runtime-events.ts b/OpenMAIC/lib/pbl/v2/operations/kernel/runtime-events.ts index 386a21d..8db81f3 100644 --- a/OpenMAIC/lib/pbl/v2/operations/kernel/runtime-events.ts +++ b/OpenMAIC/lib/pbl/v2/operations/kernel/runtime-events.ts @@ -1,2 +1,2 @@ /** Compatibility barrel for package-owned PBL runtime-event primitives. */ -export * from '@openmaic/generation'; +export * from '@openmaic/generation/pbl/operations/kernel/runtime-events'; diff --git a/OpenMAIC/lib/pbl/v2/operations/kernel/task-completion.ts b/OpenMAIC/lib/pbl/v2/operations/kernel/task-completion.ts index 69ae446..956260c 100644 --- a/OpenMAIC/lib/pbl/v2/operations/kernel/task-completion.ts +++ b/OpenMAIC/lib/pbl/v2/operations/kernel/task-completion.ts @@ -1,2 +1,2 @@ /** Compatibility barrel for package-owned PBL task-completion primitives. */ -export * from '@openmaic/generation'; +export * from '@openmaic/generation/pbl/operations/kernel/task-completion'; diff --git a/OpenMAIC/lib/pbl/v2/operations/runtime/eval-tail-parser.ts b/OpenMAIC/lib/pbl/v2/operations/runtime/eval-tail-parser.ts index cdbfe83..51048fa 100644 --- a/OpenMAIC/lib/pbl/v2/operations/runtime/eval-tail-parser.ts +++ b/OpenMAIC/lib/pbl/v2/operations/runtime/eval-tail-parser.ts @@ -23,7 +23,7 @@ * we encode them all up front here. */ -import { parseJsonResponse } from '@openmaic/generation'; +import { parseJsonResponse } from '@openmaic/generation/json-repair'; const FENCED_JSON_RE = /```json\s*\n([\s\S]*?)\n\s*```/g; const ANY_FENCED_JSON_RE = /```(?:json)?\s*([\s\S]*?)```/g; diff --git a/OpenMAIC/lib/persistence/http-route.ts b/OpenMAIC/lib/persistence/http-route.ts new file mode 100644 index 0000000..4458281 --- /dev/null +++ b/OpenMAIC/lib/persistence/http-route.ts @@ -0,0 +1,277 @@ +import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; + +import { PgAssetStore, ensureAssetSchema } from '@openmaic/storage/asset/pg'; +import { PgDocumentStore, ensureDocumentSchema } from '@openmaic/storage/document/pg'; +import { PgRuntimeStore, ensureSchema } from '@openmaic/storage/runtime/pg'; +import { createStorageHttpHandler } from '@openmaic/storage/server'; +import { + nodePostgresTransaction, + type ConnectableQueryable, +} from '@openmaic/storage/server/reference'; +import { Pool } from 'pg'; + +import { validateAppScene, validateAppStage } from '@/lib/document-store/validators'; +import { lazyAssetByteStore } from '@/lib/persistence/asset-byte-store'; +import { authenticatePersistenceRequest } from '@/lib/persistence/server-auth'; +import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators'; + +const ROUTE_PREFIX = '/api/persistence'; + +type PoolFactory = (connectionString: string) => Pool; + +interface PersistenceHandlerState { + connectionString?: string; + handlerPromise?: Promise; +} + +const HANDLER_STATE_KEY = Symbol.for('openmaic.persistence-route.handler'); +const globalState = globalThis as typeof globalThis & { + [key: symbol]: PersistenceHandlerState | undefined; +}; +const handlerState = (globalState[HANDLER_STATE_KEY] ??= {}); + +function jsonError(status: number, code: string, message: string): Response { + return Response.json({ error: { code, message } }, { status }); +} + +async function createPersistenceHandler( + connectionString: string, + poolFactory: PoolFactory, +): Promise { + const pool = poolFactory(connectionString); + const queryable = pool as unknown as ConnectableQueryable; + try { + await ensureSchema(queryable); + await ensureDocumentSchema(queryable); + await ensureAssetSchema(queryable); + const withTransaction = nodePostgresTransaction(queryable); + // Deferred to first asset use: the asset backend is optional, so its + // misconfiguration (an invalid ASSET_S3_BUCKET, an unresolvable AWS SDK) + // must fail asset requests only, never this handler's initialization. + const byteStore = lazyAssetByteStore(process.env.ASSET_S3_BUCKET, queryable); + const runtimeStore = new PgRuntimeStore(queryable, { + withTransaction, + payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS, + }); + const documentStore = new PgDocumentStore(queryable, { + withTransaction, + validateScene: validateAppScene, + validateStage: validateAppStage, + }); + // The asset contract requires a server-derived principal; this development + // authenticator instead takes the partition key from a client-supplied header. + // Cross-principal isolation is therefore not in force: asset bytes are as + // reachable as documents and runtime records under this authenticator. Before + // asset routes carry anything that matters, production must replace + // authenticatePersistenceRequest with real session verification. See + // lib/persistence/server-auth.ts for the token's limits. + const assetStore = new PgAssetStore(queryable, { withTransaction, byteStore }); + // Reclamation is not scheduled from here, and must not be: a route module + // has no once-per-process guarantee and no shutdown hook. AssetCollector + // runs from instrumentation.ts instead, over the byte store this same + // lib/persistence/asset-byte-store selection produces, so the collector + // always deletes through the layer the request path wrote through. + return createStorageHttpHandler(runtimeStore, documentStore, { + authenticate: authenticatePersistenceRequest, + authorizeMerge: async () => false, + authorizeAdmin: async () => false, + authorizeDocuments: async () => true, + validateScene: validateAppScene, + validateStage: validateAppStage, + payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS, + assetStore, + }); + } catch (error) { + await pool.end().catch(() => {}); + throw error; + } +} + +function getPersistenceHandler( + connectionString: string, + poolFactory: PoolFactory, +): Promise { + if (handlerState.handlerPromise && handlerState.connectionString === connectionString) { + return handlerState.handlerPromise; + } + + handlerState.connectionString = connectionString; + const initialization = createPersistenceHandler(connectionString, poolFactory).catch((error) => { + // Do not poison the singleton with a rejected promise. createPersistenceHandler + // has already closed its failed pool, and the next request gets a clean retry. + if (handlerState.handlerPromise === initialization) { + handlerState.handlerPromise = undefined; + handlerState.connectionString = undefined; + } + throw error; + }); + handlerState.handlerPromise = initialization; + return initialization; +} + +function nodeRequest(request: Request): IncomingMessage { + const url = new URL(request.url); + const pathname = url.pathname.startsWith(ROUTE_PREFIX) + ? url.pathname.slice(ROUTE_PREFIX.length) || '/' + : url.pathname; + const body = request.body + ? Readable.fromWeb( + request.body as unknown as import('node:stream/web').ReadableStream, + ) + : Readable.from([]); + return Object.assign(body, { + method: request.method, + url: `${pathname}${url.search}`, + headers: Object.fromEntries(request.headers.entries()), + }) as IncomingMessage; +} + +function setHeaders(target: Headers, source: Record): void { + for (const [name, value] of Object.entries(source)) { + if (Array.isArray(value)) { + for (const item of value) target.append(name, item); + } else { + target.set(name, String(value)); + } + } +} + +type ResponseCallback = () => void; + +function responseEncoding(encodingOrCallback?: BufferEncoding | ResponseCallback): BufferEncoding { + const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8'; + if (!Buffer.isEncoding(encoding)) { + // Let Buffer produce Node's ERR_UNKNOWN_ENCODING TypeError. + Buffer.from('', encoding); + } + return encoding; +} + +function responseCallback( + encodingOrCallback?: BufferEncoding | ResponseCallback, + callback?: ResponseCallback, +): ResponseCallback | undefined { + return typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; +} + +function suppressesResponseBody(request: Request, status: number): boolean { + return request.method === 'HEAD' || status === 204 || status === 205 || status === 304; +} + +function runNodeHandler(handler: RequestListener, request: Request): Promise { + return new Promise((resolve, reject) => { + let status = 200; + const headers = new Headers(); + let headersSent = false; + // Buffered as bytes rather than as a string. A handler may end with a + // `Uint8Array`, which `ServerResponse.end` accepts and which is not + // necessarily valid UTF-8; decoding it would replace every unpaired byte + // with U+FFFD and silently corrupt the response. + const body: Buffer[] = []; + + const appendChunk = (chunk: string | Uint8Array, encoding: BufferEncoding) => { + body.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : Buffer.from(chunk)); + }; + + const response = { + get headersSent() { + return headersSent; + }, + writeHead( + statusCode: number, + statusMessageOrHeaders?: string | Record, + outgoingHeaders?: Record, + ) { + status = statusCode; + headersSent = true; + const values = + typeof statusMessageOrHeaders === 'string' ? outgoingHeaders : statusMessageOrHeaders; + if (values) setHeaders(headers, values); + return this; + }, + write( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ResponseCallback, + callback?: ResponseCallback, + ) { + // `write` is part of the `ServerResponse` surface this object claims to + // implement. Omitting it made any chunked handler a runtime TypeError + // that the `as unknown as ServerResponse` cast hid from the compiler. + headersSent = true; + appendChunk(chunk, responseEncoding(encodingOrCallback)); + const done = responseCallback(encodingOrCallback, callback); + if (done) process.nextTick(done); + return true; + }, + end( + chunkOrCallback?: string | Uint8Array | ResponseCallback, + encodingOrCallback?: BufferEncoding | ResponseCallback, + callback?: ResponseCallback, + ) { + headersSent = true; + const chunk = typeof chunkOrCallback === 'function' ? undefined : chunkOrCallback; + const done = + typeof chunkOrCallback === 'function' + ? chunkOrCallback + : responseCallback(encodingOrCallback, callback); + if (chunk !== undefined) appendChunk(chunk, responseEncoding(encodingOrCallback)); + resolve( + new Response( + suppressesResponseBody(request, status) || body.length === 0 + ? undefined + : Buffer.concat(body), + { + status, + headers, + }, + ), + ); + if (done) process.nextTick(done); + return this; + }, + destroy(error?: Error) { + reject(error ?? new Error('Persistence HTTP handler destroyed the response')); + return this; + }, + } as unknown as ServerResponse; + + try { + handler(nodeRequest(request), response); + } catch (error) { + reject(error); + } + }); +} + +interface PersistenceRequestDeps { + poolFactory?: PoolFactory; +} + +export async function handlePersistenceRequest( + request: Request, + deps: PersistenceRequestDeps = {}, +): Promise { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + return jsonError(404, 'PERSISTENCE_NOT_CONFIGURED', 'server persistence not configured'); + } + if (!process.env.PERSISTENCE_DEV_TOKEN) { + return jsonError( + 503, + 'PERSISTENCE_DEV_TOKEN_MISSING', + 'server persistence requires PERSISTENCE_DEV_TOKEN (development auth only)', + ); + } + + try { + const poolFactory = deps.poolFactory ?? ((value) => new Pool({ connectionString: value })); + return await runNodeHandler( + await getPersistenceHandler(connectionString, poolFactory), + request, + ); + } catch (error) { + console.error('Embedded persistence route initialization failed', error); + return jsonError(500, 'PERSISTENCE_INIT_FAILED', 'server persistence initialization failed'); + } +} diff --git a/OpenMAIC/lib/qa/knowledge.ts b/OpenMAIC/lib/qa/knowledge.ts index a3bd73c..e5b539f 100644 --- a/OpenMAIC/lib/qa/knowledge.ts +++ b/OpenMAIC/lib/qa/knowledge.ts @@ -11,7 +11,8 @@ import type { KnowledgePack, QuizPack } from '@/lib/bundle/types'; export interface CoursewareKnowledge { coursewareId: string; - version: number; + /** Present for legacy multi-publish courseware records; unique Makelore courses omit it. */ + version?: number; language?: string; title: string; knowledge: KnowledgePack; diff --git a/OpenMAIC/lib/server/classroom-courseware-publish.ts b/OpenMAIC/lib/server/classroom-courseware-publish.ts index 49c8afe..a3d9dee 100644 --- a/OpenMAIC/lib/server/classroom-courseware-publish.ts +++ b/OpenMAIC/lib/server/classroom-courseware-publish.ts @@ -47,6 +47,10 @@ export interface PackagePersistedClassroomOptions { /** Test seam. Production uses the SSRF-guarded server fetch below. */ fetchImpl?: typeof fetch; publishedAt?: string; + /** Require interactive HTML only when the product request asked for it. */ + requireInteractiveHtml?: boolean; + /** Strict when generation requested TTS; false allows intentional silent narration. */ + requireNarrationAudio?: boolean; } export interface PublishPersistedClassroomOptions extends Omit< @@ -426,7 +430,8 @@ export async function packagePersistedClassroom( publishedAt: options.publishedAt ?? new Date().toISOString(), appVersion: process.env.npm_package_version ?? '0.0.0', requireAgentRoster: true, - requireInteractiveHtml: true, + requireInteractiveHtml: options.requireInteractiveHtml ?? true, + requireNarrationAudio: options.requireNarrationAudio ?? true, strictInteractiveAssets: true, requireComplete: true, }); diff --git a/OpenMAIC/lib/server/classroom-generation.ts b/OpenMAIC/lib/server/classroom-generation.ts index fa05f18..2bbd8f1 100644 --- a/OpenMAIC/lib/server/classroom-generation.ts +++ b/OpenMAIC/lib/server/classroom-generation.ts @@ -62,6 +62,8 @@ export interface GenerateClassroomInput { enableTTS?: boolean; /** Use the original app Interactive Mode outline prompt for this classroom. */ interactiveMode?: boolean; + /** Enable the original vocational Task Engine outline and scene path. */ + taskEngineMode?: boolean; } export type ClassroomGenerationStep = @@ -343,6 +345,7 @@ export async function generateClassroom( const requirements: UserRequirements = { requirement, ...(input.interactiveMode ? { interactiveMode: true } : {}), + ...(input.taskEngineMode ? { taskEngineMode: true } : {}), }; const vocationalActive = resolveVocationalActive(requirements); const pdfText = pdfContent?.text || undefined; diff --git a/OpenMAIC/lib/server/classroom-job-runner.ts b/OpenMAIC/lib/server/classroom-job-runner.ts index d75b780..de61665 100644 --- a/OpenMAIC/lib/server/classroom-job-runner.ts +++ b/OpenMAIC/lib/server/classroom-job-runner.ts @@ -44,7 +44,7 @@ export async function resumeClassroomGenerationJob( ): Promise { const job = await readClassroomGenerationJob(jobId); if (!job) return false; - if (job.status === 'running' || job.status === 'queued') return false; + if (job.status !== 'failed' && job.status !== 'cancelled') return false; if (!job.input) return false; await updateClassroomGenerationJob(jobId, { diff --git a/OpenMAIC/lib/server/classroom-storage.ts b/OpenMAIC/lib/server/classroom-storage.ts index 4f4480c..3ebfd33 100644 --- a/OpenMAIC/lib/server/classroom-storage.ts +++ b/OpenMAIC/lib/server/classroom-storage.ts @@ -11,9 +11,11 @@ import { } from '@/lib/server/authz/owner-binding'; export const CLASSROOMS_DIR = - process.env.CLASSROOM_DATA_DIR ?? path.join(process.cwd(), 'data', 'classrooms'); + process.env.CLASSROOM_DATA_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'classrooms'); export const CLASSROOM_JOBS_DIR = - process.env.CLASSROOM_JOBS_DIR ?? path.join(process.cwd(), 'data', 'classroom-jobs'); + process.env.CLASSROOM_JOBS_DIR ?? + path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'classroom-jobs'); async function ensureDir(dir: string) { await fs.mkdir(dir, { recursive: true }); diff --git a/OpenMAIC/lib/server/course-publish-route.ts b/OpenMAIC/lib/server/course-publish-route.ts new file mode 100644 index 0000000..b67bb66 --- /dev/null +++ b/OpenMAIC/lib/server/course-publish-route.ts @@ -0,0 +1,285 @@ +// Server-only transactional large-course ingestion. +// +// One multipart request carries `metadata` JSON plus `module-{index}` ZIPs. +// The server stages every frozen bundle as unpublished, promotes the complete +// batch, commits one schema-v2 manifest, and compensates this batch back to +// unpublished on failure. Learner APIs never expose sourceClassroomId. + +import { type NextRequest, NextResponse } from 'next/server'; +import { CoursePublishInProgressError } from '@/lib/course-framework/publish-state'; +import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types'; +import { + COURSEWARE_BUNDLES_DIR, + createFileBundleByteStore, + type BundleByteStore, +} from '@/lib/courseware-repo/bundle-store'; +import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; +import { + COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, + COURSEWARE_PUBLIC_BASE_URL_ENV, + CoursePublishTransportError, + DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, + DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, + MAX_COURSE_PUBLISH_METADATA_BYTES, + parseRemoteCoursePublishMetadata, + positiveIntegerEnv, + resolveCoursewarePublicBaseUrl, + type RemoteCoursePublishErrorBody, + type RemoteCoursePublishSuccessBody, +} from '@/lib/server/course-publish-contract'; +import { + commitRemoteCoursePublish, + type CoursePublishTransactionRepos, +} from '@/lib/server/course-publish-transaction'; +import { buildRequestOrigin } from '@/lib/server/classroom-storage'; +import { capBodyStream } from '@/lib/server/capped-stream'; +import { createLogger } from '@/lib/logger'; +import { + COURSEWARE_PUBLISH_TOKEN_ENV, + isPublishTokenConfigured, + verifyPublishToken, +} from '@/lib/courseware-repo'; +import type { CoursewareRepo } from '@/lib/courseware-repo/types'; +import { getServerDeploymentRole } from '@/lib/server/ops-access'; + +const log = createLogger('Internal Course Publish API'); + +export interface CoursePublishRouteDependencies { + coursewares?: CoursewareRepo; + bundles?: BundleByteStore; + manifests?: CourseManifestRepo; + publicBaseUrl?: string; +} + +function bearerToken(request: NextRequest): string | null { + const header = request.headers.get('authorization'); + if (!header?.startsWith('Bearer ')) return null; + return header.slice('Bearer '.length).trim() || null; +} + +function errorResponse(error: CoursePublishTransportError): NextResponse { + const body: RemoteCoursePublishErrorBody = { + success: false, + errorCode: error.errorCode, + error: error.message, + phase: error.phase, + ...(error.moduleIndex ? { moduleIndex: error.moduleIndex } : {}), + ...(error.details ? { details: error.details } : {}), + }; + return NextResponse.json(body, { status: error.status }); +} + +function routeRepos(deps: CoursePublishRouteDependencies): Partial { + return { + coursewares: deps.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR), + bundles: deps.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR), + ...(deps.manifests ? { manifests: deps.manifests } : {}), + }; +} + +function canonicalPublicBaseUrl( + request: NextRequest, + deps: CoursePublishRouteDependencies, +): string { + const configured = + deps.publicBaseUrl?.trim() || process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim(); + if (!configured && process.env.NODE_ENV === 'production') { + throw new CoursePublishTransportError( + 'PUBLIC_URL_MISSING', + `${COURSEWARE_PUBLIC_BASE_URL_ENV} is required on a production server`, + 'request', + 503, + ); + } + return resolveCoursewarePublicBaseUrl(configured || buildRequestOrigin(request)); +} + +export async function handleCoursePublishRequest( + request: NextRequest, + deps: CoursePublishRouteDependencies = {}, +): Promise { + const role = getServerDeploymentRole(); + if (role !== 'server' && role !== 'all') { + return errorResponse( + new CoursePublishTransportError( + 'FORBIDDEN', + 'Transactional course publishing is only available on the server deployment', + 'request', + 403, + ), + ); + } + if (!isPublishTokenConfigured()) { + return errorResponse( + new CoursePublishTransportError( + 'PUBLISH_DISABLED', + `Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`, + 'request', + 503, + ), + ); + } + const token = bearerToken(request); + if (!verifyPublishToken(token)) { + return errorResponse( + new CoursePublishTransportError( + 'UNAUTHORIZED', + 'Invalid or missing publish token', + 'request', + 401, + ), + ); + } + + try { + if (!request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) { + throw new CoursePublishTransportError( + 'INVALID_CONTENT_TYPE', + 'Course publish requires multipart/form-data', + 'request', + 415, + ); + } + const maxUploadBytes = positiveIntegerEnv( + COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV, + DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES, + ); + const declaredLength = Number(request.headers.get('content-length') ?? ''); + if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) { + throw new CoursePublishTransportError( + 'COURSE_UPLOAD_TOO_LARGE', + `Course publish exceeds ${maxUploadBytes} bytes`, + 'request', + 413, + ); + } + if (!request.body) { + throw new CoursePublishTransportError( + 'BODY_MISSING', + 'Course publish body is missing', + 'request', + 400, + ); + } + + const capped = capBodyStream(request.body, maxUploadBytes); + let form: FormData; + try { + form = await new Response(capped.stream, { + headers: { 'content-type': request.headers.get('content-type')! }, + }).formData(); + } catch (error) { + if (capped.exceeded()) { + throw new CoursePublishTransportError( + 'COURSE_UPLOAD_TOO_LARGE', + `Course publish exceeds ${maxUploadBytes} bytes`, + 'request', + 413, + ); + } + throw new CoursePublishTransportError( + 'MULTIPART_INVALID', + 'Course publish multipart body could not be parsed', + 'request', + 400, + undefined, + error instanceof Error ? error.message : undefined, + ); + } + + const metadataField = form.get('metadata'); + if (typeof metadataField !== 'string') { + throw new CoursePublishTransportError( + 'METADATA_MISSING', + 'Course publish metadata is missing', + 'request', + 400, + ); + } + if (new TextEncoder().encode(metadataField).byteLength > MAX_COURSE_PUBLISH_METADATA_BYTES) { + throw new CoursePublishTransportError( + 'METADATA_TOO_LARGE', + 'Course publish metadata exceeds the size limit', + 'request', + 413, + ); + } + let metadataValue: unknown; + try { + metadataValue = JSON.parse(metadataField) as unknown; + } catch { + throw new CoursePublishTransportError( + 'INVALID_METADATA', + 'Course publish metadata is not valid JSON', + 'request', + 400, + ); + } + const metadata = parseRemoteCoursePublishMetadata(metadataValue); + const perModuleLimit = positiveIntegerEnv( + 'COURSEWARE_MAX_UPLOAD_BYTES', + DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES, + ); + const archives = metadata.modules.map((moduleRecord) => { + const fieldName = `module-${moduleRecord.index}`; + const fields = form.getAll(fieldName); + if (fields.length !== 1 || !(fields[0] instanceof File)) { + throw new CoursePublishTransportError( + 'MODULE_ARCHIVE_MISSING', + `Module ${moduleRecord.index} requires exactly one ZIP`, + 'request', + 400, + moduleRecord.index, + ); + } + const file = fields[0]; + if (file.size > perModuleLimit) { + throw new CoursePublishTransportError( + 'MODULE_ARCHIVE_TOO_LARGE', + `Module ${moduleRecord.index} ZIP exceeds ${perModuleLimit} bytes`, + 'request', + 413, + moduleRecord.index, + ); + } + return { index: moduleRecord.index, file }; + }); + const loadedArchives = await Promise.all( + archives.map(async ({ index, file }) => ({ + index, + zipBytes: new Uint8Array(await file.arrayBuffer()), + })), + ); + const result = await commitRemoteCoursePublish({ + metadata, + archives: loadedArchives, + token, + publicBaseUrl: canonicalPublicBaseUrl(request, deps), + repos: routeRepos(deps), + }); + const body: RemoteCoursePublishSuccessBody = { + success: true, + record: result.record, + idempotent: result.idempotent, + }; + return NextResponse.json(body, { status: result.idempotent ? 200 : 201 }); + } catch (error) { + if (error instanceof CoursePublishTransportError) return errorResponse(error); + if (error instanceof CoursePublishInProgressError) { + return errorResponse( + new CoursePublishTransportError('PUBLISH_IN_PROGRESS', error.message, 'request', 409), + ); + } + log.error('Transactional course publish failed:', error); + return errorResponse( + new CoursePublishTransportError( + 'INTERNAL_ERROR', + 'Transactional course publish failed', + 'commit', + 500, + undefined, + error instanceof Error ? error.message : undefined, + ), + ); + } +} diff --git a/OpenMAIC/lib/server/managed-course-materials.ts b/OpenMAIC/lib/server/managed-course-materials.ts new file mode 100644 index 0000000..d7d9c92 --- /dev/null +++ b/OpenMAIC/lib/server/managed-course-materials.ts @@ -0,0 +1,229 @@ +import { nanoid } from 'nanoid'; +import { + buildDocumentBundle, + documentArtifactToParsedPdfContent, + getDocumentExtractorProviders, + getMediaExtractorProviders, + MAX_DOCUMENT_BUNDLE_FILES, + MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES, +} from '@/lib/document'; +import { normalizeDocumentMimeType, SUPPORTED_MEDIA_MIME_TYPES } from '@/lib/document/mime'; +import type { MediaArtifact } from '@/lib/document/types'; +import { + getServerPDFProviders, + isServerConfiguredProvider, + resolveManagedAliDocMindCredentials, + resolvePDFApiKey, + resolvePDFBaseUrl, +} from '@/lib/server/provider-config'; + +export const MAX_COURSE_MATERIAL_FILE_SIZE_BYTES = 50 * 1024 * 1024; + +export class CourseMaterialError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string, + ) { + super(message); + this.name = 'CourseMaterialError'; + } +} + +function mediaArtifactText(artifact: MediaArtifact): string { + const sections: string[] = []; + const synopsis = + artifact.providerRaw && + typeof artifact.providerRaw === 'object' && + 'synopsis' in artifact.providerRaw + ? String((artifact.providerRaw as { synopsis?: unknown }).synopsis ?? '').trim() + : ''; + if (synopsis) sections.push(`## Synopsis\n\n${synopsis}`); + if (artifact.transcript?.length) { + const transcript = artifact.transcript + .filter((segment) => segment.text.trim()) + .map((segment) => `[${Math.floor(segment.startMs / 1000)}s] ${segment.text.trim()}`) + .join('\n'); + if (transcript) sections.push(`## Transcript\n\n${transcript}`); + } + if (artifact.keyframes?.length) { + const keyframes = artifact.keyframes + .map((keyframe) => keyframe.description || keyframe.ocrText || '') + .filter(Boolean) + .join('\n'); + if (keyframes) sections.push(`## Keyframes\n\n${keyframes}`); + } + return sections.join('\n\n'); +} + +function managedDocumentProvider(mimeType: string) { + const configured = new Set(Object.keys(getServerPDFProviders())); + const candidates = getDocumentExtractorProviders().filter( + (provider) => + provider.supportedMimeTypes.includes(mimeType) && + (provider.id === 'plain-text' || provider.id === 'unpdf' || configured.has(provider.id)), + ); + // Prefer an operator-managed high-fidelity extractor. Plain text and unpdf + // are safe in-process fallbacks and are selected only when no managed + // provider for the MIME exists. + return ( + candidates.find((provider) => isServerConfiguredProvider('pdf', provider.id)) ?? candidates[0] + ); +} + +function managedMediaProvider(mimeType: string) { + return getMediaExtractorProviders().find( + (provider) => + provider.supportedMimeTypes.includes(mimeType) && + isServerConfiguredProvider('pdf', provider.id), + ); +} + +function managedExtractorConfig(providerId: string) { + const ali = providerId === 'alidocmind' ? resolveManagedAliDocMindCredentials() : undefined; + return { + providerId, + apiKey: resolvePDFApiKey(providerId), + baseUrl: ali?.baseUrl ?? resolvePDFBaseUrl(providerId), + accessKeyId: ali?.accessKeyId, + accessKeySecret: ali?.accessKeySecret, + allowEnvFallback: isServerConfiguredProvider('pdf', providerId), + }; +} + +/** + * Extract files with server-managed providers and fold them into the exact + * pdfContent shape consumed by the original classroom/framework runners. + * Caller-supplied provider credentials and storage keys never enter this API. + */ +export async function extractManagedCourseMaterials( + files: readonly File[], +): Promise<{ text: string; images: string[] }> { + if (files.length === 0) return { text: '', images: [] }; + if (files.length > MAX_DOCUMENT_BUNDLE_FILES) { + throw new CourseMaterialError( + 'too_many_materials', + 413, + `At most ${MAX_DOCUMENT_BUNDLE_FILES} course materials are allowed`, + ); + } + const totalBytes = files.reduce((total, file) => total + file.size, 0); + if (totalBytes > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) { + throw new CourseMaterialError( + 'materials_too_large', + 413, + 'Course materials exceed the 150 MiB aggregate limit', + ); + } + + const parts = await Promise.all( + files.map(async (file, index) => { + if (file.size <= 0 || file.size > MAX_COURSE_MATERIAL_FILE_SIZE_BYTES) { + throw new CourseMaterialError( + 'material_too_large', + 413, + `Material "${file.name}" must be between 1 byte and 50 MiB`, + ); + } + const mimeType = normalizeDocumentMimeType({ mimeType: file.type, fileName: file.name }); + if (!mimeType) { + throw new CourseMaterialError( + 'unsupported_material', + 415, + `Unsupported course material type for "${file.name}"`, + ); + } + const buffer = Buffer.from(await file.arrayBuffer()); + const source = { + id: `material_${nanoid(8)}`, + name: file.name || `material-${index + 1}`, + size: file.size, + mimeType, + order: index + 1, + }; + + if (SUPPORTED_MEDIA_MIME_TYPES.includes(mimeType)) { + const provider = managedMediaProvider(mimeType); + if (!provider) { + throw new CourseMaterialError( + 'extractor_not_configured', + 422, + `No server-managed media extractor supports "${file.name}"`, + ); + } + const artifact = await provider.extract({ + buffer, + fileName: file.name, + fileSize: file.size, + mimeType, + config: managedExtractorConfig(provider.id), + }); + const text = mediaArtifactText(artifact); + if (!text.trim()) { + throw new CourseMaterialError( + 'material_parse_failed', + 422, + `No usable transcript or synopsis was extracted from "${file.name}"`, + ); + } + return { source, text, rawTextLength: text.length, images: [] }; + } + + const provider = managedDocumentProvider(mimeType); + if (!provider) { + throw new CourseMaterialError( + 'extractor_not_configured', + 422, + `No server-managed document extractor supports "${file.name}"`, + ); + } + const artifact = await provider.extract({ + buffer, + fileName: file.name, + fileSize: file.size, + mimeType, + config: managedExtractorConfig(provider.id), + }); + const parsed = documentArtifactToParsedPdfContent(artifact); + if (!parsed.text.trim() && parsed.images.length === 0) { + throw new CourseMaterialError( + 'material_parse_failed', + 422, + `No usable content was extracted from "${file.name}"`, + ); + } + const fallbackImages = parsed.images.map((src, imageIndex) => ({ + id: `img_${imageIndex + 1}`, + src, + pageNumber: 0, + description: undefined, + width: undefined, + height: undefined, + })); + const images = (parsed.metadata?.pdfImages ?? fallbackImages).map((image) => ({ + id: image.id, + src: image.src, + pageNumber: image.pageNumber, + description: image.description, + width: image.width, + height: image.height, + })); + return { + source, + text: parsed.text, + rawTextLength: parsed.text.length, + pageCount: parsed.metadata?.pageCount, + images, + }; + }), + ); + + const bundle = buildDocumentBundle(parts); + return { + text: bundle.text, + images: bundle.images + .filter((image) => image.visionPriority > 0) + .sort((a, b) => b.visionPriority - a.visionPriority) + .map((image) => image.src), + }; +} diff --git a/OpenMAIC/lib/server/ops-access.ts b/OpenMAIC/lib/server/ops-access.ts index bc6ff76..b8f73f6 100644 --- a/OpenMAIC/lib/server/ops-access.ts +++ b/OpenMAIC/lib/server/ops-access.ts @@ -6,6 +6,8 @@ import { apiError, API_ERROR_CODES } from '@/lib/server/api-response'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); export const OPS_PUBLIC_ORIGIN_ENV = 'OPS_PUBLIC_ORIGIN'; +export const WORKS_SQUARE_API_BASE_URL_ENV = 'WORKS_SQUARE_API_BASE_URL'; +export const WORKS_OPERATIONS_ADMIN_USERNAME = 'jiaoyuop'; function configuredOpsOrigin(): string | null | undefined { const configured = process.env[OPS_PUBLIC_ORIGIN_ENV]?.trim(); @@ -69,31 +71,85 @@ export function isOpsDeployment(role = getServerDeploymentRole()): boolean { * then authenticates the browser session. A future account/role system can * replace this helper without changing every course route again. */ -export function requireOpsAccess(request: NextRequest) { - if (!isOpsDeployment()) { - return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled'); - } - - const accessCode = process.env.ACCESS_CODE; - if (!accessCode) { - if (process.env.NODE_ENV === 'production') { - return apiError( - API_ERROR_CODES.INTERNAL_ERROR, - 503, - 'Operations deployment is not configured with ACCESS_CODE', - ); +export function configuredWorksSquareApiOrigin(): string | null { + const raw = process.env[WORKS_SQUARE_API_BASE_URL_ENV]?.trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + return null; } + return url.origin; + } catch { return null; } +} - const token = request.cookies.get(ACCESS_CODE_COOKIE_NAME)?.value; - if (!token || !verifyAccessToken(token, accessCode)) { - return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Operations access required'); +async function authorizeWithWorksSession(request: NextRequest, origin: string) { + const authorization = request.headers.get('authorization')?.trim() ?? ''; + if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) { + return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session required'); + } + try { + const response = await fetch(`${origin}/api/auth/me`, { + method: 'GET', + headers: { Authorization: authorization, Accept: 'application/json' }, + redirect: 'error', + cache: 'no-store', + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session is invalid'); + } + const identity = (await response.json()) as { username?: unknown; site_role?: unknown }; + if ( + identity.username !== WORKS_OPERATIONS_ADMIN_USERNAME || + identity.site_role !== 'admin' + ) { + return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Works administrator role required'); + } + return null; + } catch { + return apiError(API_ERROR_CODES.INTERNAL_ERROR, 503, 'Works identity service is unavailable'); + } +} + +export async function requireOpsAccess(request: NextRequest) { + if (!isOpsDeployment()) { + return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled'); } if (!hasValidOpsMutationOrigin(request)) { return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied'); } + const worksOrigin = configuredWorksSquareApiOrigin(); + if (worksOrigin) return authorizeWithWorksSession(request, worksOrigin); + + // Production Learning Ops always delegates identity to Works. The legacy + // ACCESS_CODE fallback is deliberately development-only. + if (process.env.NODE_ENV === 'production') { + return apiError( + API_ERROR_CODES.INTERNAL_ERROR, + 503, + `Operations identity is not configured (${WORKS_SQUARE_API_BASE_URL_ENV})`, + ); + } + + const accessCode = process.env.ACCESS_CODE; + if (!accessCode) return null; + + const token = request.cookies.get(ACCESS_CODE_COOKIE_NAME)?.value; + if (!token || !verifyAccessToken(token, accessCode)) { + return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Operations access required'); + } + return null; } diff --git a/OpenMAIC/lib/utils/iframe.ts b/OpenMAIC/lib/utils/iframe.ts index be45e26..e1eca84 100644 --- a/OpenMAIC/lib/utils/iframe.ts +++ b/OpenMAIC/lib/utils/iframe.ts @@ -109,7 +109,7 @@ const ERROR_CAPTURE_SHIM = `', + }, + } as Scene) + : scene, + ); + await expect(packageCourseware({ + coursewareId: 'cw-offline-python', + version: 1, + stage: makeStage(), + scenes, + resolveAudioBytes: async () => AUDIO_BYTES, + publishedAt: '2026-08-16T00:00:00.000Z', + requireAgentRoster: true, + requireInteractiveHtml: true, + strictInteractiveAssets: true, + requireComplete: true, + })).rejects.toThrow(/incomplete.*loadPyodide/i); + }); }); diff --git a/OpenMAIC/tests/config/server-request-boundary.test.ts b/OpenMAIC/tests/config/server-request-boundary.test.ts index b24dbe5..3a56ca4 100644 --- a/OpenMAIC/tests/config/server-request-boundary.test.ts +++ b/OpenMAIC/tests/config/server-request-boundary.test.ts @@ -44,15 +44,17 @@ describe('server request boundary policy', () => { { role: 'server', pathname: '/api/generate-classroom', method: 'GET', notFound: true }, { role: 'server', pathname: '/api/generate-classroom', method: 'HEAD', notFound: true }, { role: 'server', pathname: '/api/generate-classroom/', method: 'HEAD', notFound: true }, - { role: 'server', pathname: '/api/generate-classroom', method: 'POST', notFound: false }, - { role: 'server', pathname: '/api/generate-classroom/', method: 'POST', notFound: false }, + { role: 'server', pathname: '/api/generate-classroom', method: 'POST', notFound: true }, + { role: 'server', pathname: '/api/generate-classroom/', method: 'POST', notFound: true }, { role: 'server', pathname: '/api/internal/course-publish', method: 'GET', notFound: true }, { role: 'server', pathname: '/api/internal/course-publish/', method: 'GET', notFound: true }, { role: 'server', pathname: '/api/internal/course-publish', method: 'POST', notFound: false }, { role: 'server', pathname: '/api/internal/course-publish/', method: 'POST', notFound: false }, - { role: 'server', pathname: '/api/chat', notFound: false }, - { role: 'server', pathname: '/api/coursewares', notFound: false }, - { role: 'server', pathname: '/api/courses-public', notFound: false }, + { role: 'server', pathname: '/api/chat', notFound: true }, + { role: 'server', pathname: '/api/coursewares', notFound: true }, + { role: 'server', pathname: '/api/courses-public', notFound: true }, + { role: 'server', pathname: '/api/runtime/v1/health/live', notFound: false }, + { role: 'server', pathname: '/api/runtime/v1/courses/generate', method: 'POST', notFound: false }, { role: 'learner', pathname: '/', notFound: false }, { role: 'learner', pathname: '/api/courses', notFound: false }, { role: 'ops', pathname: '/courses', notFound: false }, diff --git a/OpenMAIC/tests/course-framework/runner.test.ts b/OpenMAIC/tests/course-framework/runner.test.ts index 10939f3..36cc3a6 100644 --- a/OpenMAIC/tests/course-framework/runner.test.ts +++ b/OpenMAIC/tests/course-framework/runner.test.ts @@ -14,6 +14,8 @@ const ctl = vi.hoisted(() => ({ requirements: [] as string[], interactiveModes: [] as boolean[], classrooms: {} as Record, + frameworkGate: Promise.resolve() as Promise, + frameworkAbortSignals: [] as AbortSignal[], })); vi.mock('@/lib/server/resolve-model', () => ({ @@ -28,7 +30,11 @@ vi.mock('@/lib/server/resolve-model', () => ({ })); vi.mock('@/lib/ai/llm', () => ({ - callLLM: async () => ({ text: ctl.frameworkJson }), + callLLM: async (params: { abortSignal?: AbortSignal }) => { + if (params.abortSignal) ctl.frameworkAbortSignals.push(params.abortSignal); + await ctl.frameworkGate; + return { text: ctl.frameworkJson }; + }, })); vi.mock('@/lib/ai/providers', () => ({ @@ -160,13 +166,19 @@ const FRAMEWORK_WITH_FAILING_MODULE = { let frameworksDir: string; let jobsDir: string; +let frozenCoursesDir: string; +let frozenPackagesDir: string; beforeEach(async () => { frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-fw-')); jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-jobs-')); + frozenCoursesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-courses-')); + frozenPackagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-packages-')); vi.resetModules(); vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir); vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir); + vi.stubEnv('LEARNING_COURSE_STORE_DIR', frozenCoursesDir); + vi.stubEnv('LEARNING_COURSE_PACKAGE_DIR', frozenPackagesDir); ctl.frameworkJson = JSON.stringify(FRAMEWORK); ctl.gate = Promise.resolve(); ctl.releaseGate = () => {}; @@ -174,12 +186,16 @@ beforeEach(async () => { ctl.requirements = []; ctl.interactiveModes = []; ctl.classrooms = {}; + ctl.frameworkGate = Promise.resolve(); + ctl.frameworkAbortSignals = []; }); afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(frameworksDir, { recursive: true, force: true }); await fs.rm(jobsDir, { recursive: true, force: true }); + await fs.rm(frozenCoursesDir, { recursive: true, force: true }); + await fs.rm(frozenPackagesDir, { recursive: true, force: true }); }); /** Wait until a condition holds (poll every 10ms, fail after 3s). */ @@ -221,14 +237,43 @@ describe('course runner — two-phase pipeline', () => { expect(record?.modules[0]?.generationPromptSnapshot).toContain('日常预测案例'); }); - test('confirmation (module phase) generates every module in order; course completes', async () => { + test('framework cancellation reaches the LLM signal and cannot later overwrite cancelled state', async () => { + let releaseFramework!: () => void; + ctl.frameworkGate = new Promise((resolve) => { + releaseFramework = resolve; + }); const { readCourseRecord } = await setupCourse(); + const { + cancelCourseGenerationJob, + runCourseFrameworkGeneration, + } = await import('@/lib/course-framework/runner'); + + const run = runCourseFrameworkGeneration('course-1', 'http://localhost'); + await waitFor(() => ctl.frameworkAbortSignals.length === 1, 'framework LLM call'); + expect(cancelCourseGenerationJob('course-1')).toBe(true); + expect(ctl.frameworkAbortSignals[0]?.aborted).toBe(true); + releaseFramework(); + await run; + + const record = await readCourseRecord(); + expect(record?.status).toBe('cancelled'); + expect(record?.framework).toBeUndefined(); + }); + + test('confirmation (module phase) generates every module in order; course completes', async () => { + const { createCourse } = await import('@/lib/course-framework/runner'); + const { readCourseRecord } = await import('@/lib/course-framework/store'); + await createCourse( + 'course-1', + { requirement: '大型课题需求' }, + { ownerPrincipalId: 'works-owner-1' }, + ); const { runCourseFrameworkGeneration, runCourseModuleGeneration } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); - const record = await readCourseRecord(); + const record = await readCourseRecord('course-1'); expect(record?.status).toBe('completed'); expect(record?.modules.map((m) => m.status)).toEqual([ 'succeeded', @@ -254,6 +299,10 @@ describe('course runner — two-phase pipeline', () => { expect(ctl.requirements[1]).toContain('可假定的入门知识:基础概念'); expect(ctl.requirements[1]).toContain('前序模块的实际生成产出'); expect(ctl.requirements[1]).toContain('实际覆盖知识 1'); + const { listClassroomGenerationJobs } = await import('@/lib/server/classroom-job-store'); + const moduleJobs = await listClassroomGenerationJobs(10); + expect(moduleJobs).toHaveLength(4); + expect(moduleJobs.every((job) => job.ownerPrincipalId === 'works-owner-1')).toBe(true); }); test('enforces one active generation run per course', async () => { @@ -471,6 +520,52 @@ describe('course runner — two-phase pipeline', () => { expect(record?.modules.every((m) => m.status === 'pending' && !m.classroomId)).toBe(true); }); + test('rejects every source generation entry after the course id is frozen', async () => { + const { readCourseRecord } = await setupCourse(); + const { + runCourseFrameworkGeneration, + runCourseModuleGeneration, + runCourseGenerationJob, + regenerateCourseFramework, + } = await import('@/lib/course-framework/runner'); + await runCourseFrameworkGeneration('course-1', 'http://localhost'); + await runCourseModuleGeneration('course-1', 'http://localhost'); + const before = await readCourseRecord(); + expect(before?.status).toBe('completed'); + + const { persistFrozenLearningPackage } = await import('@/lib/makelore-course/package'); + await persistFrozenLearningPackage({ + schemaVersion: 2, + courseId: 'course-1', + mode: 'large', + sourceJobId: 'course-1', + sourceDigest: 'c'.repeat(64), + contentHash: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + archiveBytes: 4, + formatVersion: 2, + minPlayerVersion: '2.0.0', + title: '机器学习入门', + sceneCount: 4, + moduleCount: 4, + capabilities: { modular: true }, + createdAt: '2026-08-16T00:00:00.000Z', + }, new Uint8Array([0x50, 0x4b, 0x03, 0x04])); + + const mutations = [ + () => runCourseFrameworkGeneration('course-1', 'http://localhost'), + () => runCourseModuleGeneration('course-1', 'http://localhost'), + () => runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }), + () => regenerateCourseFramework('course-1', 'http://localhost'), + ]; + for (const mutate of mutations) { + await expect(mutate()).rejects.toMatchObject({ + name: 'FrozenLearningCourseMutationError', + }); + } + expect(await readCourseRecord()).toEqual(before); + }); + test('cancel aborts the current module and keeps the course cancellable', async () => { let release!: () => void; ctl.gate = new Promise((r) => { diff --git a/OpenMAIC/tests/courseware/remote-course-publish.test.ts b/OpenMAIC/tests/courseware/remote-course-publish.test.ts index c985360..3df2723 100644 --- a/OpenMAIC/tests/courseware/remote-course-publish.test.ts +++ b/OpenMAIC/tests/courseware/remote-course-publish.test.ts @@ -172,7 +172,7 @@ async function routeFetch( repos: Awaited>, statuses: number[], ): Promise { - const { handleCoursePublishRequest } = await import('@/app/api/internal/course-publish/route'); + const { handleCoursePublishRequest } = await import('@/lib/server/course-publish-route'); return (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new NextRequest(typeof input === 'string' ? input : input.toString(), { method: init?.method, @@ -618,7 +618,7 @@ describe('remote transactional course publish', () => { ); const server = await serverRepos(); - const { handleCoursePublishRequest } = await import('@/app/api/internal/course-publish/route'); + const { handleCoursePublishRequest } = await import('@/lib/server/course-publish-route'); // Retain the server module instance in this closure, then load the ops // route with a fresh mutex exactly as two Node processes would. vi.resetModules(); diff --git a/OpenMAIC/tests/lib/makelore-course/aggregate.test.ts b/OpenMAIC/tests/lib/makelore-course/aggregate.test.ts new file mode 100644 index 0000000..9efcc4d --- /dev/null +++ b/OpenMAIC/tests/lib/makelore-course/aggregate.test.ts @@ -0,0 +1,266 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import JSZip from 'jszip'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CourseRecord } from '@/lib/course-framework/types'; +import type { Scene, Stage } from '@/lib/types/stage'; + +const teacher = { + id: 'teacher-1', + name: '麦洛老师', + role: 'teacher', + persona: '耐心讲解', + avatar: '/avatars/teacher.png', + color: '#3b82f6', + priority: 1, +}; + +function classroom(classroomId: string, title: string, text: string) { + const stage = { + id: classroomId, + name: title, + createdAt: 1, + updatedAt: 1, + generatedAgentConfigs: [teacher], + } as Stage; + const scenes = [ + { + id: `${classroomId}-scene`, + stageId: classroomId, + title, + order: 1, + type: 'slide', + content: { + type: 'slide', + canvas: { + id: `${classroomId}-canvas`, + viewportSize: 100, + viewportRatio: 1.6, + theme: { color: '#fff', backgroundColor: '#000', fontName: 'sans-serif' }, + elements: [ + { + id: `${classroomId}-text`, + type: 'text', + content: `

${text}

`, + left: 0, + top: 0, + width: 100, + height: 20, + rotate: 0, + defaultFontName: 'sans-serif', + defaultColor: '#000', + }, + ], + }, + }, + actions: [ + { + id: `${classroomId}-speech`, + type: 'speech', + text, + audioId: `${classroomId}-missing-audio`, + }, + ], + createdAt: 1, + updatedAt: 1, + }, + ] as unknown as Scene[]; + return { id: classroomId, stage, scenes }; +} + +function largeCourseRecord(): CourseRecord { + return { + id: 'large-1', + status: 'completed', + requirement: '学习 Python', + enableTTS: false, + interactiveMode: false, + framework: { + courseTitle: 'Python 系统课', + languageDirective: 'zh-CN', + targetAudience: '零基础学习者', + summary: '从变量到循环', + courseGoals: [], + continuityContract: { + terminology: [], + teachingStyle: '渐进', + difficultyProgression: '由浅入深', + assessmentStrategy: '练习', + }, + modules: [], + }, + modules: [ + { + index: 1, + title: '变量', + description: '变量基础', + status: 'succeeded', + classroomId: 'classroom-1', + }, + { + index: 2, + title: '循环', + description: '循环基础', + status: 'succeeded', + classroomId: 'classroom-2', + }, + ], + createdAt: '2026-08-16T00:00:00.000Z', + updatedAt: '2026-08-16T00:00:00.000Z', + } as CourseRecord; +} + +describe.sequential('expanded aggregate learning package', () => { + let root = ''; + const env = { + data: process.env.LEARNING_DATA_DIR, + classrooms: process.env.CLASSROOM_DATA_DIR, + packages: process.env.LEARNING_COURSE_PACKAGE_DIR, + courses: process.env.LEARNING_COURSE_STORE_DIR, + }; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'learning-aggregate-')); + process.env.LEARNING_DATA_DIR = root; + process.env.CLASSROOM_DATA_DIR = join(root, 'classrooms'); + process.env.LEARNING_COURSE_PACKAGE_DIR = join(root, 'packages'); + process.env.LEARNING_COURSE_STORE_DIR = join(root, 'courses'); + vi.resetModules(); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + for (const [name, value] of Object.entries(env)) { + const key = { + data: 'LEARNING_DATA_DIR', + classrooms: 'CLASSROOM_DATA_DIR', + packages: 'LEARNING_COURSE_PACKAGE_DIR', + courses: 'LEARNING_COURSE_STORE_DIR', + }[name]!; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.resetModules(); + }); + + it('writes course.json plus expanded stable module roots and resolves module knowledge', async () => { + const { persistClassroom } = await import('@/lib/server/classroom-storage'); + await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine'); + await persistClassroom( + classroom('classroom-2', '模块二', '循环用于重复执行代码'), + 'http://engine', + ); + + const course = largeCourseRecord(); + const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize'); + const frozen = await finalizeLargeLearningCourse({ + jobId: 'large-1', + course, + baseUrl: 'http://engine', + }); + const { readUniqueMakeloreCoursePackage } = await import('@/lib/makelore-course/package'); + const bytes = await readUniqueMakeloreCoursePackage('large-1'); + expect(bytes).not.toBeNull(); + const zip = await JSZip.loadAsync(bytes!); + expect(zip.file('course.json')).not.toBeNull(); + expect(zip.file('aggregate.json')).toBeNull(); + expect(Object.keys(zip.files).some((name) => name.endsWith('/courseware.zip'))).toBe(false); + for (const moduleId of ['large-1_m1', 'large-1_m2']) { + expect(zip.file(`modules/${moduleId}/manifest.json`)).not.toBeNull(); + expect(zip.file(`modules/${moduleId}/bundle.json`)).not.toBeNull(); + expect(zip.file(`modules/${moduleId}/knowledge/knowledge.json`)).not.toBeNull(); + expect(zip.file(`modules/${moduleId}/quiz/quiz.json`)).not.toBeNull(); + } + const courseJson = JSON.parse(await zip.file('course.json')!.async('string')) as { + contentHash: string; + modules: Array<{ moduleId: string; path: string }>; + }; + expect(courseJson.contentHash).toBe(frozen.contentHash); + expect(courseJson.modules).toEqual([ + expect.objectContaining({ moduleId: 'large-1_m1', path: 'modules/large-1_m1/' }), + expect.objectContaining({ moduleId: 'large-1_m2', path: 'modules/large-1_m2/' }), + ]); + + const { loadMakeloreCourse, resolveMakeloreCourseModule, courseKnowledge } = + await import('@/lib/makelore-runtime/course-store'); + const loaded = await loadMakeloreCourse('large-1', frozen.contentHash); + expect(loaded).toMatchObject({ mode: 'large', moduleCount: 2 }); + const secondHash = (frozen.courseStructure!.modules as Array<{ contentHash: string }>)[1]! + .contentHash; + const selected = await resolveMakeloreCourseModule(loaded!, { + moduleId: 'large-1_m2', + moduleContentHash: secondHash, + }); + expect(selected).not.toBeNull(); + expect(courseKnowledge(selected!).knowledge.scenes[0]!.text).toContain('循环用于重复执行代码'); + }); + + it('reuses only the exact frozen source digest and never overwrites an immutable course id', async () => { + const { persistClassroom } = await import('@/lib/server/classroom-storage'); + await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine'); + await persistClassroom( + classroom('classroom-2', '模块二', '循环用于重复执行代码'), + 'http://engine', + ); + const course = largeCourseRecord(); + const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize'); + + const first = await finalizeLargeLearningCourse({ + jobId: 'large-1', + course, + baseUrl: 'http://engine', + }); + expect(first.sourceDigest).toMatch(/^[0-9a-f]{64}$/); + const firstBytes = await readFile(join(root, 'packages', 'large-1.zip')); + + const reused = await finalizeLargeLearningCourse({ + jobId: 'large-1', + course, + baseUrl: 'http://engine', + }); + expect(reused).toEqual(first); + + await persistClassroom( + classroom('classroom-2', '模块二', '循环与条件分支共同控制程序流程'), + 'http://engine', + ); + await expect(finalizeLargeLearningCourse({ + jobId: 'large-1', + course, + baseUrl: 'http://engine', + })).rejects.toThrow('已冻结'); + await expect(readFile(join(root, 'packages', 'large-1.zip'))).resolves.toEqual(firstBytes); + + await expect( + finalizeLargeLearningCourse({ + jobId: 'large-1', + course: { ...course, status: 'generating' }, + baseUrl: 'http://engine', + }), + ).rejects.toThrow('Large course is not complete'); + }); + + it('never treats a legacy frozen record without a source digest as rebuildable', async () => { + const { persistClassroom } = await import('@/lib/server/classroom-storage'); + await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine'); + await persistClassroom( + classroom('classroom-2', '模块二', '循环用于重复执行代码'), + 'http://engine', + ); + const course = largeCourseRecord(); + const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize'); + await finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' }); + const packagePath = join(root, 'packages', 'large-1.zip'); + const recordPath = join(root, 'courses', 'large-1.json'); + const frozenBytes = await readFile(packagePath); + const record = JSON.parse(await readFile(recordPath, 'utf8')) as Record; + delete record.sourceDigest; + await writeFile(recordPath, JSON.stringify(record)); + + await expect( + finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' }), + ).rejects.toThrow('已冻结'); + await expect(readFile(packagePath)).resolves.toEqual(frozenBytes); + }); +}); diff --git a/OpenMAIC/tests/lib/makelore-course/package.test.ts b/OpenMAIC/tests/lib/makelore-course/package.test.ts new file mode 100644 index 0000000..b66b0e0 --- /dev/null +++ b/OpenMAIC/tests/lib/makelore-course/package.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import JSZip from 'jszip'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + packageUniqueMakeloreCourse, + persistUniqueMakeloreCourse, + readUniqueMakeloreCoursePackage, +} from '@/lib/makelore-course/package'; +import { loadMakeloreCourse } from '@/lib/makelore-runtime/course-store'; +import { validateCoursePackageDescriptor } from '../../../packages/@makelore/learning-contracts/src/index'; +import type { Scene, Stage } from '@/lib/types/stage'; + +describe('unique Makelore course package', () => { + let root = ''; + const previousPackageRoot = process.env.MAKELORE_COURSE_PACKAGE_DIR; + const previousStoreRoot = process.env.MAKELORE_COURSE_STORE_DIR; + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + if (previousPackageRoot === undefined) delete process.env.MAKELORE_COURSE_PACKAGE_DIR; + else process.env.MAKELORE_COURSE_PACKAGE_DIR = previousPackageRoot; + if (previousStoreRoot === undefined) delete process.env.MAKELORE_COURSE_STORE_DIR; + else process.env.MAKELORE_COURSE_STORE_DIR = previousStoreRoot; + }); + + it('freezes classroom, quiz and knowledge without a product version', async () => { + const stage = { + id: 'stage-1', name: '变量入门', createdAt: Date.now(), updatedAt: Date.now(), + generatedAgentConfigs: [{ id: 'teacher-1', name: 'AI Teacher', role: 'teacher', persona: '耐心讲解', avatar: '/avatars/teacher.png', color: '#3b82f6', priority: 10 }], + } as Stage; + const scenes = [{ + id: 'scene-1', stageId: stage.id, title: '变量是什么', order: 1, type: 'interactive', + content: { type: 'interactive', html: '' }, + createdAt: Date.now(), updatedAt: Date.now(), + }] as Scene[]; + const course = await packageUniqueMakeloreCourse({ courseId: 'course-1', stage, scenes }); + const zip = await JSZip.loadAsync(course.bytes); + const descriptor = JSON.parse(await zip.file('descriptor.json')!.async('string')); + expect(descriptor).toMatchObject({ courseId: 'course-1', formatVersion: 1, sceneCount: 1 }); + expect(validateCoursePackageDescriptor(descriptor)).toMatchObject({ ok: true, errors: [] }); + expect(descriptor).not.toHaveProperty('version'); + expect(zip.file('classroom.json')).not.toBeNull(); + expect(zip.file('knowledge/knowledge.json')).not.toBeNull(); + + root = await mkdtemp(join(tmpdir(), 'makelore-course-package-')); + process.env.MAKELORE_COURSE_PACKAGE_DIR = join(root, 'packages'); + process.env.MAKELORE_COURSE_STORE_DIR = join(root, 'courses'); + await persistUniqueMakeloreCourse('course-1', course); + await expect(readUniqueMakeloreCoursePackage('course-1')).resolves.toEqual(course.bytes); + await expect(loadMakeloreCourse('course-1', course.contentHash)).resolves.toMatchObject({ title: '变量入门' }); + }); +}); diff --git a/OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts b/OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts new file mode 100644 index 0000000..dc9af21 --- /dev/null +++ b/OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + fetchMakeloreLearningAgent, + installMakeloreRuntimeFetchBridge, + transcribeMakeloreLearningAudio, +} from '@/lib/makelore-runtime/browser-bridge'; + +function courseContext() { + return { + courseId: 'course-1', + courseContentHash: 'a'.repeat(64), + contentHash: 'a'.repeat(64), + moduleId: 'course-1_m1', + moduleContentHash: 'b'.repeat(64), + }; +} + +function bridgeEvent(data: Record) { + window.dispatchEvent(new MessageEvent('message', { source: window.parent, data })); +} + +describe('Makelore browser Agent bridge', () => { + it('sends only the exact course context, learner turns and current scene to the parent', async () => { + window.__MAKELORE_COURSE_CONTEXT__ = { + courseId: 'course-1', + courseContentHash: 'a'.repeat(64), + contentHash: 'a'.repeat(64), + moduleId: null, + moduleContentHash: 'a'.repeat(64), + }; + const post = vi.spyOn(window.parent, 'postMessage'); + const responsePromise = fetchMakeloreLearningAgent({ + messages: [ + { role: 'assistant', parts: [{ type: 'text', text: '欢迎' }] }, + { role: 'user', parts: [{ type: 'text', text: '这一页是什么意思?' }] }, + ], + storeState: { + currentSceneId: 'scene-2', + scenes: [{ id: 'scene-2', order: 2, title: '变量' }], + }, + }, new AbortController().signal); + const sent = post.mock.calls[0][0] as { requestId: string; request: Record }; + expect(sent.request).toMatchObject({ + courseId: 'course-1', + contentHash: 'a'.repeat(64), + message: '这一页是什么意思?', + anchor: { sceneId: 'scene-2', sceneOrder: 2, sceneTitle: '变量' }, + }); + window.dispatchEvent(new MessageEvent('message', { + source: window, + data: { type: 'makelore:agent:response', requestId: sent.requestId, ok: true, text: '变量用于保存数据。' }, + })); + const response = await responsePromise; + const body = await response.text(); + expect(body).toContain('agent_start'); + expect(body).toContain('变量用于保存数据。'); + expect(body).toContain('cue_user'); + post.mockRestore(); + }); + + it('moves recorded audio to the trusted parent without calling a local API', async () => { + window.__MAKELORE_COURSE_CONTEXT__ = { + courseId: 'course-1', + courseContentHash: 'a'.repeat(64), + contentHash: 'a'.repeat(64), + moduleId: null, + moduleContentHash: 'a'.repeat(64), + }; + const post = vi.spyOn(window.parent, 'postMessage'); + const audio = { + type: 'audio/webm', + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + } as Blob; + const transcriptionPromise = transcribeMakeloreLearningAudio(audio, { language: 'zh-CN' }); + await vi.waitFor(() => expect(post).toHaveBeenCalled()); + const sent = post.mock.calls[0][0] as { requestId: string; audio: ArrayBuffer; language: string }; + expect(new Uint8Array(sent.audio)).toEqual(new Uint8Array([1, 2, 3])); + expect(sent.language).toBe('zh-CN'); + window.dispatchEvent(new MessageEvent('message', { + source: window, + data: { type: 'makelore:transcription:response', requestId: sent.requestId, ok: true, text: '什么是变量' }, + })); + await expect(transcriptionPromise).resolves.toBe('什么是变量'); + post.mockRestore(); + }); +}); + +describe('Makelore scoped runtime fetch bridge', () => { + const originalFetch = window.fetch; + + afterEach(() => { + window.fetch = originalFetch; + delete window.__MAKELORE_OFFLINE_PLAYER__; + delete window.__MAKELORE_COURSE_CONTEXT__; + vi.restoreAllMocks(); + }); + + function install(timeoutMs = 1_000) { + window.__MAKELORE_OFFLINE_PLAYER__ = true; + window.__MAKELORE_COURSE_CONTEXT__ = courseContext(); + const native = vi.fn(async () => new Response('native')); + window.fetch = native as typeof window.fetch; + const uninstall = installMakeloreRuntimeFetchBridge({ + timeoutMs, + getAnchor: () => ({ sceneId: 'learning_course-1_course-1_m1_s2', sceneOrder: 3 }), + }); + return { native, uninstall }; + } + + it('intercepts only exact allowlisted relative POSTs and reconstructs a streamed Response', async () => { + const { native, uninstall } = install(); + const post = vi.spyOn(window.parent, 'postMessage'); + const responsePromise = window.fetch('/api/pbl/v2/instructor', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'must-not-cross' }, + body: JSON.stringify({ project: { id: 'p1' }, userMessage: '请继续' }), + }); + const sent = post.mock.calls[0]![0] as { + requestId: string; + capability: string; + body: unknown; + context: Record; + }; + expect(sent).toMatchObject({ + capability: 'pbl/v2/instructor', + body: { project: { id: 'p1' }, userMessage: '请继续' }, + context: { + courseId: 'course-1', + courseContentHash: 'a'.repeat(64), + moduleId: 'course-1_m1', + moduleContentHash: 'b'.repeat(64), + anchor: { sceneId: 'learning_course-1_course-1_m1_s2', sceneOrder: 3 }, + }, + }); + expect(JSON.stringify(sent)).not.toContain('must-not-cross'); + + bridgeEvent({ + type: 'makelore:runtime:start', + requestId: sent.requestId, + status: 200, + contentType: 'text/event-stream', + }); + const response = await responsePromise; + bridgeEvent({ + type: 'makelore:runtime:chunk', + requestId: sent.requestId, + chunk: new TextEncoder().encode('data: one\n\n'), + }); + bridgeEvent({ + type: 'makelore:runtime:chunk', + requestId: sent.requestId, + chunk: new TextEncoder().encode('data: two\n\n'), + }); + bridgeEvent({ type: 'makelore:runtime:end', requestId: sent.requestId }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + await expect(response.text()).resolves.toBe('data: one\n\ndata: two\n\n'); + + await window.fetch('/api/pbl/v2/instructor?debug=1', { method: 'POST' }); + await window.fetch('/api/pbl/v2/instructor', { method: 'GET' }); + await window.fetch('/api/parse-pdf', { method: 'POST' }); + expect(native).toHaveBeenCalledTimes(3); + uninstall(); + }); + + it('isolates concurrent request ids and aborts one request without affecting another', async () => { + const { uninstall } = install(); + const post = vi.spyOn(window.parent, 'postMessage'); + const firstAbort = new AbortController(); + const first = window.fetch('/api/quiz-grade', { + method: 'POST', signal: firstAbort.signal, body: JSON.stringify({ question: 'q1' }), + }); + const second = window.fetch('/api/quiz-grade', { + method: 'POST', body: JSON.stringify({ question: 'q2' }), + }); + const firstId = (post.mock.calls[0]![0] as { requestId: string }).requestId; + const secondId = (post.mock.calls[1]![0] as { requestId: string }).requestId; + expect(firstId).not.toBe(secondId); + firstAbort.abort(); + await expect(first).rejects.toMatchObject({ name: 'AbortError' }); + + bridgeEvent({ type: 'makelore:runtime:start', requestId: secondId, status: 201, contentType: 'application/json' }); + const response = await second; + bridgeEvent({ type: 'makelore:runtime:chunk', requestId: firstId, chunk: new Uint8Array([9]) }); + bridgeEvent({ type: 'makelore:runtime:chunk', requestId: secondId, chunk: new TextEncoder().encode('{"ok":true}') }); + bridgeEvent({ type: 'makelore:runtime:end', requestId: secondId }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toEqual({ ok: true }); + uninstall(); + }); + + it('times out pending requests, and uninstall restores native fetch', async () => { + const { native, uninstall } = install(5); + const pending = window.fetch('/api/quiz-grade', { + method: 'POST', body: JSON.stringify({ question: 'q' }), + }); + await expect(pending).rejects.toThrow('超时'); + uninstall(); + uninstall(); + await window.fetch('/api/quiz-grade', { method: 'POST' }); + expect(native).toHaveBeenCalledOnce(); + }); + + it('does not install outside the offline player', async () => { + const native = vi.fn(async () => new Response('native')); + window.fetch = native as typeof window.fetch; + const uninstall = installMakeloreRuntimeFetchBridge({ getAnchor: () => null }); + await window.fetch('/api/quiz-grade', { method: 'POST' }); + expect(native).toHaveBeenCalledOnce(); + uninstall(); + }); +}); diff --git a/OpenMAIC/tests/lib/makelore-runtime/health.test.ts b/OpenMAIC/tests/lib/makelore-runtime/health.test.ts new file mode 100644 index 0000000..1584c9e --- /dev/null +++ b/OpenMAIC/tests/lib/makelore-runtime/health.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const DIRECTORY_ENV = [ + 'CLASSROOM_DATA_DIR', + 'CLASSROOM_JOBS_DIR', + 'COURSE_FRAMEWORK_DIR', + 'COURSE_MANIFEST_DIR', + 'COURSEWARE_DATA_DIR', + 'COURSEWARE_BUNDLE_DIR', + 'LEARNING_COURSE_PACKAGE_DIR', + 'LEARNING_COURSE_STORE_DIR', +] as const; + +const TTS_ENV = [ + 'TTS_OPENAI_API_KEY', + 'TTS_AZURE_API_KEY', + 'TTS_GLM_API_KEY', + 'TTS_QWEN_API_KEY', + 'TTS_VOXCPM_API_KEY', + 'TTS_DOUBAO_API_KEY', + 'TTS_ELEVENLABS_API_KEY', + 'TTS_MINIMAX_API_KEY', + 'TTS_LEMONADE_BASE_URL', +] as const; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + vi.resetModules(); + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function configureEngine() { + const root = await mkdtemp(path.join(os.tmpdir(), 'openmaic-learning-health-')); + temporaryRoots.push(root); + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server'); + vi.stubEnv('LEARNING_ENGINE_TOKEN', 'test-runtime-token'); + vi.stubEnv('OPENAI_API_KEY', 'test-llm-key'); + DIRECTORY_ENV.forEach((name) => vi.stubEnv(name, path.join(root, name.toLowerCase()))); + TTS_ENV.forEach((name) => vi.stubEnv(name, '')); +} + +describe('Learning Engine readiness', () => { + it('fails closed when default narration has no managed TTS provider', async () => { + await configureEngine(); + const { inspectLearningReadiness } = await import('@/lib/makelore-runtime/health'); + + const readiness = await inspectLearningReadiness(); + + expect(readiness.ready).toBe(false); + expect(readiness.checks.llmProvider).toEqual({ ok: true }); + expect(readiness.checks.ttsProvider).toEqual({ + ok: false, + detail: 'No enabled server-managed TTS provider is configured', + }); + }); + + it('becomes ready when both LLM and TTS providers are managed', async () => { + await configureEngine(); + vi.stubEnv('TTS_OPENAI_API_KEY', 'test-tts-key'); + vi.resetModules(); + const { inspectLearningReadiness } = await import('@/lib/makelore-runtime/health'); + + const readiness = await inspectLearningReadiness(); + + expect(readiness.ready).toBe(true); + expect(readiness.checks.ttsProvider).toEqual({ ok: true }); + }); +}); diff --git a/OpenMAIC/tests/lib/makelore-runtime/progress-events.test.ts b/OpenMAIC/tests/lib/makelore-runtime/progress-events.test.ts new file mode 100644 index 0000000..d41215d --- /dev/null +++ b/OpenMAIC/tests/lib/makelore-runtime/progress-events.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + emitMakelorePlaybackProgress, + MAKELORE_PLAYBACK_PROGRESS_EVENT, +} from '@/lib/makelore-runtime/progress-events'; + +afterEach(() => { + delete window.__MAKELORE_OFFLINE_PLAYER__; + vi.restoreAllMocks(); +}); +describe('offline player playback progress events', () => { + it('publishes action-level and completion state only in the offline player', () => { + const listener = vi.fn(); + window.addEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, listener); + const detail = { + sceneId: 'scene-2', + sceneOrder: 2, + actionIndex: 4, + completed: true, + }; + + emitMakelorePlaybackProgress(detail); + expect(listener).not.toHaveBeenCalled(); + + window.__MAKELORE_OFFLINE_PLAYER__ = true; + emitMakelorePlaybackProgress(detail); + expect(listener).toHaveBeenCalledOnce(); + expect((listener.mock.calls[0]![0] as CustomEvent).detail).toEqual(detail); + + window.removeEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, listener); + }); +}); diff --git a/OpenMAIC/tests/lib/ops/api-fetch.test.ts b/OpenMAIC/tests/lib/ops/api-fetch.test.ts new file mode 100644 index 0000000..9998d2e --- /dev/null +++ b/OpenMAIC/tests/lib/ops/api-fetch.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installOpsBrowserBoundary } from '@/lib/ops/api-fetch'; + +const AUTH_STORAGE_KEY = 'works-square.operations.auth.v1'; + +describe('installOpsBrowserBoundary', () => { + let nativeFetch: ReturnType; + let cleanup: (() => void) | undefined; + + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE', 'ops'); + vi.stubEnv('NEXT_PUBLIC_OPENMAIC_BASE_PATH', '/learning-ops'); + window.localStorage.clear(); + nativeFetch = vi.fn(async () => new Response(null, { status: 204 })); + window.fetch = nativeFetch as unknown as typeof window.fetch; + }); + + afterEach(() => { + cleanup?.(); + cleanup = undefined; + window.localStorage.clear(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('maps root-relative API requests to the Ops basePath and adds the Works bearer token', async () => { + window.localStorage.setItem( + AUTH_STORAGE_KEY, + JSON.stringify({ + accessToken: 'works-access-token', + expiresAt: Date.now() + 60_000, + }), + ); + cleanup = installOpsBrowserBoundary(); + + await window.fetch('/api/server-providers', { + method: 'POST', + headers: { 'X-Request-Id': 'request-1' }, + }); + + expect(nativeFetch).toHaveBeenCalledTimes(1); + const [input, init] = nativeFetch.mock.calls[0] as [RequestInfo | URL, RequestInit]; + expect(input).toBe('/learning-ops/api/server-providers'); + expect(init.method).toBe('POST'); + const headers = new Headers(init.headers); + expect(headers.get('Authorization')).toBe('Bearer works-access-token'); + expect(headers.get('X-Request-Id')).toBe('request-1'); + }); + + it('leaves external URLs and unrelated root-relative paths unchanged', async () => { + cleanup = installOpsBrowserBoundary(); + + await window.fetch('https://example.test/api/courses'); + await window.fetch('/courses/course-1', { method: 'GET' }); + + expect(nativeFetch).toHaveBeenNthCalledWith(1, 'https://example.test/api/courses', {}); + expect(nativeFetch).toHaveBeenNthCalledWith(2, '/courses/course-1', { method: 'GET' }); + }); + + it('restores unwrapped fetch behavior when cleaned up', async () => { + cleanup = installOpsBrowserBoundary(); + const wrappedFetch = window.fetch; + + cleanup(); + cleanup = undefined; + + expect(window.fetch).not.toBe(wrappedFetch); + await window.fetch('/api/server-providers'); + expect(nativeFetch).toHaveBeenCalledTimes(1); + expect(nativeFetch.mock.calls[0]?.[0]).toBe('/api/server-providers'); + }); + + it.each([ + { + label: 'expired session', + value: JSON.stringify({ accessToken: 'expired-token', expiresAt: Date.now() - 1 }), + }, + { label: 'malformed session JSON', value: '{not-json' }, + { label: 'session without a string token', value: JSON.stringify({ accessToken: 123 }) }, + ])('does not add Authorization for a $label', async ({ value }) => { + window.localStorage.setItem(AUTH_STORAGE_KEY, value); + cleanup = installOpsBrowserBoundary(); + + await window.fetch('/api/server-providers'); + + const [, init] = nativeFetch.mock.calls[0] as [RequestInfo | URL, RequestInit]; + expect(new Headers(init.headers).has('Authorization')).toBe(false); + }); +}); diff --git a/OpenMAIC/tests/lib/utils/iframe.test.ts b/OpenMAIC/tests/lib/utils/iframe.test.ts index 024e042..c9e7978 100644 --- a/OpenMAIC/tests/lib/utils/iframe.test.ts +++ b/OpenMAIC/tests/lib/utils/iframe.test.ts @@ -56,6 +56,14 @@ describe('patchHtmlForIframe', () => { expect(out.indexOf('')).toBeLessThan(out.indexOf('
')); }); + it('blocks network access for interactive HTML in the Makelore offline player', () => { + const out = patchHtmlForIframe('', { offline: true }); + expect(out).toContain('data-makelore-offline-csp'); + expect(out).toContain("connect-src 'none'"); + expect(out).toContain("frame-src 'none'"); + expect(out.indexOf('data-makelore-offline-csp')).toBeLessThan(out.indexOf('')); + }); + it('does not treat head-like text in comments or scripts as the document head', () => { const authoredScript = 'const template = ""; window.__template = template;'; const html = ``; diff --git a/OpenMAIC/tests/persistence/route.test.ts b/OpenMAIC/tests/persistence/route.test.ts index d6871fa..59b8f5a 100644 --- a/OpenMAIC/tests/persistence/route.test.ts +++ b/OpenMAIC/tests/persistence/route.test.ts @@ -81,7 +81,7 @@ describe('embedded persistence route', () => { })); vi.stubEnv('DATABASE_URL', 'postgres://retry-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const request = () => new Request('http://localhost/api/persistence/runtime/sessions', { headers: { authorization: 'Bearer test-token' }, @@ -103,7 +103,7 @@ describe('embedded persistence route', () => { // Next dev HMR reloads module code but retains globalThis. The initialized // handler must be reused rather than opening another pool. vi.resetModules(); - const reloaded = await import('@/app/api/persistence/[...path]/route'); + const reloaded = await import('@/lib/persistence/http-route'); const hmrPoolFactory = vi.fn(); const afterReload = await reloaded.handlePersistenceRequest(request(), { poolFactory: hmrPoolFactory, @@ -179,7 +179,7 @@ describe('embedded persistence route', () => { }); vi.stubEnv('DATABASE_URL', 'postgres://asset-wiring-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const pool = { end: vi.fn().mockResolvedValue(undefined) }; const response = await handlePersistenceRequest( @@ -272,7 +272,7 @@ describe('embedded persistence route', () => { vi.stubEnv('DATABASE_URL', 'postgres://asset-s3-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); vi.stubEnv('ASSET_S3_BUCKET', ' asset-bucket '); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const response = await handlePersistenceRequest( new Request('http://localhost/api/persistence/assets', { @@ -346,7 +346,7 @@ describe('embedded persistence route', () => { vi.stubEnv('DATABASE_URL', 'postgres://invalid-s3-bucket-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); vi.stubEnv('ASSET_S3_BUCKET', 'Invalid_Bucket'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const poolFactory = vi.fn(() => ({ end: vi.fn().mockResolvedValue(undefined) })); // The malformed bucket no longer gates handler initialization: document @@ -417,7 +417,7 @@ describe('embedded persistence route', () => { vi.stubEnv('DATABASE_URL', 'postgres://asset-s3-retry-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); vi.stubEnv('ASSET_S3_BUCKET', 'asset-bucket'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const response = await handlePersistenceRequest( new Request('http://localhost/api/persistence/runtime/sessions', { @@ -481,7 +481,7 @@ describe('embedded persistence route', () => { vi.stubEnv('DATABASE_URL', 'postgres://validator-wiring-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); const [{ handlePersistenceRequest }, { APP_RUNTIME_PAYLOAD_VALIDATORS }] = await Promise.all([ - import('@/app/api/persistence/[...path]/route'), + import('@/lib/persistence/http-route'), import('@/lib/runtime/payload-validators'), ]); const response = await handlePersistenceRequest( @@ -556,7 +556,7 @@ describe('embedded persistence route', () => { })); vi.stubEnv('DATABASE_URL', 'postgres://adapter-test'); vi.stubEnv('PERSISTENCE_DEV_TOKEN', 'test-token'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const pool = { end: vi.fn().mockResolvedValue(undefined) }; const put = await handlePersistenceRequest( @@ -612,7 +612,7 @@ describe('embedded persistence route', () => { }; const readAdapterBody = async (path: string) => { - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const pool = { end: vi.fn().mockResolvedValue(undefined) }; const response = await handlePersistenceRequest( new Request(`http://localhost/api/persistence/${path}`, { @@ -761,7 +761,7 @@ describe('embedded persistence route', () => { }); response.end('content'); }, 'postgres://head-test'); - const { handlePersistenceRequest } = await import('@/app/api/persistence/[...path]/route'); + const { handlePersistenceRequest } = await import('@/lib/persistence/http-route'); const response = await handlePersistenceRequest( new Request('http://localhost/api/persistence/documents/head', { method: 'HEAD', diff --git a/OpenMAIC/tests/server/access-middleware.test.ts b/OpenMAIC/tests/server/access-middleware.test.ts index b666075..908119e 100644 --- a/OpenMAIC/tests/server/access-middleware.test.ts +++ b/OpenMAIC/tests/server/access-middleware.test.ts @@ -4,6 +4,7 @@ import { middleware } from '@/middleware'; afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); describe('ACCESS_CODE middleware deployment boundary', () => { @@ -54,7 +55,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => { expect(response.headers.get('x-middleware-next')).toBe('1'); }); - test('allows a single-course generation POST while hiding the ownerless global job list', async () => { + test('hides both single-course generation POST and its ownerless global job list', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server'); @@ -65,7 +66,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => { new NextRequest('https://server.example/api/generate-classroom'), ); - expect(createResponse.headers.get('x-middleware-next')).toBe('1'); + expect(createResponse.status).toBe(404); expect(listResponse.status).toBe(404); }); @@ -98,7 +99,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => { expect(response.headers.get('x-middleware-next')).toBeNull(); }); - test('does not preempt a server publish request authenticated by Bearer at the route', async () => { + test('does not expose legacy publish APIs even when a caller supplies a Bearer', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server'); vi.stubEnv('ACCESS_CODE', 'shared-compose-access-code'); @@ -110,8 +111,8 @@ describe('ACCESS_CODE middleware deployment boundary', () => { }), ); - expect(response.status).toBe(200); - expect(response.headers.get('x-middleware-next')).toBe('1'); + expect(response.status).toBe(404); + expect(response.headers.get('x-middleware-next')).toBeNull(); }); test('preserves learner handling for ordinary and operations paths', async () => { @@ -129,7 +130,7 @@ describe('ACCESS_CODE middleware deployment boundary', () => { expect(opsApi.status).toBe(403); }); - test('preserves operations workbench routing', async () => { + test('serves the operations shell but fails closed for APIs without Works identity config', async () => { vi.stubEnv('NODE_ENV', 'test'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', ''); @@ -138,6 +139,43 @@ describe('ACCESS_CODE middleware deployment boundary', () => { const api = await middleware(new NextRequest('https://ops.example/api/courses')); expect(page.headers.get('x-middleware-next')).toBe('1'); - expect(api.headers.get('x-middleware-next')).toBe('1'); + expect(api.status).toBe(503); + }); + + test.each([ + '/api/generate-classroom', + '/api/quiz-grade', + '/api/pbl/v2/instructor', + ])('requires an introspected Works admin for every ops API: %s', async (pathname) => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); + vi.stubEnv('WORKS_SQUARE_API_BASE_URL', 'https://works.example'); + vi.stubEnv('OPS_PUBLIC_ORIGIN', 'https://ops.example'); + + const unauthorized = await middleware(new NextRequest(`https://ops.example${pathname}`, { + method: 'POST', + headers: { origin: 'https://ops.example' }, + })); + expect(unauthorized.status).toBe(401); + + const introspection = vi.fn(async () => Response.json({ + username: 'jiaoyuop', + site_role: 'admin', + })); + vi.stubGlobal('fetch', introspection); + const authorized = await middleware(new NextRequest(`https://ops.example${pathname}`, { + method: 'POST', + headers: { + origin: 'https://ops.example', + authorization: 'Bearer works-session', + }, + })); + expect(authorized.headers.get('x-middleware-next')).toBe('1'); + expect(introspection).toHaveBeenCalledWith( + 'https://works.example/api/auth/me', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer works-session' }), + }), + ); }); }); diff --git a/OpenMAIC/tests/server/classroom-job.test.ts b/OpenMAIC/tests/server/classroom-job.test.ts index 9ad9f59..e0309a8 100644 --- a/OpenMAIC/tests/server/classroom-job.test.ts +++ b/OpenMAIC/tests/server/classroom-job.test.ts @@ -308,4 +308,57 @@ describe('job resume', () => { false, ); }); + + test('resume refuses a succeeded job without mutating its frozen source result', async () => { + const store = await importStore(); + const runner = await importRunner(); + + await store.createClassroomGenerationJob('job-succeeded', INPUT); + await store.updateClassroomGenerationJob('job-succeeded', { + status: 'succeeded', + step: 'completed', + progress: 100, + message: 'Classroom generation completed', + result: { + classroomId: 'frozen-classroom', + url: 'http://localhost/classroom/frozen-classroom', + scenesCount: 2, + }, + }); + const before = await store.readClassroomGenerationJob('job-succeeded'); + + expect( + await runner.resumeClassroomGenerationJob('job-succeeded', 'http://localhost'), + ).toBe(false); + expect(await store.readClassroomGenerationJob('job-succeeded')).toEqual(before); + expect(mocks.generateClassroom).not.toHaveBeenCalled(); + }); + + test('resumes a failed job from its persisted input', async () => { + const store = await importStore(); + const runner = await importRunner(); + + await store.createClassroomGenerationJob('job-failed-resume', INPUT); + await store.markClassroomGenerationJobFailed('job-failed-resume', 'provider failed'); + mocks.generateClassroom.mockResolvedValueOnce({ + id: 'recovered-classroom', + url: 'http://localhost/classroom/recovered-classroom', + scenesCount: 1, + }); + + expect( + await runner.resumeClassroomGenerationJob('job-failed-resume', 'http://localhost'), + ).toBe(true); + expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({ + status: 'queued', + progress: 0, + message: 'Classroom generation job queued (resumed)', + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({ + status: 'succeeded', + result: { classroomId: 'recovered-classroom' }, + }); + }); }); diff --git a/OpenMAIC/tests/server/ops-access.test.ts b/OpenMAIC/tests/server/ops-access.test.ts index 147a0eb..0a02b93 100644 --- a/OpenMAIC/tests/server/ops-access.test.ts +++ b/OpenMAIC/tests/server/ops-access.test.ts @@ -12,22 +12,22 @@ describe('requireOpsAccess', () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'learner'); - const response = requireOpsAccess(new NextRequest('http://localhost/api/courses')); + const response = await requireOpsAccess(new NextRequest('http://localhost/api/courses')); expect(response?.status).toBe(403); await expect(response?.json()).resolves.toMatchObject({ errorCode: 'FORBIDDEN' }); }); - it('fails closed when a production ops deployment has no access code', () => { + it('fails closed when a production ops deployment has no Works identity origin', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', ''); - const response = requireOpsAccess(new NextRequest('http://localhost/api/courses')); + const response = await requireOpsAccess(new NextRequest('http://localhost/api/courses')); expect(response?.status).toBe(503); }); - it('accepts a valid access-code session on an ops deployment', () => { - vi.stubEnv('NODE_ENV', 'production'); + it('accepts a valid access-code session only for development compatibility', async () => { + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); const token = createAccessToken('test-access-code'); @@ -35,11 +35,11 @@ describe('requireOpsAccess', () => { headers: { cookie: `openmaic_access=${token}` }, }); - expect(requireOpsAccess(request)).toBeNull(); + await expect(requireOpsAccess(request)).resolves.toBeNull(); }); - it('accepts a same-origin mutation with a valid ops session', () => { - vi.stubEnv('NODE_ENV', 'production'); + it('accepts a same-origin mutation with a development ops session', async () => { + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); const token = createAccessToken('test-access-code'); @@ -51,17 +51,17 @@ describe('requireOpsAccess', () => { }, }); - expect(requireOpsAccess(request)).toBeNull(); + await expect(requireOpsAccess(request)).resolves.toBeNull(); }); it('rejects missing or cross-origin mutation origins', async () => { - vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); const token = createAccessToken('test-access-code'); for (const origin of [undefined, 'https://attacker.example']) { - const response = requireOpsAccess( + const response = await requireOpsAccess( new NextRequest('https://ops.example/api/courses', { method: 'POST', headers: { @@ -76,7 +76,7 @@ describe('requireOpsAccess', () => { }); it('uses a configured public origin and never trusts forged forwarded hosts', async () => { - vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); vi.stubEnv('ACCESS_CODE_TRUST_PROXY_HEADERS', 'true'); @@ -92,7 +92,7 @@ describe('requireOpsAccess', () => { 'x-forwarded-proto': 'https', }, }); - expect(requireOpsAccess(accepted)).toBeNull(); + await expect(requireOpsAccess(accepted)).resolves.toBeNull(); const rejected = new NextRequest('http://internal:3000/api/courses', { method: 'POST', @@ -103,18 +103,18 @@ describe('requireOpsAccess', () => { 'x-forwarded-proto': 'https', }, }); - const response = requireOpsAccess(rejected); + const response = await requireOpsAccess(rejected); expect(response?.status).toBe(403); await expect(response?.json()).resolves.toMatchObject({ errorCode: 'FORBIDDEN' }); }); - it('fails closed when the configured public origin is malformed', () => { - vi.stubEnv('NODE_ENV', 'production'); + it('fails closed when the configured public origin is malformed', async () => { + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); vi.stubEnv('OPS_PUBLIC_ORIGIN', 'https://ops.example/path'); const token = createAccessToken('test-access-code'); - const response = requireOpsAccess( + const response = await requireOpsAccess( new NextRequest('https://ops.example/api/courses', { method: 'POST', headers: { @@ -126,8 +126,8 @@ describe('requireOpsAccess', () => { expect(response?.status).toBe(403); }); - it('keeps production role=all aligned with the ops session contract', () => { - vi.stubEnv('NODE_ENV', 'production'); + it('keeps development role=all aligned with the ops session contract', async () => { + vi.stubEnv('NODE_ENV', 'development'); vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'all'); vi.stubEnv('ACCESS_CODE', 'test-access-code'); const token = createAccessToken('test-access-code'); @@ -139,6 +139,6 @@ describe('requireOpsAccess', () => { }, }); - expect(requireOpsAccess(request)).toBeNull(); + await expect(requireOpsAccess(request)).resolves.toBeNull(); }); });