type ChatTurn = { role: 'user' | 'assistant'; content: string }; export type MakeloreRuntimeAnchor = { sceneId: string; sceneOrder?: number; sceneTitle?: string; actionIndex?: number; }; export type MakeloreCourseRuntimeContext = { courseId: string; /** Aggregate package hash used by Works entitlement and session binding. */ courseContentHash: string; /** Compatibility alias; always the aggregate package hash. */ contentHash: string; moduleId: string | null; /** Selected module hash; equals courseContentHash for a single course. */ moduleContentHash: string; }; const RUNTIME_CAPABILITY_BY_PATH = new Map([ ['/api/quiz-grade', 'quiz-grade'], ['/api/pbl/v2/task/update', 'pbl/v2/task/update'], ['/api/pbl/v2/open-task', 'pbl/v2/open-task'], ['/api/pbl/v2/simulator', 'pbl/v2/simulator'], ['/api/pbl/v2/instructor', 'pbl/v2/instructor'], ['/api/pbl/v2/evaluate', 'pbl/v2/evaluate'], ]); type RuntimeBridgeOptions = { timeoutMs?: number; getAnchor: () => MakeloreRuntimeAnchor | null; }; type PendingRuntimeResponse = { controller: ReadableStreamDefaultController; resolve: (status: number, headers: Headers) => void; reject: (error: Error) => void; started: boolean; settled: boolean; timeout: number; signal?: AbortSignal; abort?: () => void; }; function textOfMessage(value: unknown): string { if (!value || typeof value !== 'object') return ''; const message = value as Record; if (typeof message.content === 'string') return message.content; if (!Array.isArray(message.parts)) return ''; return message.parts.map((part) => { if (!part || typeof part !== 'object') return ''; const value = part as Record; return value.type === 'text' && typeof value.text === 'string' ? value.text : ''; }).filter(Boolean).join('\n'); } function requestFromChatBody(body: Record) { const context = window.__MAKELORE_COURSE_CONTEXT__; if (!context) throw new Error('课程助教上下文尚未就绪'); const messages = Array.isArray(body.messages) ? body.messages : []; const history: ChatTurn[] = messages.flatMap((item): ChatTurn[] => { if (!item || typeof item !== 'object') return []; const role = (item as Record).role; const content = textOfMessage(item).trim(); return (role === 'user' || role === 'assistant') && content ? [{ role, content }] : []; }).slice(-8); const lastUser = [...history].reverse().find((turn) => turn.role === 'user'); if (!lastUser) throw new Error('没有可发送的问题'); const storeState = body.storeState && typeof body.storeState === 'object' ? body.storeState as Record : {}; const scenes = Array.isArray(storeState.scenes) ? storeState.scenes : []; const currentSceneId = typeof storeState.currentSceneId === 'string' ? storeState.currentSceneId : undefined; const currentScene = scenes.find((scene) => scene && typeof scene === 'object' && (scene as Record).id === currentSceneId) as Record | undefined; return { courseId: context.courseId, contentHash: context.contentHash, message: lastUser.content, history: history.slice(0, -1), anchor: { sceneId: currentSceneId, sceneOrder: typeof currentScene?.order === 'number' ? currentScene.order : undefined, sceneTitle: typeof currentScene?.title === 'string' ? currentScene.title : undefined, moduleId: context.moduleId, moduleContentHash: context.moduleContentHash, }, }; } function sseResponse(text: string): Response { const messageId = crypto.randomUUID(); const events = [ { type: 'agent_start', data: { messageId, agentId: 'default-1', agentName: 'AI Teacher' } }, { type: 'text_delta', data: { messageId, content: text } }, { type: 'agent_end', data: { messageId, agentId: 'default-1' } }, { type: 'cue_user', data: { fromAgentId: 'default-1' } }, { type: 'done', data: { totalActions: 0, totalAgents: 1, agentHadContent: Boolean(text), cueUserReceived: true } }, ]; return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { headers: { 'Content-Type': 'text/event-stream' }, }); } export function fetchMakeloreLearningAgent(body: Record, signal: AbortSignal): Promise { const requestId = crypto.randomUUID(); const request = requestFromChatBody(body); return new Promise((resolve, reject) => { const cleanup = () => { window.removeEventListener('message', onMessage); signal.removeEventListener('abort', onAbort); }; const onAbort = () => { cleanup(); reject(new DOMException('Aborted', 'AbortError')); }; const onMessage = (event: MessageEvent) => { if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; const message = event.data as { type?: string; requestId?: string; ok?: boolean; text?: string; error?: string }; if (message.type !== 'makelore:agent:response' || message.requestId !== requestId) return; cleanup(); resolve(message.ok ? sseResponse(message.text || '') : new Response(message.error || '助教回答失败', { status: 502 })); }; window.addEventListener('message', onMessage); signal.addEventListener('abort', onAbort, { once: true }); window.parent.postMessage({ type: 'makelore:agent:request', requestId, request }, '*'); }); } export async function transcribeMakeloreLearningAudio( audioBlob: Blob, options: { fileName?: string; language?: string } = {}, ): Promise { if (!window.__MAKELORE_COURSE_CONTEXT__) throw new Error('课程语音上下文尚未就绪'); const requestId = crypto.randomUUID(); const audio = await audioBlob.arrayBuffer(); return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { cleanup(); reject(new Error('语音识别超时')); }, 60_000); const cleanup = () => { window.clearTimeout(timeout); window.removeEventListener('message', onMessage); }; const onMessage = (event: MessageEvent) => { if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; const message = event.data as { type?: string; requestId?: string; ok?: boolean; text?: string; error?: string }; if (message.type !== 'makelore:transcription:response' || message.requestId !== requestId) return; cleanup(); if (message.ok && message.text) resolve(message.text); else reject(new Error(message.error || '语音识别失败')); }; window.addEventListener('message', onMessage); window.parent.postMessage({ type: 'makelore:transcription:request', requestId, audio, fileName: options.fileName || 'voice.webm', mimeType: audioBlob.type || 'audio/webm', language: options.language, }, '*', [audio]); }); } function runtimeCapability(input: RequestInfo | URL, init?: RequestInit): string | null { // Only literal relative paths are capabilities. Absolute/same-origin URLs, // Request objects, query strings, and lookalike prefixes stay on native fetch. if (typeof input !== 'string') return null; const capability = RUNTIME_CAPABILITY_BY_PATH.get(input); if (!capability) return null; const method = (init?.method ?? 'GET').toUpperCase(); return method === 'POST' ? capability : null; } function structuredRuntimeBody(body: BodyInit | null | undefined): unknown { if (body == null || body === '') return {}; if (typeof body !== 'string') { throw new TypeError('课堂能力桥只接受 JSON 请求体'); } try { return JSON.parse(body) as unknown; } catch { throw new TypeError('课堂能力桥收到无效 JSON'); } } function runtimeChunk(value: unknown): Uint8Array | null { if (value instanceof Uint8Array) return new Uint8Array(value); if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); if (ArrayBuffer.isView(value)) { return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); } return null; } /** * Replace fetch only inside the verified offline player and only for the six * frozen-course host capabilities. Returns an idempotent cleanup function. */ export function installMakeloreRuntimeFetchBridge(options: RuntimeBridgeOptions): () => void { if (!window.__MAKELORE_OFFLINE_PLAYER__) return () => undefined; const nativeFetch = window.fetch.bind(window); const pending = new Map(); const timeoutMs = Math.max(1, options.timeoutMs ?? 300_000); const cleanupPending = (requestId: string, state: PendingRuntimeResponse) => { window.clearTimeout(state.timeout); if (state.signal && state.abort) state.signal.removeEventListener('abort', state.abort); pending.delete(requestId); }; const failPending = (requestId: string, state: PendingRuntimeResponse, error: Error) => { if (state.settled) return; state.settled = true; cleanupPending(requestId, state); if (state.started) state.controller.error(error); else state.reject(error); }; const onMessage = (event: MessageEvent) => { if (event.source !== window.parent || !event.data || typeof event.data !== 'object') return; const message = event.data as { type?: string; requestId?: string; status?: number; contentType?: string; chunk?: unknown; message?: string; code?: string; }; if (typeof message.requestId !== 'string') return; const state = pending.get(message.requestId); if (!state || state.settled) return; if (message.type === 'makelore:runtime:start') { if (state.started) { failPending(message.requestId, state, new Error('课堂能力桥收到重复响应头')); return; } const status = Number(message.status); if (!Number.isInteger(status) || status < 100 || status > 599) { failPending(message.requestId, state, new Error('课堂能力桥响应状态无效')); return; } state.started = true; const headers = new Headers(); if (typeof message.contentType === 'string' && message.contentType.trim()) { headers.set('Content-Type', message.contentType); } state.resolve(status, headers); return; } if (message.type === 'makelore:runtime:chunk') { if (!state.started) { failPending(message.requestId, state, new Error('课堂能力桥在响应头前收到数据')); return; } const chunk = runtimeChunk(message.chunk); if (!chunk) { failPending(message.requestId, state, new Error('课堂能力桥收到无效数据块')); return; } state.controller.enqueue(chunk); return; } if (message.type === 'makelore:runtime:end') { if (!state.started) { failPending(message.requestId, state, new Error('课堂能力桥响应缺少开始事件')); return; } state.settled = true; cleanupPending(message.requestId, state); state.controller.close(); return; } if (message.type === 'makelore:runtime:error') { const prefix = typeof message.code === 'string' ? `${message.code}: ` : ''; failPending( message.requestId, state, new Error(`${prefix}${message.message || '课堂联网能力暂时不可用'}`), ); } }; window.addEventListener('message', onMessage); const bridgedFetch: typeof window.fetch = (input, init) => { const capability = runtimeCapability(input, init); if (!capability) return nativeFetch(input, init); const context = window.__MAKELORE_COURSE_CONTEXT__; const anchor = options.getAnchor(); if (!context || !anchor?.sceneId) { return Promise.reject(new TypeError('课堂运行上下文尚未就绪')); } let body: unknown; try { body = structuredRuntimeBody(init?.body); } catch (error) { return Promise.reject(error); } const signal = init?.signal ?? undefined; if (signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); const requestId = crypto.randomUUID(); let streamController!: ReadableStreamDefaultController; const stream = new ReadableStream({ start(controller) { streamController = controller; }, }); const response = new Promise((resolve, reject) => { const state: PendingRuntimeResponse = { controller: streamController, resolve: (status, headers) => resolve(new Response( status === 204 || status === 205 || status === 304 ? null : stream, { status, headers }, )), reject, started: false, settled: false, timeout: 0, signal, }; state.timeout = window.setTimeout(() => { failPending(requestId, state, new Error('课堂联网能力请求超时')); }, timeoutMs); if (signal) { state.abort = () => failPending(requestId, state, new DOMException('Aborted', 'AbortError')); signal.addEventListener('abort', state.abort, { once: true }); } pending.set(requestId, state); window.parent.postMessage({ type: 'makelore:runtime:request', requestId, capability, method: 'POST', body, context: { courseId: context.courseId, courseContentHash: context.courseContentHash, moduleId: context.moduleId, moduleContentHash: context.moduleContentHash, anchor, }, }, '*'); }); return response; }; window.fetch = bridgedFetch; let uninstalled = false; return () => { if (uninstalled) return; uninstalled = true; if (window.fetch === bridgedFetch) window.fetch = nativeFetch; window.removeEventListener('message', onMessage); for (const [requestId, state] of pending) { failPending(requestId, state, new Error('课堂能力桥已关闭')); } }; }