feat(learning): replace courses with project catalog
This commit is contained in:
@@ -18,24 +18,6 @@ import {
|
||||
lockAdminSession,
|
||||
verifyAdminPassword,
|
||||
} from './admin-access';
|
||||
import { createLearningCourseLibrary, LearningCourseLibraryError } from '../services/learning-course-library';
|
||||
import {
|
||||
assertLearningCoursePackageRegistered,
|
||||
closeLearningPlayerServer,
|
||||
evictLearningCoursePackagesForAccount,
|
||||
getLearningPlayerServer,
|
||||
} from '../services/learning-player-server';
|
||||
import {
|
||||
getWorksSquareAccountBinding,
|
||||
isCurrentWorksSquareAccountBinding,
|
||||
subscribeWorksSquareSession,
|
||||
type WorksSquareAccountBinding,
|
||||
} from '../services/works-square-session';
|
||||
import { createLearningAgentClient, type LearningAgentRequest } from '../services/learning-agent-client';
|
||||
import { createLearningSpeechClient, type LearningSpeechRequest } from '../services/learning-speech-client';
|
||||
import { createLearningGenerationClient } from '../services/learning-generation-client';
|
||||
import { createLearningRuntimeBridge } from '../services/learning-runtime-bridge';
|
||||
import type { LearningGenerationUploadRequest, LearningRuntimeBridgeRequest } from '../../shared/learning';
|
||||
import type { BackgroundLifecycleController, DesktopActivity } from './background-lifecycle';
|
||||
import { collectPerformanceSnapshot } from './performance-diagnostics';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
@@ -58,10 +40,6 @@ type UnifiedResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
const LEARNING_COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const LEARNING_MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
||||
const LEARNING_SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function ok(id: string | undefined, data: unknown): UnifiedResponse {
|
||||
return { id, ok: true, data };
|
||||
}
|
||||
@@ -78,114 +56,6 @@ function unsupported(id: string | undefined, module: string, action: string): Un
|
||||
return fail(id, 'UNSUPPORTED', `APP_REQUEST_UNSUPPORTED:${module}:${action}`);
|
||||
}
|
||||
|
||||
async function withLearningBinding<T>(
|
||||
operation: (
|
||||
binding: WorksSquareAccountBinding,
|
||||
assertCurrentAccount: () => void,
|
||||
) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const binding = getWorksSquareAccountBinding();
|
||||
if (!binding) {
|
||||
throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录');
|
||||
}
|
||||
const assertCurrentAccount = () => {
|
||||
if (!isCurrentWorksSquareAccountBinding(binding)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试');
|
||||
}
|
||||
};
|
||||
assertCurrentAccount();
|
||||
const result = await operation(binding, assertCurrentAccount);
|
||||
assertCurrentAccount();
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertLearningCoursePackageActive(
|
||||
binding: WorksSquareAccountBinding,
|
||||
courseId: string,
|
||||
contentHash: string,
|
||||
): void {
|
||||
try {
|
||||
assertLearningCoursePackageRegistered(binding.accountKey, courseId, contentHash);
|
||||
} catch {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
||||
}
|
||||
}
|
||||
|
||||
function assertLearningCourseIdentity(
|
||||
requested: { courseId: string; contentHash: string; moduleId?: string | null; moduleContentHash?: string },
|
||||
authoritative: { courseId: string; courseContentHash: string; moduleId: string | null; moduleContentHash: string },
|
||||
): void {
|
||||
if (requested.courseId !== authoritative.courseId
|
||||
|| requested.contentHash !== authoritative.courseContentHash
|
||||
|| (requested.moduleId !== undefined && requested.moduleId !== authoritative.moduleId)
|
||||
|| (requested.moduleContentHash !== undefined
|
||||
&& requested.moduleContentHash !== authoritative.moduleContentHash)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
||||
}
|
||||
}
|
||||
|
||||
function learningIdentityError(): LearningCourseLibraryError {
|
||||
return new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效');
|
||||
}
|
||||
|
||||
function requireLearningAgentIdentity(request: unknown): {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
moduleId?: string | null;
|
||||
moduleContentHash?: string;
|
||||
} {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError();
|
||||
const input = request as Record<string, unknown>;
|
||||
if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId)
|
||||
|| typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)) {
|
||||
throw learningIdentityError();
|
||||
}
|
||||
if (input.anchor === undefined) return { courseId: input.courseId, contentHash: input.contentHash };
|
||||
if (!input.anchor || typeof input.anchor !== 'object' || Array.isArray(input.anchor)) throw learningIdentityError();
|
||||
const anchor = input.anchor as Record<string, unknown>;
|
||||
if (anchor.moduleId !== undefined && anchor.moduleId !== null
|
||||
&& (typeof anchor.moduleId !== 'string' || !LEARNING_MODULE_ID_PATTERN.test(anchor.moduleId))) {
|
||||
throw learningIdentityError();
|
||||
}
|
||||
if (anchor.moduleContentHash !== undefined
|
||||
&& (typeof anchor.moduleContentHash !== 'string'
|
||||
|| !LEARNING_SHA256_PATTERN.test(anchor.moduleContentHash))) {
|
||||
throw learningIdentityError();
|
||||
}
|
||||
return {
|
||||
courseId: input.courseId,
|
||||
contentHash: input.contentHash,
|
||||
...(anchor.moduleId === undefined ? {} : { moduleId: anchor.moduleId as string | null }),
|
||||
...(anchor.moduleContentHash === undefined ? {} : { moduleContentHash: anchor.moduleContentHash as string }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireLearningRuntimeIdentity(request: unknown): {
|
||||
courseId: string;
|
||||
contentHash: string;
|
||||
moduleId: string | null;
|
||||
moduleContentHash: string;
|
||||
} {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError();
|
||||
const input = request as Record<string, unknown>;
|
||||
const context = input.context;
|
||||
if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId)
|
||||
|| typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)
|
||||
|| !context || typeof context !== 'object' || Array.isArray(context)) throw learningIdentityError();
|
||||
const runtimeContext = context as Record<string, unknown>;
|
||||
if ((runtimeContext.moduleId !== null
|
||||
&& (typeof runtimeContext.moduleId !== 'string'
|
||||
|| !LEARNING_MODULE_ID_PATTERN.test(runtimeContext.moduleId)))
|
||||
|| typeof runtimeContext.moduleContentHash !== 'string'
|
||||
|| !LEARNING_SHA256_PATTERN.test(runtimeContext.moduleContentHash)) throw learningIdentityError();
|
||||
return {
|
||||
courseId: input.courseId,
|
||||
contentHash: input.contentHash,
|
||||
moduleId: runtimeContext.moduleId as string | null,
|
||||
moduleContentHash: runtimeContext.moduleContentHash,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadArray(payload: unknown): unknown[] {
|
||||
return Array.isArray(payload) ? payload : payload === undefined ? [] : [payload];
|
||||
}
|
||||
@@ -391,127 +261,6 @@ export function registerIpcHandlers(
|
||||
rendererWebContents.on('render-process-gone', releaseRendererLeases);
|
||||
rendererWebContents.on('destroyed', releaseRendererLeases);
|
||||
}
|
||||
const learningCourseLibrary = createLearningCourseLibrary({ rootDirectory: app.getPath('userData') });
|
||||
let learningAgentClient = createLearningAgentClient();
|
||||
const learningSpeechClient = createLearningSpeechClient();
|
||||
const learningGenerationClient = createLearningGenerationClient();
|
||||
const learningRuntimeBridge = createLearningRuntimeBridge();
|
||||
let learningPlayerClose = Promise.resolve();
|
||||
let activeLearningBinding = getWorksSquareAccountBinding();
|
||||
subscribeWorksSquareSession(() => {
|
||||
const nextBinding = getWorksSquareAccountBinding();
|
||||
const bindingChanged = activeLearningBinding?.accountKey !== nextBinding?.accountKey
|
||||
|| activeLearningBinding?.epoch !== nextBinding?.epoch;
|
||||
if (bindingChanged) {
|
||||
if (activeLearningBinding) {
|
||||
evictLearningCoursePackagesForAccount(activeLearningBinding.accountKey);
|
||||
}
|
||||
learningPlayerClose = closeLearningPlayerServer().catch(() => undefined);
|
||||
learningAgentClient = createLearningAgentClient();
|
||||
}
|
||||
activeLearningBinding = nextBinding;
|
||||
});
|
||||
|
||||
ipcMain.handle('learning:startGeneration', (_event, request: LearningGenerationUploadRequest) => (
|
||||
withLearningBinding((_binding, assertCurrentAccount) => (
|
||||
learningGenerationClient.start(request, assertCurrentAccount)
|
||||
))
|
||||
));
|
||||
ipcMain.handle('learning:download', (_event, courseId: string) => (
|
||||
withLearningBinding(() => learningCourseLibrary.download(courseId))
|
||||
));
|
||||
ipcMain.handle('learning:listInstalled', () => (
|
||||
withLearningBinding(() => learningCourseLibrary.listInstalled())
|
||||
));
|
||||
ipcMain.handle('learning:readClassroom', (_event, courseId: string, moduleId?: string) => (
|
||||
withLearningBinding(() => learningCourseLibrary.readClassroom(courseId, moduleId))
|
||||
));
|
||||
ipcMain.handle('learning:playerUrl', () => withLearningBinding(async (binding, assertCurrentAccount) => {
|
||||
await learningPlayerClose;
|
||||
assertCurrentAccount();
|
||||
return (await getLearningPlayerServer(binding.accountKey)).url;
|
||||
}));
|
||||
ipcMain.handle('learning:closePlayer', () => closeLearningPlayerServer());
|
||||
ipcMain.handle('learning:agentAsk', (_event, request: unknown) => withLearningBinding(
|
||||
async (binding, assertCurrentAccount) => {
|
||||
const identity = requireLearningAgentIdentity(request);
|
||||
const agentRequest = request as LearningAgentRequest;
|
||||
assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash);
|
||||
const classroom = await learningCourseLibrary.resolveClassroom(identity.courseId, identity.moduleId ?? undefined);
|
||||
assertCurrentAccount();
|
||||
assertLearningCourseIdentity({
|
||||
courseId: identity.courseId,
|
||||
contentHash: identity.contentHash,
|
||||
moduleId: identity.moduleId,
|
||||
moduleContentHash: identity.moduleContentHash,
|
||||
}, classroom);
|
||||
return learningAgentClient.ask({
|
||||
...agentRequest,
|
||||
contentHash: classroom.courseContentHash,
|
||||
anchor: {
|
||||
...agentRequest.anchor,
|
||||
moduleId: classroom.moduleId,
|
||||
moduleContentHash: classroom.moduleContentHash,
|
||||
},
|
||||
}, assertCurrentAccount);
|
||||
},
|
||||
));
|
||||
ipcMain.handle('learning:agentReset', (_event, courseId?: string, contentHash?: string) => withLearningBinding(
|
||||
async (binding, assertCurrentAccount) => {
|
||||
if (courseId === undefined && contentHash === undefined) {
|
||||
return learningAgentClient.reset(undefined, undefined, assertCurrentAccount);
|
||||
}
|
||||
if (typeof courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(courseId)
|
||||
|| typeof contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(contentHash)) {
|
||||
throw learningIdentityError();
|
||||
}
|
||||
assertLearningCoursePackageActive(binding, courseId, contentHash);
|
||||
const classroom = await learningCourseLibrary.resolveClassroom(courseId);
|
||||
assertCurrentAccount();
|
||||
assertLearningCourseIdentity({
|
||||
courseId,
|
||||
contentHash,
|
||||
}, classroom);
|
||||
return learningAgentClient.reset(courseId, classroom.courseContentHash, assertCurrentAccount);
|
||||
},
|
||||
));
|
||||
ipcMain.handle('learning:transcribe', (_event, request: LearningSpeechRequest) => (
|
||||
withLearningBinding((_binding, assertCurrentAccount) => (
|
||||
learningSpeechClient.transcribe(request, assertCurrentAccount)
|
||||
))
|
||||
));
|
||||
ipcMain.handle('learning:runtimeRequest', (event, request: unknown) => withLearningBinding(
|
||||
async (binding, assertCurrentAccount) => {
|
||||
const identity = requireLearningRuntimeIdentity(request);
|
||||
const runtimeRequest = request as LearningRuntimeBridgeRequest;
|
||||
assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash);
|
||||
const classroom = await learningCourseLibrary.resolveClassroom(
|
||||
identity.courseId,
|
||||
identity.moduleId ?? undefined,
|
||||
);
|
||||
assertCurrentAccount();
|
||||
assertLearningCourseIdentity({
|
||||
courseId: identity.courseId,
|
||||
contentHash: identity.contentHash,
|
||||
moduleId: identity.moduleId,
|
||||
moduleContentHash: identity.moduleContentHash,
|
||||
}, classroom);
|
||||
await learningRuntimeBridge.request({
|
||||
...runtimeRequest,
|
||||
contentHash: classroom.courseContentHash,
|
||||
context: {
|
||||
...runtimeRequest.context,
|
||||
moduleId: classroom.moduleId,
|
||||
moduleContentHash: classroom.moduleContentHash,
|
||||
},
|
||||
}, (payload) => {
|
||||
if (isCurrentWorksSquareAccountBinding(binding) && !event.sender.isDestroyed()) {
|
||||
event.sender.send('learning:runtime-event', payload);
|
||||
}
|
||||
}, assertCurrentAccount);
|
||||
},
|
||||
));
|
||||
|
||||
ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => (
|
||||
handleUnifiedRequest(request, opencodeManager)
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user