feat: productionize learning engine and classroom
This commit is contained in:
369
OpenMAIC/lib/makelore-runtime/browser-bridge.ts
Normal file
369
OpenMAIC/lib/makelore-runtime/browser-bridge.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
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<string, string>([
|
||||
['/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<Uint8Array>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return value.type === 'text' && typeof value.text === 'string' ? value.text : '';
|
||||
}).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function requestFromChatBody(body: Record<string, unknown>) {
|
||||
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<string, unknown>).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<string, unknown>
|
||||
: {};
|
||||
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<string, unknown>).id === currentSceneId) as Record<string, unknown> | 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<string, unknown>, signal: AbortSignal): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const request = requestFromChatBody(body);
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', onMessage);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
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<string> {
|
||||
if (!window.__MAKELORE_COURSE_CONTEXT__) throw new Error('课程语音上下文尚未就绪');
|
||||
const requestId = crypto.randomUUID();
|
||||
const audio = await audioBlob.arrayBuffer();
|
||||
return new Promise<string>((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<unknown>) => {
|
||||
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<string, PendingRuntimeResponse>();
|
||||
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<unknown>) => {
|
||||
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<Uint8Array>;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
},
|
||||
});
|
||||
const response = new Promise<Response>((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('课堂能力桥已关闭'));
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user