feat(learning): replace courses with project catalog
This commit is contained in:
@@ -57,12 +57,6 @@ const UNIFIED_CHANNELS = new Set<string>([
|
||||
'update:cancelAutoInstall',
|
||||
]);
|
||||
|
||||
const ALLOWED_EVENT_CHANNELS = new Set([
|
||||
'learning:runtime-event',
|
||||
] as const);
|
||||
|
||||
export type AllowedIpcEventChannel = 'learning:runtime-event';
|
||||
|
||||
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
|
||||
const splitIndex = channel.indexOf(':');
|
||||
return {
|
||||
@@ -118,31 +112,6 @@ export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise
|
||||
return invokeApi<T>(channel, ...args);
|
||||
}
|
||||
|
||||
export function subscribeIpcEvent(
|
||||
channel: AllowedIpcEventChannel,
|
||||
listener: (payload: unknown) => void,
|
||||
): () => void {
|
||||
if (!ALLOWED_EVENT_CHANNELS.has(channel)) {
|
||||
throw new AppError('PERMISSION', 'IPC event channel is not allowed', {
|
||||
transport: 'ipc',
|
||||
channel,
|
||||
source: 'renderer-event-subscription',
|
||||
});
|
||||
}
|
||||
|
||||
const unsubscribe = window.electron.ipcRenderer.on(channel, listener);
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
window.electron.ipcRenderer.off(channel, listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeIpcWithRetry<T>(
|
||||
channel: string,
|
||||
args: unknown[] = [],
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export type LearningRuntimeAnchor = {
|
||||
sceneId?: string;
|
||||
sceneOrder?: number;
|
||||
};
|
||||
|
||||
/** Copy only the scene identity accepted by the Works runtime boundary. */
|
||||
export function sanitizeLearningRuntimeAnchor(value: unknown): LearningRuntimeAnchor | 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;
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { invokeIpc, subscribeIpcEvent } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
LEARNING_API_PATH,
|
||||
type LearningCourse,
|
||||
type InstalledLearningCourse,
|
||||
type LearningProgress,
|
||||
type LearningProgressWrite,
|
||||
type LearningGeneration,
|
||||
type LearningGenerationMaterialUpload,
|
||||
type LearningGenerationOptions,
|
||||
type LearningGenerationUploadRequest,
|
||||
type LearningClassroomPayload,
|
||||
type LearningRuntimeBridgeEvent,
|
||||
type LearningRuntimeBridgeRequest,
|
||||
LEARNING_MEDIA_MAX_BYTES,
|
||||
type LearningProjectDetail,
|
||||
type LearningProjectDownloadResult,
|
||||
type LearningProjectListQuery,
|
||||
type LearningProjectPage,
|
||||
} from '../../shared/learning';
|
||||
|
||||
type Envelope<T> = {
|
||||
type LearningEnvelope<T> = {
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
code?: string;
|
||||
@@ -24,6 +18,21 @@ type Envelope<T> = {
|
||||
data?: T;
|
||||
};
|
||||
|
||||
type LearningProjectMedia = {
|
||||
dataBase64: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const PROJECT_MEDIA_URL_PATTERN = /^\/api\/learning\/projects\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/media\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const PROJECT_MEDIA_MIME_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
const MAX_MEDIA_BASE64_LENGTH = Math.ceil(LEARNING_MEDIA_MAX_BYTES / 3) * 4;
|
||||
|
||||
export class LearningApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
@@ -35,242 +44,113 @@ export class LearningApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let envelope: Envelope<T>;
|
||||
try {
|
||||
envelope = await hostApiFetch<Envelope<T>>(path, init);
|
||||
} catch (error) {
|
||||
const status = error instanceof AppError && typeof error.details?.status === 'number'
|
||||
? error.details.status
|
||||
: 502;
|
||||
throw new LearningApiError(status, 'LEARNING_REQUEST_FAILED', error instanceof Error ? error.message : '学习服务请求失败');
|
||||
function errorStatus(error: unknown): number {
|
||||
return error instanceof AppError && typeof error.details?.status === 'number'
|
||||
? error.details.status
|
||||
: 502;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown, status: number): string {
|
||||
if (error instanceof AppError && typeof error.details?.backendCode === 'string') {
|
||||
return error.details.backendCode;
|
||||
}
|
||||
if (!envelope.success || envelope.data === undefined) {
|
||||
if (status === 401) return 'LEARNING_AUTH_REQUIRED';
|
||||
if (status === 404) return 'LEARNING_PROJECT_NOT_FOUND';
|
||||
return 'LEARNING_REQUEST_FAILED';
|
||||
}
|
||||
|
||||
async function requestData<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: LearningEnvelope<T>;
|
||||
try {
|
||||
response = await hostApiFetch<LearningEnvelope<T>>(path, init);
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
throw new LearningApiError(
|
||||
envelope.status ?? 502,
|
||||
envelope.code ?? 'LEARNING_REQUEST_FAILED',
|
||||
envelope.error ?? '学习服务请求失败',
|
||||
status,
|
||||
errorCode(error, status),
|
||||
error instanceof Error ? error.message : '学习项目请求失败',
|
||||
);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export function fetchLearningCourses(): Promise<LearningCourse[]> {
|
||||
return request(`${LEARNING_API_PATH}/courses`);
|
||||
}
|
||||
|
||||
export function fetchMyLearningCourses(): Promise<LearningCourse[]> {
|
||||
return request(`${LEARNING_API_PATH}/courses/mine`);
|
||||
}
|
||||
|
||||
export function fetchLearningProgress(): Promise<LearningProgress[]> {
|
||||
return request(`${LEARNING_API_PATH}/progress`);
|
||||
}
|
||||
|
||||
export const DEFAULT_LEARNING_GENERATION_OPTIONS: Omit<LearningGenerationOptions, 'requirement'> = {
|
||||
enableWebSearch: false,
|
||||
enableImageGeneration: false,
|
||||
enableVideoGeneration: false,
|
||||
enableTTS: true,
|
||||
interactiveMode: true,
|
||||
taskEngineMode: false,
|
||||
};
|
||||
|
||||
function normalizeGenerationOptions(
|
||||
input: string | LearningGenerationOptions,
|
||||
): LearningGenerationOptions {
|
||||
return typeof input === 'string'
|
||||
? { requirement: input, ...DEFAULT_LEARNING_GENERATION_OPTIONS }
|
||||
: input;
|
||||
}
|
||||
|
||||
export function startLearningGeneration(
|
||||
input: string | LearningGenerationOptions,
|
||||
materials: LearningGenerationMaterialUpload[] = [],
|
||||
): Promise<LearningGeneration> {
|
||||
const options = normalizeGenerationOptions(input);
|
||||
if (materials.length > 0) {
|
||||
const request: LearningGenerationUploadRequest = { options, materials };
|
||||
return invokeIpc<LearningGeneration>('learning:startGeneration', request);
|
||||
if (!response.success || response.data === undefined) {
|
||||
throw new LearningApiError(
|
||||
response.status ?? 502,
|
||||
response.code ?? 'LEARNING_REQUEST_FAILED',
|
||||
response.error ?? '学习项目请求失败',
|
||||
);
|
||||
}
|
||||
return request(`${LEARNING_API_PATH}/generations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function fetchLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}`);
|
||||
function listQuery(query: LearningProjectListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor?.trim()) params.set('cursor', query.cursor.trim());
|
||||
if (query.limit !== undefined) params.set('limit', String(query.limit));
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
export function cancelLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/cancel`, {
|
||||
export function fetchLearningProjects(
|
||||
query: LearningProjectListQuery = {},
|
||||
): Promise<LearningProjectPage> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects${listQuery(query)}`);
|
||||
}
|
||||
|
||||
export function fetchLearningProject(projectId: string): Promise<LearningProjectDetail> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
|
||||
export function downloadLearningProject(
|
||||
projectId: string,
|
||||
): Promise<LearningProjectDownloadResult> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}/download`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function resumeLearningGeneration(jobId: string): Promise<LearningGeneration> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/resume`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function finalizeLearningGeneration(jobId: string): Promise<LearningCourse> {
|
||||
return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/finalize`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export const LEARNING_LIBRARY_CHANGED_EVENT = 'makelore:learning-library-changed';
|
||||
|
||||
export function saveLearningProgress(
|
||||
courseId: string,
|
||||
progress: LearningProgressWrite,
|
||||
): Promise<LearningProgress> {
|
||||
const payload: LearningProgressWrite = {
|
||||
contentHash: progress.contentHash,
|
||||
...(progress.moduleId ? { moduleId: progress.moduleId } : {}),
|
||||
sceneOrder: progress.sceneOrder,
|
||||
actionIndex: progress.actionIndex,
|
||||
positionMs: progress.positionMs,
|
||||
completed: progress.completed,
|
||||
};
|
||||
return request(`${LEARNING_API_PATH}/courses/${encodeURIComponent(courseId)}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadLearningCourse(courseId: string): Promise<InstalledLearningCourse> {
|
||||
const record = await invokeIpc<InstalledLearningCourse>('learning:download', courseId);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(LEARNING_LIBRARY_CHANGED_EVENT));
|
||||
export async function fetchLearningProjectMedia(mediaUrl: string): Promise<string> {
|
||||
if (!PROJECT_MEDIA_URL_PATTERN.test(mediaUrl)) {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_MEDIA_URL', '项目图片地址无效');
|
||||
}
|
||||
return {
|
||||
schemaVersion: record.schemaVersion,
|
||||
course: record.course,
|
||||
installedAt: record.installedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listInstalledLearningCourses(): Promise<InstalledLearningCourse[]> {
|
||||
const records = await invokeIpc<InstalledLearningCourse[]>('learning:listInstalled');
|
||||
return records.map((record) => ({
|
||||
schemaVersion: record.schemaVersion,
|
||||
course: record.course,
|
||||
installedAt: record.installedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export type { LearningClassroomPayload } from '../../shared/learning';
|
||||
|
||||
export function readLearningClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
|
||||
return invokeIpc<LearningClassroomPayload>('learning:readClassroom', courseId, moduleId);
|
||||
}
|
||||
|
||||
export function getLearningPlayerUrl(): Promise<string> {
|
||||
return invokeIpc<string>('learning:playerUrl');
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
export function askLearningAgent(request: LearningAgentRequest): Promise<{ text: string }> {
|
||||
return invokeIpc<{ text: string }>('learning:agentAsk', request);
|
||||
}
|
||||
|
||||
export function resetLearningAgent(courseId?: string, contentHash?: string): Promise<void> {
|
||||
return invokeIpc<void>('learning:agentReset', courseId, contentHash);
|
||||
}
|
||||
|
||||
export function transcribeLearningSpeech(request: {
|
||||
audio: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
language?: string;
|
||||
}): Promise<{ text: string }> {
|
||||
return invokeIpc<{ text: string }>('learning:transcribe', request);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readLearningRuntimeEvent(payload: unknown): LearningRuntimeBridgeEvent | null {
|
||||
if (!isRecord(payload) || typeof payload.requestId !== 'string' || payload.requestId.length === 0 || payload.requestId.length > 128) {
|
||||
return null;
|
||||
const localPath = mediaUrl.replace('/api/learning/', `${LEARNING_API_PATH}/`);
|
||||
const payload = await hostApiFetch<LearningProjectMedia>(localPath);
|
||||
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
|
||||
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
|
||||
if (
|
||||
!PROJECT_MEDIA_MIME_TYPES.has(mimeType)
|
||||
|| !dataBase64
|
||||
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|
||||
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|
||||
|| dataBase64.length % 4 === 1
|
||||
) {
|
||||
throw new LearningApiError(502, 'LEARNING_INVALID_MEDIA_RESPONSE', '项目图片返回了无效数据');
|
||||
}
|
||||
if (payload.type === 'start') {
|
||||
if (
|
||||
!Number.isInteger(payload.status)
|
||||
|| (payload.status as number) < 100
|
||||
|| (payload.status as number) > 599
|
||||
|| typeof payload.contentType !== 'string'
|
||||
|| payload.contentType.length === 0
|
||||
|| payload.contentType.length > 128
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
type: 'start',
|
||||
status: payload.status as number,
|
||||
contentType: payload.contentType,
|
||||
};
|
||||
}
|
||||
if (payload.type === 'chunk') {
|
||||
if (!(payload.chunk instanceof Uint8Array) || payload.chunk.byteLength > 64 * 1024) return null;
|
||||
return { requestId: payload.requestId, type: 'chunk', chunk: payload.chunk };
|
||||
}
|
||||
if (payload.type === 'end') {
|
||||
return { requestId: payload.requestId, type: 'end' };
|
||||
}
|
||||
if (payload.type === 'error') {
|
||||
if (
|
||||
typeof payload.code !== 'string'
|
||||
|| payload.code.length === 0
|
||||
|| payload.code.length > 64
|
||||
|| typeof payload.message !== 'string'
|
||||
|| payload.message.length === 0
|
||||
|| payload.message.length > 256
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
type: 'error',
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return `data:${mimeType};base64,${dataBase64}`;
|
||||
}
|
||||
|
||||
export async function streamLearningRuntime(
|
||||
request: LearningRuntimeBridgeRequest,
|
||||
onEvent: (event: LearningRuntimeBridgeEvent) => void,
|
||||
): Promise<void> {
|
||||
const unsubscribe = subscribeIpcEvent('learning:runtime-event', (payload) => {
|
||||
const event = readLearningRuntimeEvent(payload);
|
||||
if (event?.requestId === request.requestId) onEvent(event);
|
||||
});
|
||||
export function isLearningProjectMediaUrl(value: string): boolean {
|
||||
return PROJECT_MEDIA_URL_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isSafeLearningCoverUrl(value: string): boolean {
|
||||
if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true;
|
||||
try {
|
||||
await invokeIpc<void>('learning:runtimeRequest', request);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openLearningExternalLink(value: string): Promise<void> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password) {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
|
||||
}
|
||||
await invokeIpc('shell:openExternal', url.toString());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user