Files
makelore/electron/services/learning-package-consumer.ts
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AdmZip from 'adm-zip';
import type {
LearningClassroomPayload,
LearningCourse,
LearningCourseModule,
} from '../../shared/learning';
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
type ModuleLocation = {
module: LearningCourseModule;
root: string;
kind: 'legacy' | 'frozen';
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function readEntry(zip: AdmZip, name: string): Promise<Buffer> {
const entry = zip.getEntry(name);
if (!entry || entry.isDirectory || entry.header.size > MAX_DOCUMENT_BYTES) {
return Promise.reject(new Error(`课程包缺少 ${name}`));
}
return new Promise((resolveData, rejectData) => {
entry.getDataAsync((data, error) => error ? rejectData(error) : resolveData(data));
});
}
async function readJson(zip: AdmZip, name: string): Promise<unknown> {
try {
return JSON.parse((await readEntry(zip, name)).toString('utf8')) as unknown;
} catch (error) {
throw new Error(
`${name} 无效${error instanceof Error ? `${error.message}` : ''}`,
{ cause: error },
);
}
}
function safeModuleId(value: unknown, fallback: string): string {
return typeof value === 'string' && MODULE_ID_PATTERN.test(value) ? value : fallback;
}
function safeCount(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
}
function safeHash(value: unknown, fallback: string): string {
return typeof value === 'string' && SHA256_PATTERN.test(value) ? value : fallback;
}
function normalizeRoot(value: unknown): string | null {
if (typeof value !== 'string') return null;
const root = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!root || root.split('/').some((part) => part === '.' || part === '..')) return null;
return `${root}/`;
}
function manifestRoots(zip: AdmZip): string[] {
return zip.getEntries()
.map((entry) => entry.entryName.replace(/\\/g, '/'))
.flatMap((name) => {
if (name === 'manifest.json') return [''];
const match = name.match(/^(modules\/[^/]+\/)manifest\.json$/);
return match ? [match[1]] : [];
})
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
}
function courseJsonModules(value: unknown): Array<Record<string, unknown>> {
return isRecord(value) && Array.isArray(value.modules)
? value.modules.filter(isRecord)
: [];
}
async function frozenModuleMetadata(
zip: AdmZip,
root: string,
fallback: LearningCourseModule,
): Promise<LearningCourseModule> {
const bundle = await readJson(zip, `${root}bundle.json`);
const meta = isRecord(bundle) && isRecord(bundle.meta) ? bundle.meta : null;
if (!meta
|| typeof meta.coursewareId !== 'string'
|| !MODULE_ID_PATTERN.test(meta.coursewareId)
|| !Number.isSafeInteger(meta.sceneCount)
|| Number(meta.sceneCount) < 0
|| typeof meta.contentHash !== 'string'
|| !SHA256_PATTERN.test(meta.contentHash)) {
throw new Error(`${root}bundle.json 模块身份无效`);
}
return {
moduleId: meta.coursewareId,
title: typeof meta.stageName === 'string' && meta.stageName.trim() ? meta.stageName : fallback.title,
summary: fallback.summary,
sceneCount: Number(meta.sceneCount),
contentHash: meta.contentHash,
};
}
async function resolveModuleLocations(zip: AdmZip, course: LearningCourse): Promise<ModuleLocation[]> {
const roots = manifestRoots(zip);
const rawCourseJson = zip.getEntry('course.json') ? await readJson(zip, 'course.json') : null;
const descriptors = courseJsonModules(rawCourseJson);
const declared = course.modules?.length ? course.modules : descriptors.map((value, index) => ({
moduleId: safeModuleId(value.moduleId ?? value.id, `module-${index + 1}`),
title: typeof value.title === 'string' && value.title.trim() ? value.title : `模块 ${index + 1}`,
summary: typeof value.summary === 'string' ? value.summary : null,
sceneCount: safeCount(value.sceneCount, 0),
contentHash: safeHash(value.contentHash, course.contentHash),
}));
if (declared.length > 0) {
const locations: ModuleLocation[] = [];
for (const [index, module] of declared.entries()) {
const descriptor = descriptors.find((value) => value.moduleId === module.moduleId || value.id === module.moduleId)
?? descriptors[index];
const preferredRoot = normalizeRoot(descriptor?.path ?? descriptor?.directory);
const candidates = [
preferredRoot,
`modules/${module.moduleId}/`,
`modules/${index}/`,
`modules/${index + 1}/`,
roots[index],
].filter((value): value is string => value !== null && value !== undefined);
const root = candidates.find((candidate) => zip.getEntry(`${candidate}manifest.json`));
if (!root) throw new Error(`课程模块“${module.title}”缺少 frozen manifest`);
const metadata = await frozenModuleMetadata(zip, root, module);
if (metadata.moduleId !== module.moduleId || metadata.contentHash !== module.contentHash) {
throw new Error(`课程模块“${module.title}”内容校验不一致`);
}
locations.push({ module: { ...module, ...metadata, moduleId: module.moduleId }, root, kind: 'frozen' });
}
return locations;
}
if (roots.length > 0) {
return Promise.all(roots.map(async (root, index) => {
const fallback: LearningCourseModule = {
moduleId: roots.length === 1 ? 'main' : `module-${index + 1}`,
title: roots.length === 1 ? course.title : `模块 ${index + 1}`,
summary: roots.length === 1 ? course.summary : null,
sceneCount: roots.length === 1 ? course.sceneCount : 0,
contentHash: course.contentHash,
};
const module = await frozenModuleMetadata(zip, root, fallback);
return { module, root, kind: 'frozen' as const };
}));
}
if (zip.getEntry('classroom.json')) {
return [{
module: {
moduleId: 'main',
title: course.title,
summary: course.summary,
sceneCount: course.sceneCount,
contentHash: course.contentHash,
},
root: '',
kind: 'legacy',
}];
}
throw new Error('课程包缺少可播放模块');
}
function assetUrl(course: LearningCourse, entryName: string): string {
const encodedPath = entryName.split('/').map(encodeURIComponent).join('/');
return `/course-assets/${encodeURIComponent(course.id)}/${course.contentHash}/${encodedPath}`;
}
function mediaRefFromPath(path: string, mimeType?: unknown): string {
const relative = path.startsWith('media/') ? path.slice('media/'.length) : path;
const suffix = typeof mimeType === 'string' ? mimeType.split('/')[1] : '';
if (suffix && relative.toLowerCase().endsWith(`.${suffix.toLowerCase()}`)) {
return relative.slice(0, -suffix.length - 1);
}
return relative.replace(/\.[^/.]+$/, '');
}
function rewriteDeep(value: unknown, mediaUrls: Map<string, string>): unknown {
if (typeof value === 'string') return mediaUrls.get(value) ?? value;
if (Array.isArray(value)) return value.map((item) => rewriteDeep(item, mediaUrls));
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDeep(item, mediaUrls)]));
}
function rewriteVideoManifest(value: unknown, mediaUrls: Map<string, string>): unknown {
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
mediaUrls.get(key) ?? key,
rewriteDeep(entry, mediaUrls),
]));
}
async function buildFrozenClassroom(
zip: AdmZip,
course: LearningCourse,
location: ModuleLocation,
): Promise<{ stage: Record<string, unknown>; scenes: unknown[] }> {
const [manifestValue, bundleValue] = await Promise.all([
readJson(zip, `${location.root}manifest.json`),
readJson(zip, `${location.root}bundle.json`),
readJson(zip, `${location.root}quiz/quiz.json`),
readJson(zip, `${location.root}knowledge/knowledge.json`),
]);
if (!isRecord(manifestValue)
|| !isRecord(manifestValue.stage)
|| !Array.isArray(manifestValue.scenes)
|| !Array.isArray(manifestValue.agents)
|| manifestValue.agents.some((agent) => !isRecord(agent))
|| !isRecord(manifestValue.mediaIndex)) {
throw new Error('Frozen manifest 结构无效');
}
const bundle = isRecord(bundleValue) ? bundleValue : {};
const meta = isRecord(bundle.meta) ? bundle.meta : {};
const completeness = isRecord(bundle.completeness) ? bundle.completeness : {};
if (completeness.complete !== true) throw new Error('课程模块资源不完整,无法离线播放');
if (safeCount(meta.sceneCount, -1) !== manifestValue.scenes.length) {
throw new Error('课程模块场景数量不一致');
}
if (safeHash(meta.contentHash, '') !== location.module.contentHash) {
throw new Error('课程模块内容哈希不一致');
}
const mediaUrls = new Map<string, string>();
for (const [relativePath, mediaValue] of Object.entries(manifestValue.mediaIndex)) {
if (!isRecord(mediaValue) || mediaValue.missing === true) continue;
const fullPath = `${location.root}${relativePath}`;
if (!zip.getEntry(fullPath)) throw new Error(`课程模块缺少资源 ${relativePath}`);
const url = assetUrl(course, fullPath);
mediaUrls.set(relativePath, url);
if (mediaValue.type === 'generated' || mediaValue.type === 'image') {
mediaUrls.set(mediaRefFromPath(relativePath, mediaValue.mimeType), url);
}
}
const stageId = `learning_${course.id}_${location.module.moduleId}`;
const agentIds = manifestValue.agents.map((_agent, index) => `${stageId}_a${index}`);
const agents = manifestValue.agents.filter(isRecord).map((agent, index) => ({
id: agentIds[index],
name: typeof agent.name === 'string' ? agent.name : `Agent ${index + 1}`,
role: typeof agent.role === 'string' ? agent.role : 'teacher',
persona: typeof agent.persona === 'string' ? agent.persona : '',
avatar: typeof agent.avatar === 'string' ? agent.avatar : '/avatars/teacher.png',
color: typeof agent.color === 'string' ? agent.color : '#3b82f6',
priority: typeof agent.priority === 'number' ? agent.priority : index,
...(isRecord(agent.voiceConfig) ? { voiceConfig: agent.voiceConfig } : {}),
...(isRecord(agent.voiceDesign) ? { voiceDesign: agent.voiceDesign } : {}),
}));
const fallbackAgentIndex = manifestValue.agents.findIndex((agent) => isRecord(agent) && agent.role !== 'teacher');
const stageSource = manifestValue.stage;
const stage: Record<string, unknown> = {
id: stageId,
name: typeof stageSource.name === 'string' ? stageSource.name : location.module.title,
...(typeof stageSource.description === 'string' ? { description: stageSource.description } : {}),
...(typeof stageSource.language === 'string' ? { languageDirective: stageSource.language } : {}),
...(typeof stageSource.style === 'string' ? { style: stageSource.style } : {}),
createdAt: typeof stageSource.createdAt === 'number' ? stageSource.createdAt : Date.now(),
updatedAt: typeof stageSource.updatedAt === 'number' ? stageSource.updatedAt : Date.now(),
agentIds,
generatedAgentConfigs: agents,
...(Array.isArray(stageSource.whiteboard)
? { whiteboard: rewriteDeep(stageSource.whiteboard, mediaUrls) }
: {}),
...(stageSource.interactiveMode === true ? { interactiveMode: true } : {}),
...(stageSource.taskEngineMode === true ? { taskEngineMode: true } : {}),
...(stageSource.videoManifest
? { videoManifest: rewriteVideoManifest(stageSource.videoManifest, mediaUrls) }
: {}),
};
const scenes = manifestValue.scenes.map((sceneValue, index) => {
if (!isRecord(sceneValue) || !isRecord(sceneValue.content)) {
throw new Error(`课程模块第 ${index + 1} 个场景无效`);
}
const actions = Array.isArray(sceneValue.actions) ? sceneValue.actions.map((actionValue) => {
if (!isRecord(actionValue)) return actionValue;
if (actionValue.type === 'speech' && typeof actionValue.audioRef === 'string') {
const { audioRef, ...rest } = actionValue;
const audioUrl = mediaUrls.get(audioRef);
if (!audioUrl) throw new Error(`课程旁白资源缺失:${audioRef}`);
return { ...rest, audioUrl };
}
if (actionValue.type === 'discussion') {
const { agentIndex, agentId: legacyAgentId, ...rest } = actionValue;
const indexValue = typeof agentIndex === 'number' ? agentIndex : fallbackAgentIndex;
const agentId = agentIds[indexValue] || (typeof legacyAgentId === 'string' ? legacyAgentId : undefined);
return { ...rest, ...(agentId ? { agentId } : {}) };
}
return rewriteDeep(actionValue, mediaUrls);
}) : undefined;
const multiAgent = isRecord(sceneValue.multiAgent) && sceneValue.multiAgent.enabled === true
? {
enabled: true,
agentIds: Array.isArray(sceneValue.multiAgent.agentIndices)
? sceneValue.multiAgent.agentIndices
.map((value) => typeof value === 'number' ? agentIds[value] : undefined)
.filter(Boolean)
: [],
...(typeof sceneValue.multiAgent.directorPrompt === 'string'
? { directorPrompt: sceneValue.multiAgent.directorPrompt }
: {}),
}
: undefined;
return {
id: `${stageId}_s${index}`,
stageId,
title: typeof sceneValue.title === 'string' ? sceneValue.title : `场景 ${index + 1}`,
order: safeCount(sceneValue.order, index),
type: typeof sceneValue.type === 'string' ? sceneValue.type : sceneValue.content.type,
content: rewriteDeep(sceneValue.content, mediaUrls),
...(actions ? { actions } : {}),
...(Array.isArray(sceneValue.whiteboards)
? { whiteboards: rewriteDeep(sceneValue.whiteboards, mediaUrls) }
: {}),
...(multiAgent ? { multiAgent } : {}),
createdAt: Date.now(),
updatedAt: Date.now(),
};
});
return { stage, scenes };
}
export async function readLearningPackageClassroom(
zip: AdmZip,
course: LearningCourse,
requestedModuleId?: string,
): Promise<LearningClassroomPayload> {
const locations = await resolveModuleLocations(zip, course);
const selected = requestedModuleId
? locations.find((location) => location.module.moduleId === requestedModuleId)
: locations[0];
if (!selected) throw new Error('找不到指定课程模块');
const classroomValue = selected.kind === 'legacy'
? await readJson(zip, 'classroom.json')
: await buildFrozenClassroom(zip, course, selected);
if (!isRecord(classroomValue)
|| !isRecord(classroomValue.stage)
|| typeof classroomValue.stage.id !== 'string'
|| !Array.isArray(classroomValue.scenes)
|| classroomValue.scenes.length !== selected.module.sceneCount) {
throw new Error('课程播放数据与模块信息不匹配');
}
return {
courseId: course.id,
courseContentHash: course.contentHash,
contentHash: course.contentHash,
// Works treats modules as an aggregate-course concept. Keep the local
// synthetic `main` descriptor for playback metadata, but omit it from
// cloud progress/runtime context for a single-course package.
moduleId: course.capabilities.modular === true ? selected.module.moduleId : null,
moduleContentHash: selected.module.contentHash,
modules: locations.map((location) => location.module),
classroom: classroomValue as { stage: Record<string, unknown>; scenes: unknown[] },
};
}