feat: productionize learning engine and classroom
This commit is contained in:
201
OpenMAIC/app/makelore-player/page.tsx
Normal file
201
OpenMAIC/app/makelore-player/page.tsx
Normal file
@@ -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<string, unknown>;
|
||||
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<ClassroomPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialSceneOrder, setInitialSceneOrder] = useState<number | null>(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<MakelorePlaybackProgress>).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<unknown>) => {
|
||||
if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return;
|
||||
const message = event.data as Partial<CourseLoadMessage>;
|
||||
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 (
|
||||
<ThemeProvider>
|
||||
<MediaStageProvider value={classroom?.stage.id ?? 'makelore-loading'}>
|
||||
<main className="flex h-screen overflow-hidden bg-background">
|
||||
{classroom ? (
|
||||
<Stage />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{error ?? '正在打开课件…'}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</MediaStageProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MAKELORE_OFFLINE_PLAYER__?: boolean;
|
||||
__MAKELORE_COURSE_CONTEXT__?: MakeloreCourseRuntimeContext;
|
||||
__MAKELORE_INITIAL_PROGRESS__?: {
|
||||
sceneOrder?: number;
|
||||
actionIndex?: number | null;
|
||||
completed?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user