merge: integrate remote learning module safely
This commit is contained in:
226
electron/services/learning-generation-client.ts
Normal file
226
electron/services/learning-generation-client.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
LEARNING_GENERATION_CONTRACT_VERSION,
|
||||
type LearningGeneration,
|
||||
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 nullableString(value: unknown, maxLength: number): string | null {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string' || value.length > maxLength) throw new Error('课程任务创建失败');
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown, min: number, max: number): number | null {
|
||||
if (value === null) return null;
|
||||
if (!Number.isSafeInteger(value) || Number(value) < min || Number(value) > max) {
|
||||
throw new Error('课程任务创建失败');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function projectGeneration(value: unknown): LearningGeneration {
|
||||
const generation = record(value);
|
||||
const contractVersion = generation.contractVersion === undefined
|
||||
? undefined
|
||||
: Number.isSafeInteger(generation.contractVersion)
|
||||
&& Number(generation.contractVersion) === LEARNING_GENERATION_CONTRACT_VERSION
|
||||
? Number(generation.contractVersion)
|
||||
: null;
|
||||
const jobId = typeof generation.jobId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(generation.jobId)
|
||||
? generation.jobId
|
||||
: null;
|
||||
const status = typeof generation.status === 'string' && /^[a-z][a-z0-9_-]{0,63}$/.test(generation.status)
|
||||
? generation.status
|
||||
: null;
|
||||
const mode = generation.mode === undefined
|
||||
? undefined
|
||||
: generation.mode === 'single' || generation.mode === 'large' ? generation.mode : null;
|
||||
const progress = generation.progress === null ? null : generation.progress;
|
||||
const courseId = generation.courseId === null
|
||||
? null
|
||||
: typeof generation.courseId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(generation.courseId)
|
||||
? generation.courseId
|
||||
: undefined;
|
||||
if (contractVersion === null || !jobId || !status || mode === null || typeof generation.done !== 'boolean'
|
||||
|| courseId === undefined
|
||||
|| (progress !== null && (typeof progress !== 'number' || !Number.isFinite(progress) || progress < 0 || progress > 100))) {
|
||||
throw new Error('课程任务创建失败');
|
||||
}
|
||||
return {
|
||||
...(contractVersion === undefined ? {} : { contractVersion }),
|
||||
jobId,
|
||||
status,
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
step: nullableString(generation.step, 128),
|
||||
progress,
|
||||
message: nullableString(generation.message, 2_000),
|
||||
scenesGenerated: nullableInteger(generation.scenesGenerated, 0, 100_000),
|
||||
totalScenes: nullableInteger(generation.totalScenes, 0, 100_000),
|
||||
courseId,
|
||||
error: generation.error === null ? null : (nullableString(generation.error, 2_000), '课程生成失败'),
|
||||
done: generation.done,
|
||||
};
|
||||
}
|
||||
|
||||
const INVALID_UPLOAD_ERROR = '课程任务创建失败';
|
||||
const MATERIAL_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
||||
const MIME_TYPE_PATTERN = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/;
|
||||
const MAX_MATERIAL_TIMESTAMP = 8_640_000_000_000_000;
|
||||
|
||||
function invalidUpload(): never {
|
||||
throw new Error(INVALID_UPLOAD_ERROR);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function validateOptions(value: unknown): LearningGenerationOptions {
|
||||
const options = record(value);
|
||||
const rawRequirement = typeof options.requirement === 'string' ? options.requirement : '';
|
||||
const requirement = rawRequirement.trim();
|
||||
if (!requirement || rawRequirement.length > 4_000) invalidUpload();
|
||||
const booleanKeys = [
|
||||
'enableWebSearch',
|
||||
'enableImageGeneration',
|
||||
'enableVideoGeneration',
|
||||
'enableTTS',
|
||||
'interactiveMode',
|
||||
'taskEngineMode',
|
||||
] as const;
|
||||
for (const key of booleanKeys) {
|
||||
if (typeof options[key] !== 'boolean') invalidUpload();
|
||||
}
|
||||
return {
|
||||
requirement,
|
||||
enableWebSearch: options.enableWebSearch as boolean,
|
||||
enableImageGeneration: options.enableImageGeneration as boolean,
|
||||
enableVideoGeneration: options.enableVideoGeneration as boolean,
|
||||
enableTTS: options.enableTTS as boolean,
|
||||
interactiveMode: options.interactiveMode as boolean,
|
||||
taskEngineMode: options.taskEngineMode as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMaterials(value: unknown): Array<{
|
||||
name: string;
|
||||
mimeType: string;
|
||||
order: number;
|
||||
bytes: Uint8Array;
|
||||
}> {
|
||||
if (!Array.isArray(value) || value.length > LEARNING_MATERIAL_MAX_FILES) invalidUpload();
|
||||
let totalBytes = 0;
|
||||
const ids = new Set<string>();
|
||||
const orders = new Set<number>();
|
||||
const validated = value.map((candidate) => {
|
||||
const material = record(candidate);
|
||||
const id = material.id;
|
||||
const name = material.name;
|
||||
const mimeType = material.mimeType;
|
||||
const size = material.size;
|
||||
const lastModified = material.lastModified;
|
||||
const order = material.order;
|
||||
const bytes = material.bytes;
|
||||
if (typeof id !== 'string' || !MATERIAL_ID_PATTERN.test(id) || ids.has(id)
|
||||
|| typeof name !== 'string' || !name.trim() || name.length > 255
|
||||
|| name.includes('/') || name.includes('\\') || hasControlCharacter(name)
|
||||
|| typeof mimeType !== 'string' || mimeType.length > 128 || !MIME_TYPE_PATTERN.test(mimeType)
|
||||
|| !Number.isSafeInteger(size) || Number(size) <= 0 || Number(size) > LEARNING_MATERIAL_MAX_FILE_BYTES
|
||||
|| !Number.isSafeInteger(lastModified) || Number(lastModified) < 0 || Number(lastModified) > MAX_MATERIAL_TIMESTAMP
|
||||
|| !Number.isSafeInteger(order) || Number(order) < 1 || Number(order) > value.length
|
||||
|| orders.has(Number(order))
|
||||
|| !(bytes instanceof Uint8Array)
|
||||
|| bytes.byteLength !== Number(size)) invalidUpload();
|
||||
ids.add(id);
|
||||
orders.add(Number(order));
|
||||
totalBytes += bytes.byteLength;
|
||||
if (totalBytes > LEARNING_MATERIAL_MAX_TOTAL_BYTES) invalidUpload();
|
||||
const projectedBytes = new Uint8Array(bytes.byteLength);
|
||||
projectedBytes.set(bytes);
|
||||
return { name, mimeType, order: Number(order), bytes: projectedBytes };
|
||||
}).sort((left, right) => left.order - right.order);
|
||||
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,
|
||||
assertCurrentAccount: () => void = () => undefined,
|
||||
): Promise<LearningGeneration> {
|
||||
const upload = record(input);
|
||||
const options = validateOptions(upload.options);
|
||||
const materials = validateMaterials(upload.materials);
|
||||
assertCurrentAccount();
|
||||
const token = await getAccessToken({ fetchImpl }).catch(() => { throw new Error('课程任务创建失败'); });
|
||||
assertCurrentAccount();
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
const form = materials.length > 0 ? new FormData() : null;
|
||||
if (form) {
|
||||
form.set('options', JSON.stringify(options));
|
||||
for (const { name, mimeType, bytes } of materials) {
|
||||
form.append(
|
||||
'materials',
|
||||
new Blob([bytes.buffer], { type: mimeType }),
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
const request = (accessToken: string) => {
|
||||
assertCurrentAccount();
|
||||
return 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).catch(() => { throw new Error('课程任务创建失败'); });
|
||||
assertCurrentAccount();
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
assertCurrentAccount();
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }).catch(() => null);
|
||||
assertCurrentAccount();
|
||||
if (refreshed) {
|
||||
response = await request(refreshed).catch(() => { throw new Error('课程任务创建失败'); });
|
||||
assertCurrentAccount();
|
||||
}
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload) throw new Error('课程任务创建失败');
|
||||
return projectGeneration(payload);
|
||||
}
|
||||
|
||||
return { start };
|
||||
}
|
||||
Reference in New Issue
Block a user