Files
openmaic/OpenMAIC/lib/bundle/use-publish-courseware.ts
2026-08-16 14:58:47 +08:00

111 lines
4.5 KiB
TypeScript

'use client';
// Ops publish workbench hook — packages the current classroom into a frozen
// bundle in the browser and uploads it to the courseware registry (L2).
//
// Collection mirrors the interactive-ZIP export (IndexedDB + asset pool);
// packaging and the wire contract are the same `lib/bundle` code the server
// uses, so what the ops end previews is exactly what the learner end plays.
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { useStageStore } from '@/lib/store/stage';
import { useI18n } from '@/lib/hooks/use-i18n';
import { collectAudioFiles, collectMediaFiles } from '@/lib/export/classroom-zip-utils';
import { preparePBLScenesForDocumentPersistence } from '@/lib/pbl/v2/runtime/document-persistence';
import { createProxiedFetch } from '@/lib/export/proxied-fetch';
import { createLogger } from '@/lib/logger';
import { packageCourseware, type PackageResult } from '@/lib/bundle/packager';
import { uploadCoursewareZip, type PublishedRecord } from '@/lib/bundle/client-upload';
import { getDefaultGeneratedAgentConfigs } from '@/lib/orchestration/registry/store';
const log = createLogger('PublishCourseware');
export type PublishStatus = 'idle' | 'packaging' | 'uploading' | 'success' | 'error';
export interface PublishState {
status: PublishStatus;
record?: PublishedRecord;
/** Present on success — the local freeze report before registry version stamping. */
package?: PackageResult;
/** Present on error — the failure message (incl. missing-resource detail). */
error?: string;
}
const IDLE: PublishState = { status: 'idle' };
export function usePublishCourseware() {
const { t } = useI18n();
const [state, setState] = useState<PublishState>(IDLE);
const publish = useCallback(async () => {
const { stage, scenes } = useStageStore.getState();
if (!stage?.id || scenes.length === 0) return;
setState({ status: 'packaging' });
const toastId = toast.loading(t('publish.packaging'));
try {
// 1. Collect bytes (IndexedDB + asset pool) — same sources as the ZIP export.
const documentScenes = await preparePBLScenesForDocumentPersistence(stage.id, scenes);
const audioFiles = await collectAudioFiles(documentScenes);
const mediaFiles = await collectMediaFiles(stage.id);
const audioById = new Map(audioFiles.map((a) => [a.record.id, a.record]));
const mediaByElementId = new Map(mediaFiles.map((m) => [m.elementId, m.record]));
// 2. Package (freeze gate: every speech/media resource must resolve).
const agentConfigs = stage.generatedAgentConfigs?.length
? stage.generatedAgentConfigs
: getDefaultGeneratedAgentConfigs(stage.agentIds);
const packaged = await packageCourseware({
coursewareId: stage.id,
stage,
scenes: documentScenes,
agentConfigs,
mediaRecords: mediaFiles.map((m) => m.record),
resolveAudioBytes: async (audioId) => audioById.get(audioId)?.blob ?? null,
resolveMediaBytes: async (record) => {
const elementId = record.id.includes(':')
? record.id.split(':').slice(1).join(':')
: record.id;
return mediaByElementId.get(elementId)?.blob ?? null;
},
fetchImpl: createProxiedFetch(),
requireAgentRoster: true,
strictInteractiveAssets: true,
requireComplete: true,
});
// 3. Upload as an unversioned template. The server allocates the real
// monotonic version under its publish lock and stamps it into bundle.json.
setState({ status: 'uploading' });
toast.loading(t('publish.uploading'), { id: toastId });
const result = await uploadCoursewareZip({
zip: packaged.zip,
coursewareId: stage.id,
});
if (!result.ok) {
log.error('Publish upload failed:', result);
toast.error(t('publish.failed'), { id: toastId });
setState({ status: 'error', error: result.details ?? result.error });
return;
}
log.info(`Published courseware ${result.record?.coursewareId} v${result.record?.version}`);
toast.success(t('publish.success'), { id: toastId });
setState({ status: 'success', record: result.record, package: packaged });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error('Courseware publish failed:', error);
toast.error(t('publish.failed'), { id: toastId });
setState({ status: 'error', error: message });
}
}, [t]);
const reset = useCallback(() => setState(IDLE), []);
return { state, publish, reset };
}