Makelore 2.0 initial clean snapshot
This commit is contained in:
449
src/lib/works-square.ts
Normal file
449
src/lib/works-square.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import type { WorksCloudDeploymentRecord } from '../../shared/works-cloud-deployment';
|
||||
|
||||
export type ProjectPublic = {
|
||||
app_id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
cover_url?: string | null;
|
||||
category?: string | null;
|
||||
age_band?: string | null;
|
||||
difficulty?: string | null;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
playable?: boolean;
|
||||
runtime_url?: string | null;
|
||||
creator_name?: string | 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;
|
||||
cover_url?: string | null;
|
||||
category?: string | null;
|
||||
age_band?: string | null;
|
||||
difficulty?: string | null;
|
||||
};
|
||||
|
||||
export type WorksProjectVersionUpload = {
|
||||
version_id: string;
|
||||
review_status: string;
|
||||
};
|
||||
|
||||
export type WorksProjectVersion = {
|
||||
id: string;
|
||||
version_name: string;
|
||||
review_status: string;
|
||||
change_log: string;
|
||||
build_job_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WorksProjectVersionsPage = {
|
||||
items: WorksProjectVersion[];
|
||||
};
|
||||
|
||||
export type WorksProjectStatusDetail = {
|
||||
project: ProjectPublic;
|
||||
latest_version: WorksProjectVersion | null;
|
||||
versions: WorksProjectVersion[];
|
||||
};
|
||||
|
||||
export type WorksProjectUploadInput = {
|
||||
accessToken: string;
|
||||
appId: string;
|
||||
projectId: string;
|
||||
versionName: string;
|
||||
changeLog: string;
|
||||
zipFilePath: string;
|
||||
};
|
||||
|
||||
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 WorksTokenUsage = {
|
||||
five_hour_remaining_percent: number | null;
|
||||
five_hour_refresh_at?: string | null;
|
||||
weekly_remaining_percent: number | null;
|
||||
weekly_refresh_at?: string | null;
|
||||
plan_code?: string | null;
|
||||
plan_name?: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message);
|
||||
this.name = 'WorksSquareApiError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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.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 fetchWorksTokenUsage(accessToken: string): Promise<WorksTokenUsage> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'usage', WorksTokenUsage>>(
|
||||
'/api/works/billing/token-usage',
|
||||
{
|
||||
headers: {
|
||||
'X-NianCode-Access-Token': accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
return assertSuccess(response, 'usage', 'Failed to load Works Square token usage');
|
||||
}
|
||||
|
||||
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 uploadWorksProjectZip(
|
||||
input: WorksProjectUploadInput,
|
||||
): Promise<WorksProjectVersionUpload> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'upload', WorksProjectVersionUpload>>(
|
||||
`/api/works/projects/${encodeURIComponent(input.appId)}/versions/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accessToken: input.accessToken,
|
||||
projectId: input.projectId,
|
||||
versionName: input.versionName,
|
||||
changeLog: input.changeLog,
|
||||
zipFilePath: input.zipFilePath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return assertSuccess(response, 'upload', 'Failed to upload Works Square project zip');
|
||||
}
|
||||
|
||||
export async function armWorksCloudDeployment(projectId: string): Promise<WorksCloudDeploymentRecord> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'deployment', WorksCloudDeploymentRecord>>(
|
||||
`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
);
|
||||
return assertSuccess(response, 'deployment', 'Failed to arm cloud deployment');
|
||||
}
|
||||
|
||||
export async function fetchWorksCloudDeployment(
|
||||
projectId: string,
|
||||
): Promise<WorksCloudDeploymentRecord | null> {
|
||||
const response = await hostApiFetch<{
|
||||
success: boolean;
|
||||
deployment?: WorksCloudDeploymentRecord | null;
|
||||
error?: string;
|
||||
}>(`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`);
|
||||
if (!response.success) {
|
||||
throw new WorksSquareApiError(response.error || 'Failed to read cloud deployment status');
|
||||
}
|
||||
return response.deployment ?? null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user