feat: integrate learning module
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

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

View File

@@ -24,6 +24,13 @@ type CreateProjectInput = {
type PublishProjectSourceInput = {
projectId?: unknown;
project?: unknown;
cover?: unknown;
};
type ProjectCoverUpload = {
fileName: string;
mimeType: string;
bytes: Buffer;
};
type DownloadAssetInput = {
@@ -50,6 +57,8 @@ type AgentAvatarUploadInput = {
const MAX_AGENT_AVATAR_BYTES = 4 * 1024 * 1024;
const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -62,6 +71,15 @@ function readOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function readOptionalInteger(value: unknown, field: string, min: number, max: number): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
throw new Error(`Invalid ${field}`);
}
return numeric;
}
function readRequiredHeader(req: IncomingMessage, name: string): string {
const value = req.headers[name.toLowerCase()];
const firstValue = Array.isArray(value) ? value[0] : value;
@@ -257,6 +275,7 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
const projected: Record<string, unknown> = { app_id: appId, title, summary };
for (const field of [
'description',
'cover_url',
'category',
'age_band',
@@ -280,6 +299,10 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
}
if (Number.isInteger(value.creator_age)) {
projected.creator_age = value.creator_age;
}
const versionName = readOptionalString(value.version_name);
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
projected.playable = false;
@@ -960,16 +983,56 @@ function readProjectMetadata(value: unknown): Record<string, unknown> | null {
title: readRequiredString(source.title, 'project.title'),
summary: readRequiredString(source.summary, 'project.summary'),
};
for (const field of ['cover_url', 'category', 'age_band', 'difficulty'] as const) {
const description = readOptionalString(source.description);
if (description) metadata.description = description;
for (const field of ['cover_url', 'category', 'age_band', 'difficulty', 'creator_name'] as const) {
const fieldValue = readOptionalString(source[field]);
if (fieldValue) metadata[field] = fieldValue;
}
const creatorAge = readOptionalInteger(source.creator_age, 'project.creator_age', 1, 150);
if (creatorAge !== undefined) metadata.creator_age = creatorAge;
return metadata;
} catch {
return null;
}
}
function readProjectCoverUpload(value: unknown): ProjectCoverUpload | null {
if (value === undefined || value === null) return null;
if (!isRecord(value)) return null;
const fileName = readOptionalString(value.fileName);
const mimeType = readOptionalString(value.mimeType)?.toLowerCase();
const dataBase64 = readOptionalString(value.dataBase64);
if (!fileName || !mimeType || !dataBase64 || !PROJECT_COVER_MIME_TYPES.has(mimeType)) return null;
if (dataBase64.length > Math.ceil(MAX_PROJECT_COVER_BYTES * 4 / 3) + 4) return null;
const bytes = Buffer.from(dataBase64, 'base64');
if (bytes.length === 0 || bytes.length > MAX_PROJECT_COVER_BYTES) return null;
return { fileName, mimeType, bytes };
}
async function uploadProjectCover(
accessToken: string,
upload: ProjectCoverUpload,
): Promise<string | null> {
const form = new FormData();
form.set(
'file',
new Blob([new Uint8Array(upload.bytes)], { type: upload.mimeType }),
upload.fileName,
);
const response = await proxyAwareFetch(createWorksUrl('/api/projects/covers').toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: form,
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
return null;
}
const payload = await readResponsePayload(response);
return isRecord(payload) ? readOptionalString(payload.cover_url) ?? null : null;
}
async function handlePublishProjectSource(
req: IncomingMessage,
res: ServerResponse,
@@ -984,6 +1047,7 @@ async function handlePublishProjectSource(
const projectId = readOptionalString(body.projectId);
const projectMetadata = readProjectMetadata(body.project);
const projectCover = readProjectCoverUpload(body.cover);
if (!projectId || !projectMetadata) {
sendPublishSourceFailure(
res,
@@ -993,6 +1057,15 @@ async function handlePublishProjectSource(
);
return;
}
if (body.cover !== undefined && body.cover !== null && !projectCover) {
sendPublishSourceFailure(
res,
400,
'PROJECT_COVER_INVALID',
'封面图片无效,请重新选择 PNG、JPEG 或 WebP 图片。',
);
return;
}
const appId = projectMetadata.app_id as string;
const localProject = (await ctx.opencodeProjectStore.listProjects())
.find((candidate) => candidate.id === projectId);
@@ -1009,6 +1082,20 @@ async function handlePublishProjectSource(
const versionName = await readAutomaticVersionName(localProject.path);
const idempotencyKey = `makelore-${randomUUID()}`;
if (projectCover) {
const coverUrl = await uploadProjectCover(accessToken, projectCover);
if (!coverUrl) {
sendPublishSourceFailure(
res,
502,
'PROJECT_COVER_UPLOAD_REJECTED',
'封面上传失败,请稍后重试。',
);
return;
}
projectMetadata.cover_url = coverUrl;
}
const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
method: 'POST',
headers: {
@@ -1045,7 +1132,39 @@ async function handlePublishProjectSource(
);
return;
}
await ownershipResponse.body?.cancel().catch(() => undefined);
const ownershipPayload = projectSafeStatusPayload(await readResponsePayload(ownershipResponse));
if (!ownershipPayload) {
sendPublishSourceFailure(
res,
502,
'PROJECT_OWNERSHIP_UNCONFIRMED',
'这个作品的归属暂时无法确认,请稍后重试。',
);
return;
}
if (ownershipPayload.project.status !== 'published') {
const updateResponse = await proxyAwareFetch(
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}`).toString(),
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(projectMetadata),
},
);
if (!updateResponse.ok && updateResponse.status !== 409) {
await sendPublishSourceUpstreamError(
res,
updateResponse,
'PROJECT_METADATA_UPDATE_REJECTED',
'平台没有保存作品信息,请修改后重试。',
);
return;
}
await updateResponse.body?.cancel().catch(() => undefined);
}
}
const uploadResponse = await uploadSourceProjectVersion({