feat: integrate learning module
This commit is contained in:
190
electron/services/learning-agent-client.ts
Normal file
190
electron/services/learning-agent-client.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
|
||||
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export type LearningAgentRequest = {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
message: string;
|
||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchor?: {
|
||||
sceneId?: string;
|
||||
sceneOrder?: number;
|
||||
sceneTitle?: string;
|
||||
actionIndex?: number;
|
||||
moduleId?: string | null;
|
||||
moduleContentHash?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Dependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
class LearningAgentHttpError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'LearningAgentHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function createLearningAgentClient(dependencies: Dependencies = {}) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
const sessions = new Map<string, string>();
|
||||
const sessionFlights = new Map<string, Promise<string>>();
|
||||
|
||||
async function authorizedJson(path: string, init: RequestInit): Promise<Record<string, unknown>> {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) throw new Error('请先登录');
|
||||
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = record(record(payload).detail);
|
||||
throw new LearningAgentHttpError(
|
||||
response.status,
|
||||
typeof detail.message === 'string' ? detail.message : '助教服务暂时不可用',
|
||||
);
|
||||
}
|
||||
return record(payload);
|
||||
}
|
||||
|
||||
async function createSession(bindingKey: string, courseId: string): Promise<string> {
|
||||
const existing = sessions.get(bindingKey);
|
||||
if (existing) return existing;
|
||||
const inFlight = sessionFlights.get(bindingKey);
|
||||
if (inFlight) return inFlight;
|
||||
const pending = authorizedJson('/api/agents/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_session_id: `learning-${courseId}-${randomUUID()}`,
|
||||
runtime: 'learning',
|
||||
runtime_version: 'v1',
|
||||
binding: { kind: 'learning-course', key: bindingKey },
|
||||
}),
|
||||
}).then((session) => {
|
||||
const sessionId = String(session.session_id || '');
|
||||
if (!sessionId) throw new Error('助教会话创建失败');
|
||||
sessions.set(bindingKey, sessionId);
|
||||
return sessionId;
|
||||
}).finally(() => sessionFlights.delete(bindingKey));
|
||||
sessionFlights.set(bindingKey, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function runTurn(sessionId: string, input: LearningAgentRequest): Promise<{ text: string }> {
|
||||
const command = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
client_command_id: `turn-${randomUUID()}`,
|
||||
name: 'turn.submit',
|
||||
input: {
|
||||
message: input.message.trim().slice(0, 4000),
|
||||
// Compatibility context for an older runtime. The durable Works
|
||||
// Agent session remains authoritative across turns.
|
||||
history: (input.history ?? []).slice(-8),
|
||||
anchor: input.anchor ?? {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const runId = String(command.run_id || '');
|
||||
const ticket = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ transport: 'sse' }),
|
||||
});
|
||||
const streamUrl = String(ticket.stream_url || '');
|
||||
if (!runId || !streamUrl.startsWith('/api/agents/')) throw new Error('助教事件通道创建失败');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 120_000);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${apiBaseUrl}${streamUrl}`, { headers: { Accept: 'text/event-stream' }, signal: controller.signal });
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
clearTimeout(timeout);
|
||||
throw new LearningAgentHttpError(response.status, '助教事件通道不可用');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let text = '';
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() || '';
|
||||
for (const frame of frames) {
|
||||
const data = frame.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
|
||||
if (!data) continue;
|
||||
const envelope = record(JSON.parse(data));
|
||||
if (envelope.run_id !== runId) continue;
|
||||
const payload = record(envelope.payload);
|
||||
if (envelope.type === 'learning.assistant.delta' && typeof payload.delta === 'string') text += payload.delta;
|
||||
if (envelope.type === 'learning.assistant.failed') throw new Error(typeof payload.message === 'string' ? payload.message : '助教回答失败');
|
||||
if (envelope.type === 'learning.assistant.completed') {
|
||||
controller.abort();
|
||||
return { text: text.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
throw new Error('助教回答意外中断');
|
||||
}
|
||||
|
||||
async function ask(input: LearningAgentRequest): Promise<{ text: string }> {
|
||||
if (!COURSE_ID_PATTERN.test(input.courseId)
|
||||
|| !SHA256_PATTERN.test(input.contentHash)
|
||||
|| !input.message.trim()) throw new Error('助教请求无效');
|
||||
const bindingKey = `${input.courseId}:${input.contentHash}`;
|
||||
let sessionId = await createSession(bindingKey, input.courseId);
|
||||
try {
|
||||
return await runTurn(sessionId, input);
|
||||
} catch (error) {
|
||||
if (!(error instanceof LearningAgentHttpError) || error.status !== 404) throw error;
|
||||
sessions.delete(bindingKey);
|
||||
sessionId = await createSession(bindingKey, input.courseId);
|
||||
return runTurn(sessionId, input);
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(courseId?: string, contentHash?: string): Promise<void> {
|
||||
const exactKey = courseId && contentHash ? `${courseId}:${contentHash}` : null;
|
||||
const entries = [...sessions.entries()].filter(([key]) => exactKey
|
||||
? key === exactKey
|
||||
: courseId ? key.startsWith(`${courseId}:`) : true);
|
||||
for (const [key, sessionId] of entries) {
|
||||
sessions.delete(key);
|
||||
void authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return { ask, reset };
|
||||
}
|
||||
340
electron/services/learning-course-library.ts
Normal file
340
electron/services/learning-course-library.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
import type { InstalledLearningCourse, LearningClassroomPayload, LearningCourse } from '../../shared/learning';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
import { readLearningPackageClassroom } from './learning-package-consumer';
|
||||
import { registerLearningCoursePackage } from './learning-player-server';
|
||||
|
||||
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export class LearningCourseLibraryError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'LearningCourseLibraryError';
|
||||
}
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
rootDirectory: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
function courseDirectory(root: string, course: LearningCourse): string {
|
||||
return join(root, 'learning', 'courses', course.id, course.contentHash);
|
||||
}
|
||||
|
||||
function assertCourseId(courseId: string): void {
|
||||
if (!COURSE_ID_PATTERN.test(courseId)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_ID_INVALID', '课程编号无效');
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseCourse(payload: unknown): LearningCourse {
|
||||
const capabilities = isRecord(payload) && isRecord(payload.capabilities) ? payload.capabilities : null;
|
||||
const hasSingleCapabilities = Boolean(capabilities
|
||||
&& (capabilities.modular === undefined || capabilities.modular === false)
|
||||
&& Array.isArray(capabilities.sceneKinds)
|
||||
&& capabilities.sceneKinds.every((kind) => ['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind)))
|
||||
&& typeof capabilities.hasAudio === 'boolean'
|
||||
&& typeof capabilities.hasWhiteboard === 'boolean'
|
||||
&& typeof capabilities.hasAgent === 'boolean');
|
||||
const hasLargeCapabilities = Boolean(capabilities
|
||||
&& capabilities.modular === true
|
||||
&& capabilities.orderedModules === true
|
||||
&& capabilities.productionStagePerModule === true);
|
||||
const modules = isRecord(payload) && Array.isArray(payload.modules) ? payload.modules : null;
|
||||
const moduleIds = modules?.flatMap((module) => isRecord(module) && typeof module.moduleId === 'string'
|
||||
? [module.moduleId]
|
||||
: []) ?? [];
|
||||
if (!isRecord(payload)
|
||||
|| typeof payload.id !== 'string'
|
||||
|| !COURSE_ID_PATTERN.test(payload.id)
|
||||
|| !['user_single', 'ops_large'].includes(String(payload.origin))
|
||||
|| !['generating', 'ready', 'published', 'failed', 'archived'].includes(String(payload.status))
|
||||
|| typeof payload.title !== 'string'
|
||||
|| !payload.title.trim()
|
||||
|| (payload.summary !== null && typeof payload.summary !== 'string')
|
||||
|| (payload.language !== null && typeof payload.language !== 'string')
|
||||
|| typeof payload.contentHash !== 'string'
|
||||
|| !SHA256_PATTERN.test(payload.contentHash)
|
||||
|| typeof payload.archiveSha256 !== 'string'
|
||||
|| !SHA256_PATTERN.test(payload.archiveSha256)
|
||||
|| typeof payload.archiveBytes !== 'number'
|
||||
|| !Number.isSafeInteger(payload.archiveBytes)
|
||||
|| payload.archiveBytes <= 0
|
||||
|| !Number.isSafeInteger(payload.formatVersion)
|
||||
|| Number(payload.formatVersion) < 1
|
||||
|| typeof payload.minPlayerVersion !== 'string'
|
||||
|| !payload.minPlayerVersion.trim()
|
||||
|| !Number.isSafeInteger(payload.sceneCount)
|
||||
|| Number(payload.sceneCount) < 0
|
||||
|| (!hasSingleCapabilities && !hasLargeCapabilities)
|
||||
|| typeof payload.createdAt !== 'string'
|
||||
|| (payload.publishedAt !== null && typeof payload.publishedAt !== 'string')
|
||||
|| (modules && new Set(moduleIds).size !== modules.length)
|
||||
|| (payload.modules !== undefined && (!modules || modules.some((module) => (
|
||||
!isRecord(module)
|
||||
|| typeof module.moduleId !== 'string'
|
||||
|| !MODULE_ID_PATTERN.test(module.moduleId)
|
||||
|| typeof module.title !== 'string'
|
||||
|| (module.summary !== null && typeof module.summary !== 'string')
|
||||
|| typeof module.sceneCount !== 'number'
|
||||
|| !Number.isSafeInteger(module.sceneCount)
|
||||
|| module.sceneCount < 0
|
||||
|| typeof module.contentHash !== 'string'
|
||||
|| !SHA256_PATTERN.test(module.contentHash)
|
||||
))))) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_INVALID', '课程信息不完整');
|
||||
}
|
||||
return payload as LearningCourse;
|
||||
}
|
||||
|
||||
function parseInstalled(payload: unknown): InstalledLearningCourse | null {
|
||||
if (!isRecord(payload)
|
||||
|| payload.schemaVersion !== 1
|
||||
|| typeof payload.archivePath !== 'string'
|
||||
|| typeof payload.installedAt !== 'string') return null;
|
||||
try {
|
||||
return { ...payload, course: parseCourse(payload.course) } as InstalledLearningCourse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(response: Response): Promise<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
function upstreamError(payload: unknown, status: number): LearningCourseLibraryError {
|
||||
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
|
||||
return new LearningCourseLibraryError(
|
||||
detail && typeof detail.code === 'string' ? detail.code : `LEARNING_HTTP_${status}`,
|
||||
detail && typeof detail.message === 'string' ? detail.message : '学习服务暂时不可用',
|
||||
);
|
||||
}
|
||||
|
||||
async function streamVerifiedArchive(
|
||||
response: Response,
|
||||
temporaryPath: string,
|
||||
course: LearningCourse,
|
||||
): Promise<void> {
|
||||
if (!response.body) {
|
||||
throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_EMPTY', '课程包内容为空');
|
||||
}
|
||||
const handle = await open(temporaryPath, 'wx');
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const signature: number[] = [];
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > course.archiveBytes) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败');
|
||||
}
|
||||
for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte);
|
||||
hash.update(value);
|
||||
await handle.write(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await handle.close();
|
||||
}
|
||||
if (bytes !== course.archiveBytes) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败');
|
||||
}
|
||||
if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b
|
||||
|| !((signature[2] === 0x03 && signature[3] === 0x04)
|
||||
|| (signature[2] === 0x05 && signature[3] === 0x06)
|
||||
|| (signature[2] === 0x07 && signature[3] === 0x08))) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包不是有效的 ZIP 文件');
|
||||
}
|
||||
if (hash.digest('hex') !== course.archiveSha256) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '课程包完整性校验失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSha256(path: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function createLearningCourseLibrary(dependencies: Dependencies) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
const now = dependencies.now ?? (() => new Date());
|
||||
const activeDownloads = new Map<string, Promise<InstalledLearningCourse>>();
|
||||
|
||||
async function authorizedFetch(path: string): Promise<Response> {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录');
|
||||
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, {
|
||||
headers: { Accept: path.endsWith('/download') ? 'application/zip' : 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||
redirect: 'manual',
|
||||
});
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function followDownloadRedirect(response: Response): Promise<Response> {
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
||||
const location = response.headers.get('location');
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
if (!location) throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效');
|
||||
const target = new URL(location, apiBaseUrl);
|
||||
const base = new URL(apiBaseUrl);
|
||||
if (target.protocol !== 'https:' && !(target.protocol === 'http:' && target.origin === base.origin)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址不安全');
|
||||
}
|
||||
return fetchImpl(target, { headers: { Accept: 'application/zip' }, redirect: 'follow' });
|
||||
}
|
||||
|
||||
async function install(courseId: string): Promise<InstalledLearningCourse> {
|
||||
assertCourseId(courseId);
|
||||
const metadataResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}`);
|
||||
const metadataPayload = await safeJson(metadataResponse);
|
||||
if (!metadataResponse.ok) throw upstreamError(metadataPayload, metadataResponse.status);
|
||||
const course = parseCourse(metadataPayload);
|
||||
if (course.id !== courseId) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_ID_MISMATCH', '课程信息与请求不匹配');
|
||||
}
|
||||
|
||||
const targetDirectory = courseDirectory(dependencies.rootDirectory, course);
|
||||
const archivePath = join(targetDirectory, 'course.makelore-course.zip');
|
||||
const descriptorPath = join(targetDirectory, 'installed.json');
|
||||
try {
|
||||
const existing = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8')));
|
||||
if (existing && existing.course.archiveSha256 === course.archiveSha256
|
||||
&& (await stat(archivePath)).size === course.archiveBytes
|
||||
&& await fileSha256(archivePath) === course.archiveSha256) return existing;
|
||||
} catch {
|
||||
// Missing or incomplete installs are replaced atomically below.
|
||||
}
|
||||
|
||||
await mkdir(targetDirectory, { recursive: true });
|
||||
const temporaryArchive = join(targetDirectory, `.course-${randomUUID()}.partial`);
|
||||
const temporaryDescriptor = join(targetDirectory, `.installed-${randomUUID()}.partial`);
|
||||
try {
|
||||
let downloadResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}/download`);
|
||||
downloadResponse = await followDownloadRedirect(downloadResponse);
|
||||
if (!downloadResponse.ok) {
|
||||
const payload = await safeJson(downloadResponse);
|
||||
throw upstreamError(payload, downloadResponse.status);
|
||||
}
|
||||
await streamVerifiedArchive(downloadResponse, temporaryArchive, course);
|
||||
const installed: InstalledLearningCourse = {
|
||||
schemaVersion: 1,
|
||||
course,
|
||||
archivePath,
|
||||
installedAt: now().toISOString(),
|
||||
};
|
||||
await writeFile(temporaryDescriptor, JSON.stringify(installed, null, 2), { encoding: 'utf8', flag: 'wx' });
|
||||
await rename(temporaryArchive, archivePath);
|
||||
await rename(temporaryDescriptor, descriptorPath);
|
||||
return installed;
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
rm(temporaryArchive, { force: true }),
|
||||
rm(temporaryDescriptor, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
download(courseId: string): Promise<InstalledLearningCourse> {
|
||||
assertCourseId(courseId);
|
||||
const existing = activeDownloads.get(courseId);
|
||||
if (existing) return existing;
|
||||
const pending = install(courseId).finally(() => activeDownloads.delete(courseId));
|
||||
activeDownloads.set(courseId, pending);
|
||||
return pending;
|
||||
},
|
||||
|
||||
async listInstalled(): Promise<InstalledLearningCourse[]> {
|
||||
const coursesRoot = join(dependencies.rootDirectory, 'learning', 'courses');
|
||||
let courseDirectories;
|
||||
try {
|
||||
courseDirectories = await readdir(coursesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const records: InstalledLearningCourse[] = [];
|
||||
for (const courseEntry of courseDirectories) {
|
||||
if (!courseEntry.isDirectory() || !COURSE_ID_PATTERN.test(courseEntry.name)) continue;
|
||||
const hashRoot = join(coursesRoot, courseEntry.name);
|
||||
const hashDirectories = await readdir(hashRoot, { withFileTypes: true }).catch(() => []);
|
||||
for (const hashEntry of hashDirectories) {
|
||||
if (!hashEntry.isDirectory() || !SHA256_PATTERN.test(hashEntry.name)) continue;
|
||||
const descriptorPath = join(hashRoot, hashEntry.name, 'installed.json');
|
||||
try {
|
||||
const record = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8')));
|
||||
if (!record
|
||||
|| record.course.id !== courseEntry.name
|
||||
|| record.course.contentHash !== hashEntry.name
|
||||
|| dirname(record.archivePath) !== join(hashRoot, hashEntry.name)
|
||||
|| (await stat(record.archivePath)).size !== record.course.archiveBytes) continue;
|
||||
records.push(record);
|
||||
} catch {
|
||||
// Ignore incomplete or corrupted entries; they can be downloaded again.
|
||||
}
|
||||
}
|
||||
}
|
||||
return records.sort((left, right) => right.installedAt.localeCompare(left.installedAt));
|
||||
},
|
||||
|
||||
async readClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
|
||||
assertCourseId(courseId);
|
||||
const installed = (await this.listInstalled()).find((record) => record.course.id === courseId);
|
||||
if (!installed) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_NOT_INSTALLED', '课程尚未下载到本机');
|
||||
}
|
||||
if (await fileSha256(installed.archivePath) !== installed.course.archiveSha256) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '本地课程包完整性校验失败,请重新下载');
|
||||
}
|
||||
const zip = new AdmZip(installed.archivePath);
|
||||
for (const entry of zip.getEntries()) {
|
||||
const parts = entry.entryName.replace(/\\/g, '/').split('/');
|
||||
if (entry.entryName.startsWith('/') || parts.includes('..')) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包包含不安全路径');
|
||||
}
|
||||
}
|
||||
registerLearningCoursePackage(
|
||||
installed.course.id,
|
||||
installed.course.contentHash,
|
||||
installed.archivePath,
|
||||
);
|
||||
try {
|
||||
return await readLearningPackageClassroom(zip, installed.course, moduleId);
|
||||
} catch (error) {
|
||||
throw new LearningCourseLibraryError(
|
||||
'LEARNING_CLASSROOM_INVALID',
|
||||
`课程播放数据无效${error instanceof Error ? `:${error.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
147
electron/services/learning-generation-client.ts
Normal file
147
electron/services/learning-generation-client.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
import {
|
||||
LEARNING_MATERIAL_MAX_FILE_BYTES,
|
||||
LEARNING_MATERIAL_MAX_FILES,
|
||||
LEARNING_MATERIAL_MAX_TOTAL_BYTES,
|
||||
type LearningGeneration,
|
||||
type LearningGenerationMaterialUpload,
|
||||
type LearningGenerationOptions,
|
||||
type LearningGenerationUploadRequest,
|
||||
} from '../../shared/learning';
|
||||
|
||||
type Dependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string): string {
|
||||
const root = record(payload);
|
||||
const detail = root.detail;
|
||||
if (typeof detail === 'string' && detail.trim()) return detail;
|
||||
const detailRecord = record(detail);
|
||||
if (typeof detailRecord.message === 'string' && detailRecord.message.trim()) {
|
||||
return detailRecord.message;
|
||||
}
|
||||
if (typeof root.error === 'string' && root.error.trim()) return root.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function validateOptions(value: LearningGenerationOptions): LearningGenerationOptions {
|
||||
const requirement = typeof value?.requirement === 'string' ? value.requirement.trim() : '';
|
||||
if (!requirement) throw new Error('请填写课程需求');
|
||||
const booleanKeys = [
|
||||
'enableWebSearch',
|
||||
'enableImageGeneration',
|
||||
'enableVideoGeneration',
|
||||
'enableTTS',
|
||||
'interactiveMode',
|
||||
'taskEngineMode',
|
||||
] as const;
|
||||
for (const key of booleanKeys) {
|
||||
if (typeof value[key] !== 'boolean') throw new Error('课程生成选项无效');
|
||||
}
|
||||
return {
|
||||
requirement,
|
||||
enableWebSearch: value.enableWebSearch,
|
||||
enableImageGeneration: value.enableImageGeneration,
|
||||
enableVideoGeneration: value.enableVideoGeneration,
|
||||
enableTTS: value.enableTTS,
|
||||
interactiveMode: value.interactiveMode,
|
||||
taskEngineMode: value.taskEngineMode,
|
||||
};
|
||||
}
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
const normalized = value.replace(/[\\/\0\r\n]/g, '_').trim().slice(0, 255);
|
||||
return normalized || 'material';
|
||||
}
|
||||
|
||||
function materialBytes(material: LearningGenerationMaterialUpload): Uint8Array {
|
||||
return material.bytes instanceof Uint8Array
|
||||
? new Uint8Array(material.bytes)
|
||||
: new Uint8Array(material.bytes as ArrayBuffer);
|
||||
}
|
||||
|
||||
function validateMaterials(materials: LearningGenerationMaterialUpload[]): Array<{
|
||||
material: LearningGenerationMaterialUpload;
|
||||
bytes: Uint8Array;
|
||||
}> {
|
||||
if (!Array.isArray(materials)) throw new Error('课程材料格式无效');
|
||||
if (materials.length > LEARNING_MATERIAL_MAX_FILES) {
|
||||
throw new Error(`课程材料最多 ${LEARNING_MATERIAL_MAX_FILES} 个`);
|
||||
}
|
||||
let totalBytes = 0;
|
||||
const validated = materials
|
||||
.map((material) => ({ material, bytes: materialBytes(material) }))
|
||||
.sort((left, right) => left.material.order - right.material.order);
|
||||
for (const { material, bytes } of validated) {
|
||||
if (!bytes.byteLength || bytes.byteLength !== material.size) throw new Error('课程材料内容不完整');
|
||||
if (bytes.byteLength > LEARNING_MATERIAL_MAX_FILE_BYTES) throw new Error('单个课程材料不能超过 50 MiB');
|
||||
totalBytes += bytes.byteLength;
|
||||
}
|
||||
if (totalBytes > LEARNING_MATERIAL_MAX_TOTAL_BYTES) throw new Error('课程材料总计不能超过 150 MiB');
|
||||
return validated;
|
||||
}
|
||||
|
||||
export function createLearningGenerationClient(dependencies: Dependencies = {}) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
|
||||
async function start(input: LearningGenerationUploadRequest): Promise<LearningGeneration> {
|
||||
const options = validateOptions(input.options);
|
||||
const materials = validateMaterials(input.materials);
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
const form = materials.length > 0 ? new FormData() : null;
|
||||
if (form) {
|
||||
form.set('options', JSON.stringify(options));
|
||||
for (const { material, bytes } of materials) {
|
||||
const blobBytes = new Uint8Array(bytes.byteLength);
|
||||
blobBytes.set(bytes);
|
||||
form.append(
|
||||
'materials',
|
||||
new Blob([blobBytes.buffer], { type: material.mimeType || 'application/octet-stream' }),
|
||||
safeFileName(material.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}/api/learning/generations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(form ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: form ?? JSON.stringify(options),
|
||||
redirect: 'manual',
|
||||
});
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(errorMessage(payload, `课程任务创建失败(HTTP ${response.status || 502})`));
|
||||
}
|
||||
const generation = record(payload);
|
||||
if (typeof generation.jobId !== 'string' || !generation.jobId) {
|
||||
throw new Error('学习服务没有返回任务编号');
|
||||
}
|
||||
return generation as LearningGeneration;
|
||||
}
|
||||
|
||||
return { start };
|
||||
}
|
||||
359
electron/services/learning-package-consumer.ts
Normal file
359
electron/services/learning-package-consumer.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
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[] },
|
||||
};
|
||||
}
|
||||
195
electron/services/learning-player-server.ts
Normal file
195
electron/services/learning-player-server.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.aac': 'audio/aac',
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wav': 'audio/wav',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.webm': 'video/webm',
|
||||
};
|
||||
|
||||
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const registeredCoursePackages = new Map<string, string>();
|
||||
|
||||
function coursePackageKey(courseId: string, contentHash: string): string {
|
||||
return `${courseId}:${contentHash}`;
|
||||
}
|
||||
|
||||
/** Register only a verified installed archive; requests still receive an asset allowlist below. */
|
||||
export function registerLearningCoursePackage(
|
||||
courseId: string,
|
||||
contentHash: string,
|
||||
archivePath: string,
|
||||
): void {
|
||||
if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) {
|
||||
throw new Error('Invalid learning course package identity');
|
||||
}
|
||||
registeredCoursePackages.set(coursePackageKey(courseId, contentHash), resolve(archivePath));
|
||||
}
|
||||
|
||||
function readZipEntry(zip: AdmZip, entryName: string): Promise<Buffer> {
|
||||
const entry = zip.getEntry(entryName);
|
||||
if (!entry || entry.isDirectory || entry.header.size > 256 * 1024 * 1024) {
|
||||
return Promise.reject(new Error('Course asset is unavailable'));
|
||||
}
|
||||
return new Promise((resolveData, rejectData) => {
|
||||
entry.getDataAsync((data, error) => error ? rejectData(error) : resolveData(data));
|
||||
});
|
||||
}
|
||||
|
||||
async function serveCourseAsset(pathname: string): Promise<{ bytes: Buffer; entryName: string } | null> {
|
||||
const prefix = '/course-assets/';
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const segments = pathname.slice(prefix.length).split('/').map((part) => decodeURIComponent(part));
|
||||
const [courseId, contentHash, ...entryParts] = segments;
|
||||
const entryName = entryParts.join('/');
|
||||
if (!COURSE_ID_PATTERN.test(courseId || '')
|
||||
|| !SHA256_PATTERN.test(contentHash || '')
|
||||
|| entryParts.some((part) => !part || part === '.' || part === '..')
|
||||
|| !/^(?:(?:modules\/[^/]+)\/)?(?:audio|media)\/.+/.test(entryName)) {
|
||||
throw new Error('Invalid course asset path');
|
||||
}
|
||||
const archivePath = registeredCoursePackages.get(coursePackageKey(courseId, contentHash));
|
||||
if (!archivePath) throw new Error('Course package is not registered');
|
||||
return { bytes: await readZipEntry(new AdmZip(archivePath), entryName), entryName };
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
return target === root || target.startsWith(`${root}${sep}`);
|
||||
}
|
||||
|
||||
export async function resolveLearningPlayerArtifactRoot(explicitRoot?: string): Promise<string> {
|
||||
const candidates = [
|
||||
explicitRoot,
|
||||
process.env.MAKELORE_LEARNING_PLAYER_ROOT,
|
||||
process.resourcesPath ? resolve(process.resourcesPath, 'resources', 'learning-player') : undefined,
|
||||
resolve(process.cwd(), 'build', 'learning-player'),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
for (const candidate of candidates) {
|
||||
const root = resolve(candidate);
|
||||
try {
|
||||
const artifact = JSON.parse(await readFile(resolve(root, 'artifact.json'), 'utf8')) as Record<string, unknown>;
|
||||
const indexPath = resolve(root, 'index.html');
|
||||
const html = await readFile(indexPath);
|
||||
if (artifact.schemaVersion !== 1
|
||||
|| artifact.entrypoint !== 'index.html'
|
||||
|| typeof artifact.htmlSha256 !== 'string'
|
||||
|| createHash('sha256').update(html).digest('hex') !== artifact.htmlSha256
|
||||
|| !await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false)
|
||||
|| !await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false)
|
||||
|| !await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false)) continue;
|
||||
return root;
|
||||
} catch {
|
||||
// Try the next verified artifact location.
|
||||
}
|
||||
}
|
||||
throw new Error('学习播放器资源未随安装包提供,请重新安装 Makelore');
|
||||
}
|
||||
|
||||
export async function createLearningPlayerServer(options: { artifactRoot?: string } = {}): Promise<{
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const root = await resolveLearningPlayerArtifactRoot(options.artifactRoot);
|
||||
const indexPath = resolve(root, 'index.html');
|
||||
const staticRoot = resolve(root, '_next', 'static');
|
||||
const avatarRoot = resolve(root, 'avatars');
|
||||
const publicRoot = resolve(root, 'public');
|
||||
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/course-assets/')) {
|
||||
const asset = await serveCourseAsset(url.pathname);
|
||||
if (!asset) throw new Error('Course asset is unavailable');
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.setHeader('Content-Type', MIME_TYPES[extname(asset.entryName).toLowerCase()] || 'application/octet-stream');
|
||||
res.setHeader('Content-Length', String(asset.bytes.byteLength));
|
||||
res.end(asset.bytes);
|
||||
return;
|
||||
}
|
||||
let target: string;
|
||||
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname === '/makelore-player') {
|
||||
target = indexPath;
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' data: blob:; object-src 'none'; base-uri 'none'; form-action 'none'");
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
} else if (url.pathname.startsWith('/_next/static/')) {
|
||||
const relativePath = decodeURIComponent(url.pathname.slice('/_next/static/'.length));
|
||||
target = resolve(staticRoot, relativePath);
|
||||
if (!isInside(staticRoot, target)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else if (url.pathname.startsWith('/avatars/')) {
|
||||
const relativePath = decodeURIComponent(url.pathname.slice('/avatars/'.length));
|
||||
target = resolve(avatarRoot, relativePath);
|
||||
if (!isInside(avatarRoot, target)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else {
|
||||
// OpenMAIC's production Stage references a small public asset tree
|
||||
// (for example PBL marks and vendor/fonts) with root-relative URLs.
|
||||
// Resolve those URLs only inside the verified artifact's public root.
|
||||
const relativePath = decodeURIComponent(url.pathname.slice(1));
|
||||
target = resolve(publicRoot, relativePath);
|
||||
if (!relativePath || !isInside(publicRoot, target)) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
const bytes = await readFile(target);
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', MIME_TYPES[extname(target)] || 'application/octet-stream');
|
||||
res.setHeader('Content-Length', String(bytes.byteLength));
|
||||
res.end(bytes);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolveListen());
|
||||
});
|
||||
server.unref();
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Learning player failed to bind');
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/makelore-player?embedded=1`,
|
||||
close: () => new Promise<void>((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose())),
|
||||
};
|
||||
}
|
||||
|
||||
let sharedServer: Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> | null = null;
|
||||
|
||||
export function getLearningPlayerServer(): Promise<Awaited<ReturnType<typeof createLearningPlayerServer>>> {
|
||||
sharedServer ??= createLearningPlayerServer();
|
||||
return sharedServer;
|
||||
}
|
||||
143
electron/services/learning-runtime-bridge.ts
Normal file
143
electron/services/learning-runtime-bridge.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
import type {
|
||||
LearningRuntimeBridgeEvent,
|
||||
LearningRuntimeBridgeRequest,
|
||||
LearningRuntimeCapability,
|
||||
} from '../../shared/learning';
|
||||
|
||||
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
||||
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
||||
const CAPABILITIES = new Set<LearningRuntimeCapability>([
|
||||
'quiz-grade',
|
||||
'pbl/v2/task/update',
|
||||
'pbl/v2/open-task',
|
||||
'pbl/v2/simulator',
|
||||
'pbl/v2/instructor',
|
||||
'pbl/v2/evaluate',
|
||||
]);
|
||||
|
||||
type Dependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function capabilityPath(capability: LearningRuntimeCapability): string {
|
||||
return capability.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function sanitizeAnchor(value: unknown): { sceneId?: string; sceneOrder?: number } | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const anchor = {
|
||||
...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256
|
||||
? { sceneId: candidate.sceneId }
|
||||
: {}),
|
||||
...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0
|
||||
? { sceneOrder: Number(candidate.sceneOrder) }
|
||||
: {}),
|
||||
};
|
||||
return Object.keys(anchor).length > 0 ? anchor : undefined;
|
||||
}
|
||||
|
||||
function validateRequest(request: LearningRuntimeBridgeRequest): string {
|
||||
const anchor = sanitizeAnchor(request.context.anchor);
|
||||
if (!REQUEST_ID_PATTERN.test(request.requestId)
|
||||
|| !COURSE_ID_PATTERN.test(request.courseId)
|
||||
|| !SHA256_PATTERN.test(request.contentHash)
|
||||
|| (request.context.moduleId !== null && !MODULE_ID_PATTERN.test(request.context.moduleId))
|
||||
|| !SHA256_PATTERN.test(request.context.moduleContentHash)
|
||||
|| !anchor?.sceneId
|
||||
|| request.method !== 'POST'
|
||||
|| !CAPABILITIES.has(request.capability)) {
|
||||
throw new Error('课堂联网能力请求无效');
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
context: {
|
||||
moduleId: request.context.moduleId,
|
||||
moduleContentHash: request.context.moduleContentHash,
|
||||
anchor: { sceneId: anchor.sceneId, ...(anchor.sceneOrder !== undefined ? { sceneOrder: anchor.sceneOrder } : {}) },
|
||||
},
|
||||
body: request.body ?? {},
|
||||
});
|
||||
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('课堂联网请求过大');
|
||||
return body;
|
||||
}
|
||||
|
||||
export function createLearningRuntimeBridge(dependencies: Dependencies = {}) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
|
||||
async function request(
|
||||
input: LearningRuntimeBridgeRequest,
|
||||
emit: (event: LearningRuntimeBridgeEvent) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const body = validateRequest(input);
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
emit({ requestId: input.requestId, type: 'error', code: 'LEARNING_AUTH_REQUIRED', message: '请先登录后使用联网课堂能力' });
|
||||
return;
|
||||
}
|
||||
const fetchRequest = (accessToken: string) => fetchImpl(
|
||||
`${apiBaseUrl}/api/learning/courses/${encodeURIComponent(input.courseId)}/runtime/${capabilityPath(input.capability)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Course-Content-Hash': input.contentHash,
|
||||
'X-Content-Hash': input.contentHash,
|
||||
},
|
||||
body,
|
||||
redirect: 'manual',
|
||||
},
|
||||
);
|
||||
let response = await fetchRequest(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await fetchRequest(refreshed);
|
||||
}
|
||||
emit({
|
||||
requestId: input.requestId,
|
||||
type: 'start',
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') || 'application/octet-stream',
|
||||
});
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
let bytesRead = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytesRead += chunk.value.byteLength;
|
||||
if (bytesRead > MAX_RESPONSE_BYTES) throw new Error('课堂联网响应过大');
|
||||
emit({ requestId: input.requestId, type: 'chunk', chunk: new Uint8Array(chunk.value) });
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
emit({ requestId: input.requestId, type: 'end' });
|
||||
} catch (error) {
|
||||
emit({
|
||||
requestId: input.requestId,
|
||||
type: 'error',
|
||||
code: 'LEARNING_RUNTIME_UNAVAILABLE',
|
||||
message: error instanceof Error ? error.message : '课堂联网能力暂时不可用',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { request };
|
||||
}
|
||||
52
electron/services/learning-speech-client.ts
Normal file
52
electron/services/learning-speech-client.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
|
||||
const MAX_AUDIO_BYTES = 26_214_400;
|
||||
|
||||
export type LearningSpeechRequest = {
|
||||
audio: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
type Dependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
export function createLearningSpeechClient(dependencies: Dependencies = {}) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
|
||||
async function transcribe(input: LearningSpeechRequest): Promise<{ text: string }> {
|
||||
const audio = input.audio instanceof Uint8Array ? input.audio : new Uint8Array(input.audio);
|
||||
if (!audio.byteLength || audio.byteLength > MAX_AUDIO_BYTES) throw new Error('语音长度不符合要求');
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) throw new Error('请先登录');
|
||||
const form = new FormData();
|
||||
form.set('audio', new Blob([audio], { type: input.mimeType || 'audio/webm' }), input.fileName || 'voice.webm');
|
||||
if (input.language?.trim()) form.set('language', input.language.trim());
|
||||
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}/api/speech/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: form,
|
||||
});
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { text?: unknown; detail?: unknown } | null;
|
||||
if (!response.ok || typeof payload?.text !== 'string' || !payload.text.trim()) {
|
||||
throw new Error(typeof payload?.detail === 'string' ? payload.detail : '语音识别失败');
|
||||
}
|
||||
return { text: payload.text.trim() };
|
||||
}
|
||||
|
||||
return { transcribe };
|
||||
}
|
||||
Reference in New Issue
Block a user