Files
makelore/electron/services/learning-generation-client.ts
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

148 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}