Files
openmaic/OpenMAIC/lib/makelore-runtime/browser-bridge.ts

370 lines
14 KiB
TypeScript

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('课堂能力桥已关闭'));
}
};
}