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; }; }; 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 = { 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( response: WorksActionResponse, 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 { 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 { 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>( `/api/works/projects${query ? `?${query}` : ''}`, ); return assertSuccess(response, 'page', 'Failed to load Works Square projects'); } export async function fetchWorksProject(appId: string): Promise { const response = await hostApiFetch>( `/api/works/projects/${encodeURIComponent(appId)}`, ); return assertSuccess(response, 'project', 'Failed to load Works Square project'); } export async function fetchWorksAssets(input: FetchWorksAssetsInput = {}): Promise { 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>( `/api/works/assets${query ? `?${query}` : ''}`, ); return assertSuccess(response, 'assets', 'Failed to load Works Square assets'); } export async function fetchWorksAsset(slug: string): Promise { const response = await hostApiFetch>( `/api/works/assets/${encodeURIComponent(slug)}`, ); return assertSuccess(response, 'asset', 'Failed to load Works Square asset'); } export async function downloadWorksAssetToProject( input: WorksAssetDownloadInput, ): Promise { const response = await hostApiFetch>( `/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 { const response = await hostApiFetch>('/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 { const params = new URLSearchParams(); params.set('limit', String(input.limit ?? 50)); const response = await hostApiFetch>( `/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 { const response = await hostApiFetch>( `/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 { const response = await hostApiFetch>( `/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 { 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 { 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 { 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 { 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 { const response = await hostApiFetch>( `/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 { const response = await hostApiFetch>( '/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; }