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