feat: integrate learning module
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user