OpenMAIC-React / src /pages /ClassroomPage.tsx
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
import { Stage } from '@/components/stage';
import { ThemeProvider } from '@/lib/hooks/use-theme';
import { useStageStore } from '@/lib/store';
import { loadImageMapping } from '@/lib/utils/image-storage';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
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';
const log = createLogger('Classroom');
export default function ClassroomDetailPage() {
const params = useParams();
const classroomId = params?.id as string;
const { loadFromStorage } = useStageStore();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const generationStartedRef = useRef(false);
const { generateRemaining, retrySingleOutline, stop } = useSceneGenerator({
onComplete: () => {
log.info('[Classroom] All scenes generated');
},
});
const loadClassroom = useCallback(async () => {
try {
await loadFromStorage(classroomId);
// If IndexedDB had no data, try server-side storage (API-generated classrooms)
if (!useStageStore.getState().stage) {
log.info('No IndexedDB data, trying server-side storage for:', classroomId);
try {
const res = await fetch(`/api/classroom?id=${encodeURIComponent(classroomId)}`);
if (res.ok) {
const json = await res.json();
if (json.success && json.classroom) {
const { stage, scenes } = json.classroom;
useStageStore.getState().setStage(stage);
useStageStore.setState({
scenes,
currentSceneId: scenes[0]?.id ?? null,
});
log.info('Loaded from server-side storage:', classroomId);
// Hydrate server-generated agents into IndexedDB + registry.
// Don't set selectedAgentIds here — the general agent
// restoration logic below (Path 2) handles it uniformly.
if (stage.generatedAgentConfigs?.length) {
const { saveGeneratedAgents } = await import('@/lib/orchestration/registry/store');
await saveGeneratedAgents(stage.id, stage.generatedAgentConfigs);
log.info('Hydrated server-generated agents for stage:', stage.id);
}
}
}
} catch (fetchErr) {
log.warn('Server-side storage fetch failed:', fetchErr);
}
}
// Restore completed media generation tasks from IndexedDB
await useMediaGenerationStore.getState().restoreFromDB(classroomId);
// Restore agents for this stage
const { loadGeneratedAgentsForStage, useAgentRegistry } =
await import('@/lib/orchestration/registry/store');
const generatedAgentIds = await loadGeneratedAgentsForStage(classroomId);
const { useSettingsStore } = await import('@/lib/store/settings');
if (generatedAgentIds.length > 0) {
// Auto mode — use generated agents from IndexedDB
useSettingsStore.getState().setAgentMode('auto');
useSettingsStore.getState().setSelectedAgentIds(generatedAgentIds);
} else {
// Preset mode — restore agent IDs saved in the stage at creation time.
// Filter out any stale generated IDs that may have been persisted before
// the bleed-fix, so they don't resolve against a leftover registry entry.
const stage = useStageStore.getState().stage;
const stageAgentIds = stage?.agentIds;
const registry = useAgentRegistry.getState();
const cleanIds = stageAgentIds?.filter((id) => {
const a = registry.getAgent(id);
return a && !a.isGenerated;
});
useSettingsStore.getState().setAgentMode('preset');
useSettingsStore
.getState()
.setSelectedAgentIds(
cleanIds && cleanIds.length > 0 ? cleanIds : ['default-1', 'default-2', 'default-3'],
);
}
} catch (error) {
log.error('Failed to load classroom:', error);
setError(error instanceof Error ? error.message : 'Failed to load classroom');
} finally {
setLoading(false);
}
}, [classroomId, loadFromStorage]);
useEffect(() => {
// Reset loading state on course switch to unmount Stage during transition,
// preventing stale data from syncing back to the new course
setLoading(true);
setError(null);
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();
loadClassroom();
// Cancel ongoing generation when classroomId changes or component unmounts
return () => {
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 } = state;
// Check if there are pending outlines
const completedOrders = new Set(scenes.map((s) => s.order));
const hasPending = outlines.some((o) => !completedOrders.has(o.order));
if (hasPending && stage) {
generationStartedRef.current = true;
// 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 (outlines.length > 0 && 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;
generateMediaForOutlines(outlines, stage.id).catch((err) => {
log.warn('[Classroom] Media generation resume error:', err);
});
}
}, [loading, error, generateRemaining]);
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>
) : (
<Stage onRetryOutline={retrySingleOutline} />
)}
</div>
</MediaStageProvider>
</ThemeProvider>
);
}