Files
openmaic/OpenMAIC/app/classroom/[id]/page.tsx
T

262 lines
11 KiB
TypeScript

'use client';
import { Stage } from '@/components/stage';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { useStageStore } from '@/lib/store';
import { useSettingsStore } from '@/lib/store/settings';
import { claimStageSceneLoadToken, isCurrentStageSceneLoadToken } from '@/lib/store/stage';
import { loadImageMapping } from '@/lib/utils/image-storage';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useSearchParams, useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSceneGenerator } from '@/lib/hooks/use-scene-generator';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useWhiteboardHistoryStore } from '@/lib/store/whiteboard-history';
import { createLogger } from '@/lib/logger';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import {
applyClassroomStageAndScenes,
defaultClassroomLoadDeps,
runClassroomLoad,
} from '@/lib/classroom/load-classroom';
import { GenerationResume } from '@/components/stage/generation-resume';
import { classroomGenerationResumeMode } from '@/lib/classroom/generation-resume-policy';
const log = createLogger('Classroom');
export default function ClassroomDetailPage() {
const params = useParams();
const router = useRouter();
const { t } = useI18n();
const classroomId = params?.id as string;
// Learner mode keeps the original Stage chrome, including its contextual
// ChatArea/Roundtable. The query is only used for course navigation here.
const searchParams = useSearchParams();
const learnerMode = searchParams.get('learner') === '1';
// Large-course mode: entered through a course framework page — offer a way
// back so the learner can continue with the next module.
const learnerCourseId = searchParams.get('course') ?? '';
const learnerCourseVersion = searchParams.get('courseVersion') ?? '';
const { loadFromStorage } = useStageStore();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Generation-resume overlay: reopened mid-generation → show progress instead
// of dumping the user into a half-generated deck.
const [resuming, setResuming] = useState(false);
const [resumeDone, setResumeDone] = useState(false);
const generationStartedRef = useRef(false);
const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
onComplete: () => {
log.info('[Classroom] All scenes generated');
setResumeDone(true);
},
});
const loadClassroom = useCallback(
async (isEffectCurrent: () => boolean = () => true) => {
const loadToken = claimStageSceneLoadToken();
const isCurrent = () => isEffectCurrent() && isCurrentStageSceneLoadToken(loadToken);
await runClassroomLoad({
classroomId,
loadToken,
isCurrent,
loadFromStorage,
getCurrentStage: () => useStageStore.getState().stage,
fetchClassroom: defaultClassroomLoadDeps.fetchClassroom,
applyFallbackScenes: (args) =>
defaultClassroomLoadDeps.applyFallbackScenes({
...args,
isCurrent,
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: defaultClassroomLoadDeps.applyRestoredMediaTasks,
discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks,
loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks,
commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs,
applyGeneratedAgents: defaultClassroomLoadDeps.applyGeneratedAgents,
getSettings: () => useSettingsStore.getState(),
getAgent: (id) => useAgentRegistry.getState().getAgent(id),
restoreAgentSelection: defaultClassroomLoadDeps.restoreAgentSelection,
setError,
setLoading,
log,
});
},
[classroomId, loadFromStorage],
);
useEffect(() => {
// Reset loading state on course switch to unmount Stage during transition,
// preventing stale data from syncing back to the new course
/* eslint-disable react-hooks/set-state-in-effect -- Course switch must hide stale Stage before async load */
setLoading(true);
setError(null);
setResuming(false);
setResumeDone(false);
/* eslint-enable react-hooks/set-state-in-effect */
generationStartedRef.current = false;
// Clear previous classroom's media tasks to prevent cross-classroom contamination.
// Placeholder IDs (gen_img_1, gen_vid_1) are NOT globally unique across stages,
// so stale tasks from a previous classroom would shadow the new one's.
const mediaStore = useMediaGenerationStore.getState();
mediaStore.revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
// Clear whiteboard history to prevent snapshots from a previous course leaking in.
useWhiteboardHistoryStore.getState().clearHistory();
let cancelled = false;
loadClassroom(() => !cancelled);
// Cancel ongoing generation when classroomId changes or component unmounts
return () => {
cancelled = true;
stop();
};
}, [classroomId, loadClassroom, stop]);
// Auto-resume generation for pending outlines
useEffect(() => {
if (loading || error || generationStartedRef.current) return;
const state = useStageStore.getState();
const { outlines, scenes, stage, generationComplete } = state;
const resumeMode = classroomGenerationResumeMode({
learnerMode,
generationComplete,
outlineOrders: outlines.map((outline) => outline.order),
sceneOrders: scenes.map((scene) => scene.order),
});
if (resumeMode === 'scenes' && stage) {
generationStartedRef.current = true;
/* eslint-disable react-hooks/set-state-in-effect -- resume overlay must
mount synchronously to avoid flashing the half-generated deck */
setResuming(true);
/* eslint-enable react-hooks/set-state-in-effect */
// Load generation params from sessionStorage (stored by generation-preview before navigating)
const genParamsStr = sessionStorage.getItem('generationParams');
const params = genParamsStr ? JSON.parse(genParamsStr) : {};
// Reconstruct imageMapping from IndexedDB using pdfImages storageIds
const storageIds = (params.pdfImages || [])
.map((img: { storageId?: string }) => img.storageId)
.filter(Boolean);
loadImageMapping(storageIds).then((imageMapping) => {
generateRemaining({
pdfImages: params.pdfImages,
imageMapping,
stageInfo: {
name: stage.name || '',
description: stage.description,
style: stage.style,
},
agents: params.agents,
userProfile: params.userProfile,
languageDirective: params.languageDirective || stage.languageDirective,
});
});
} else if (resumeMode === 'media' && stage) {
// All scenes are generated, but some media may not have finished.
// Resume media generation for any tasks not yet in IndexedDB.
// generateMediaForOutlines skips already-completed tasks automatically.
generationStartedRef.current = true;
setResuming(true);
// The deck reached the classroom already fully materialized (e.g. a
// single-slide course, or a deck whose last slide finished in
// generation-preview), so generateRemaining's completion path never
// ran. Record completion now so a later edit/delete is not treated as
// an interrupted generation. No-op if already complete or not all
// outlines have scenes.
useStageStore.getState().markGenerationCompleteIfDone();
// Resume media only for outlines that still have a scene. On a finished
// deck the user may have deleted a slide, leaving an orphaned outline;
// generating its media would waste API calls on a slide that is gone.
const materializedOrders = new Set(scenes.map((s) => s.order));
const materializedOutlines = outlines.filter((o) => materializedOrders.has(o.order));
generateMediaForOutlines(materializedOutlines, stage.id)
.catch((err) => {
log.warn('[Classroom] Media generation resume error:', err);
})
.finally(() => {
setResumeDone(true);
});
} else {
// Nothing to resume — straight into the deck.
setResumeDone(true);
}
}, [loading, error, generateRemaining, learnerMode]);
return (
<ThemeProvider>
<MediaStageProvider value={classroomId}>
<div className="h-screen flex flex-col overflow-hidden">
{loading ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center text-muted-foreground">
<p>Loading classroom...</p>
</div>
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<p className="text-destructive mb-4">Error: {error}</p>
<button
onClick={() => {
setError(null);
setLoading(true);
loadClassroom();
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
) : resuming && !resumeDone ? (
<GenerationResume
onEnterClassroom={() => setResumeDone(true)}
onRetryOutline={retrySingleOutline}
/>
) : (
<Stage onRetryOutline={retrySingleOutline} />
)}
{/* Large-course mode: back to the course framework page so the learner
can continue with the next module (only when entered via a course). */}
{learnerMode && learnerCourseId && (
<button
onClick={() =>
router.push(
`/learn/course/${encodeURIComponent(learnerCourseId)}${
learnerCourseVersion
? `?version=${encodeURIComponent(learnerCourseVersion)}`
: ''
}`,
)
}
className="fixed top-4 left-4 z-50 inline-flex items-center gap-1.5 rounded-full bg-white/85 dark:bg-zinc-900/85 backdrop-blur-md border border-border/60 px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-white dark:hover:bg-zinc-900 transition-colors"
>
<ArrowLeft className="size-3.5" />
{t('course.learner.backToCourse')}
</button>
)}
</div>
</MediaStageProvider>
</ThemeProvider>
);
}