Files
makelore/src/lib/works-square.ts

532 lines
15 KiB
TypeScript

import { hostApiFetch } from '@/lib/host-api';
export type ProjectPublic = {
app_id: string;
title: string;
summary: string;
description?: string | null;
cover_url?: string | null;
category?: string | null;
age_band?: string | null;
difficulty?: string | null;
status?: string | null;
updated_at?: string | null;
playable?: boolean;
play_url?: string | null;
/** @deprecated Use play_url. Kept for one client compatibility release. */
runtime_url?: string | null;
creator_name?: string | null;
creator_age?: number | null;
buddy_name?: string | null;
buddy_sprite_url?: string | null;
buddy_pose_url?: string | null;
version_name?: string | null;
testing_ask?: string | null;
update_note?: string | null;
remix_note?: string | null;
how_to?: string | null;
learning_note?: string | null;
visual_alt?: string | null;
};
export type ProjectPage = {
items: ProjectPublic[];
next_cursor: string | null;
limit: number;
};
export type WorksAssetPublic = {
slug: string;
title: string;
summary: string;
category: string;
source: string;
source_url?: string | null;
license?: string | null;
license_url?: string | null;
series?: string | null;
tags: string[];
preview_url?: string | null;
image_urls: string[];
archive_size_bytes?: number | null;
extracted_file_count?: number | null;
published_at?: string | null;
updated_at?: string | null;
};
export type WorksAssetPage = {
items: WorksAssetPublic[];
next_cursor: string | null;
limit: number;
};
export type WorksAssetDownloadInput = {
slug: string;
projectId: string;
};
export type WorksAssetDownloadResult = {
slug: string;
filePath: string;
relativePath: string;
bytesWritten: number;
};
export type WorksProjectMetadataInput = {
app_id: string;
title: string;
summary: string;
description?: string | null;
cover_url?: string | null;
creator_name?: string | null;
creator_age?: number | null;
category?: string | null;
age_band?: string | null;
difficulty?: string | null;
};
export type WorksProjectVersionUpload = {
version_id: string;
review_status: string;
build_job_id?: string | null;
build_status?: string | null;
};
export type WorksProjectVersion = {
id: string;
version_name: string;
review_status: string;
change_log: string;
build_job_id: string | null;
build_status: string | null;
build_error_code: string | null;
release_id: string | null;
rejection_reason?: string | null;
created_at: string;
};
export type WorksProjectVersionsPage = {
items: WorksProjectVersion[];
};
export type WorksProjectStatusDetail = {
project: ProjectPublic;
latest_version: WorksProjectVersion | null;
versions: WorksProjectVersion[];
};
export type WorksStaticPackageSummary = {
archiveName: string;
sha256: string;
fileCount: number;
sourceBytes: number;
archiveBytes: number;
excludedCount: number;
excludedPaths: string[];
manifest: {
schema_version: 1;
kind: 'web';
runtime: 'static';
build: {
preset: 'vite';
package_manager: 'npm';
entry: 'index.html';
};
};
};
export type WorksProjectSourcePublishInput = {
projectId: string;
project: WorksProjectMetadataInput;
cover?: WorksProjectCoverUpload;
};
export type WorksProjectCoverUpload = {
fileName: string;
mimeType: string;
dataBase64: string;
};
export type WorksSubmissionBindingWarning = {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED';
message: string;
};
export type WorksProjectSourcePublishResult = {
package: WorksStaticPackageSummary;
upload: WorksProjectVersionUpload;
bindingWarning?: WorksSubmissionBindingWarning;
};
export type ReleaseJobStatus = {
jobId: string;
projectId: string;
state: 'queued' | 'running' | 'complete' | 'failed' | 'cancelled';
phase: 'queued' | 'packaging' | 'installing' | 'building' | 'archiving' | 'complete';
percent: number;
errorCode?: string;
errorMessage?: string;
result?: {
package: WorksStaticPackageSummary;
contract: Record<string, unknown>;
};
};
export type WorksSpeechTranscription = {
text: string;
model: string;
};
export type WorksSpeechTranscriptionInput = {
accessToken: string;
audioBase64: string;
fileName: string;
mimeType: string;
language?: string;
prompt?: string;
model?: string;
};
export type PlazaCard = {
id: string;
title: string;
summary: string;
coverImage: string | null;
type: string | null;
ageBand: string | null;
difficulty: string | null;
updatedAt: string | null;
playable: boolean;
runtimeUrl: string | null;
};
type WorksActionResponse<TField extends string, TValue> = {
success: boolean;
status?: number;
code?: string;
error?: string;
} & {
[key in TField]?: TValue;
};
type FetchWorksProjectsInput = {
q?: string;
category?: string;
limit?: number;
};
type FetchWorksAssetsInput = {
q?: string;
category?: string;
tag?: string;
source?: string;
limit?: number;
};
type FetchMyWorksProjectsInput = {
limit?: number;
};
export class WorksSquareApiError extends Error {
readonly statusCode?: number;
readonly code?: string;
constructor(message: string, statusCode?: number, code?: string) {
super(message);
this.name = 'WorksSquareApiError';
this.statusCode = statusCode;
this.code = code;
}
}
function assertSuccess<TField extends string, TValue>(
response: WorksActionResponse<TField, TValue>,
field: TField,
fallback: string,
): TValue {
if (!response.success || response[field] === undefined) {
throw new WorksSquareApiError(response.error || fallback, response.status, response.code);
}
return response[field] as TValue;
}
function appendTrimmedParam(params: URLSearchParams, name: string, value: string | undefined): void {
const trimmed = value?.trim();
if (trimmed) {
params.set(name, trimmed);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function readNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function normalizeSpeechTranscription(value: unknown): WorksSpeechTranscription | null {
const directText = readNonEmptyString(value);
if (directText) {
return { text: directText, model: '' };
}
if (!isRecord(value)) {
return null;
}
const text = readNonEmptyString(value.text);
const model = readNonEmptyString(value.model) ?? '';
if (text) {
return { text, model };
}
for (const key of ['transcription', 'data', 'result', 'payload']) {
const nested = normalizeSpeechTranscription(value[key]);
if (nested) {
return {
text: nested.text,
model: nested.model || model,
};
}
}
return null;
}
export function toPlazaCard(project: ProjectPublic): PlazaCard {
return {
id: project.app_id,
title: project.title,
summary: project.summary,
coverImage: project.cover_url ?? null,
type: project.category ?? null,
ageBand: project.age_band ?? null,
difficulty: project.difficulty ?? null,
updatedAt: project.updated_at ?? null,
playable: Boolean(project.playable),
runtimeUrl: project.play_url ?? project.runtime_url ?? null,
};
}
export async function fetchWorksProjects(input: FetchWorksProjectsInput = {}): Promise<ProjectPage> {
const params = new URLSearchParams();
appendTrimmedParam(params, 'q', input.q);
appendTrimmedParam(params, 'category', input.category);
params.set('limit', String(input.limit ?? 24));
const query = params.toString();
const response = await hostApiFetch<WorksActionResponse<'page', ProjectPage>>(
`/api/works/projects${query ? `?${query}` : ''}`,
);
return assertSuccess(response, 'page', 'Failed to load Works Square projects');
}
export async function fetchWorksProject(appId: string): Promise<ProjectPublic> {
const response = await hostApiFetch<WorksActionResponse<'project', ProjectPublic>>(
`/api/works/projects/${encodeURIComponent(appId)}`,
);
return assertSuccess(response, 'project', 'Failed to load Works Square project');
}
export async function fetchWorksAssets(input: FetchWorksAssetsInput = {}): Promise<WorksAssetPage> {
const params = new URLSearchParams();
appendTrimmedParam(params, 'q', input.q);
appendTrimmedParam(params, 'category', input.category);
appendTrimmedParam(params, 'tag', input.tag);
appendTrimmedParam(params, 'source', input.source);
params.set('limit', String(input.limit ?? 24));
const query = params.toString();
const response = await hostApiFetch<WorksActionResponse<'assets', WorksAssetPage>>(
`/api/works/assets${query ? `?${query}` : ''}`,
);
return assertSuccess(response, 'assets', 'Failed to load Works Square assets');
}
export async function fetchWorksAsset(slug: string): Promise<WorksAssetPublic> {
const response = await hostApiFetch<WorksActionResponse<'asset', WorksAssetPublic>>(
`/api/works/assets/${encodeURIComponent(slug)}`,
);
return assertSuccess(response, 'asset', 'Failed to load Works Square asset');
}
export async function downloadWorksAssetToProject(
input: WorksAssetDownloadInput,
): Promise<WorksAssetDownloadResult> {
const response = await hostApiFetch<WorksActionResponse<'download', WorksAssetDownloadResult>>(
`/api/works/assets/${encodeURIComponent(input.slug)}/download`,
{
method: 'POST',
body: JSON.stringify({ projectId: input.projectId }),
},
);
return assertSuccess(response, 'download', 'Failed to download Works Square asset');
}
export async function createWorksProject(
accessToken: string,
project: WorksProjectMetadataInput,
): Promise<ProjectPublic> {
const response = await hostApiFetch<WorksActionResponse<'project', ProjectPublic>>('/api/works/projects', {
method: 'POST',
body: JSON.stringify({ accessToken, project }),
});
return assertSuccess(response, 'project', 'Failed to create Works Square project');
}
export async function fetchMyWorksProjects(
accessToken: string,
input: FetchMyWorksProjectsInput = {},
): Promise<ProjectPage> {
const params = new URLSearchParams();
params.set('limit', String(input.limit ?? 50));
const response = await hostApiFetch<WorksActionResponse<'page', ProjectPage>>(
`/api/works/projects/mine?${params.toString()}`,
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'page', 'Failed to load your Works Square projects');
}
export async function fetchMyWorksProjectStatus(
accessToken: string,
appId: string,
): Promise<WorksProjectStatusDetail> {
const response = await hostApiFetch<WorksActionResponse<'status', WorksProjectStatusDetail>>(
`/api/works/projects/mine/${encodeURIComponent(appId)}/status`,
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'status', 'Failed to load Works Square project status');
}
export async function fetchCurrentWorksProjectStatus(
appId: string,
): Promise<WorksProjectStatusDetail> {
const response = await hostApiFetch<WorksActionResponse<'status', WorksProjectStatusDetail>>(
`/api/works/projects/mine/${encodeURIComponent(appId)}/status`,
);
return assertSuccess(response, 'status', 'Failed to load Works Square project status');
}
export async function publishWorksProjectSource(
input: WorksProjectSourcePublishInput,
): Promise<WorksProjectSourcePublishResult> {
const response = await hostApiFetch<{
success: boolean;
status?: number;
code?: string;
error?: string;
package?: WorksStaticPackageSummary;
upload?: WorksProjectVersionUpload;
binding_warning?: unknown;
}>('/api/works/projects/publish-source', {
method: 'POST',
body: JSON.stringify({
projectId: input.projectId,
project: input.project,
...(input.cover ? { cover: input.cover } : {}),
}),
});
if (!response.success || !response.package || !response.upload) {
throw new WorksSquareApiError(
response.error || 'Failed to package and upload Works Square project',
response.status,
response.code,
);
}
const bindingWarning = isRecord(response.binding_warning)
&& response.binding_warning.code === 'LOCAL_PREVIEW_BINDING_SAVE_FAILED'
? {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED' as const,
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
}
: undefined;
return {
package: response.package,
upload: response.upload,
...(bindingWarning ? { bindingWarning } : {}),
};
}
export async function startWorksReleaseJob(projectId: string): Promise<ReleaseJobStatus> {
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
'/api/works/projects/release-jobs',
{ method: 'POST', body: JSON.stringify({ projectId }) },
);
if (!response.success || !response.job) {
throw new WorksSquareApiError(response.error || 'Failed to start release build', undefined, response.code);
}
return response.job;
}
export async function getWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}`,
);
if (!response.success || !response.job) {
throw new WorksSquareApiError(response.error || 'Failed to read release build status', undefined, response.code);
}
return response.job;
}
export async function cancelWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}/cancel`,
{ method: 'POST', body: JSON.stringify({}) },
);
if (!response.success || !response.job) {
throw new WorksSquareApiError(response.error || 'Failed to cancel release build', undefined, response.code);
}
return response.job;
}
export async function fetchWorksProjectVersions(
accessToken: string,
appId: string,
): Promise<WorksProjectVersionsPage> {
const response = await hostApiFetch<WorksActionResponse<'versions', WorksProjectVersionsPage>>(
`/api/works/projects/${encodeURIComponent(appId)}/versions`,
{
headers: {
'X-NianCode-Access-Token': accessToken,
},
},
);
return assertSuccess(response, 'versions', 'Failed to load Works Square uploaded versions');
}
export async function transcribeWorksSpeech(
input: WorksSpeechTranscriptionInput,
): Promise<WorksSpeechTranscription> {
const response = await hostApiFetch<WorksActionResponse<'transcription', unknown>>(
'/api/works/speech/transcriptions',
{
method: 'POST',
body: JSON.stringify({
accessToken: input.accessToken,
audioBase64: input.audioBase64,
fileName: input.fileName,
mimeType: input.mimeType,
language: input.language,
prompt: input.prompt,
model: input.model,
}),
},
);
const transcriptionPayload = assertSuccess(response, 'transcription', 'Failed to transcribe speech');
const transcription = normalizeSpeechTranscription(transcriptionPayload);
if (!transcription) {
throw new WorksSquareApiError(response.error || 'Speech transcription response did not contain text', response.status);
}
return transcription;
}