import type { IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { hasRendererCapability } from '../renderer-capability'; import { trustedWorksProjectPlayUrl } from '../works-play-url'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { createStaticProjectPackage, ProjectPackageError, } from '../../services/project-packager'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { logger } from '../../utils/logger'; type CreateProjectInput = { accessToken?: unknown; project?: unknown; }; type PublishProjectSourceInput = { projectId?: unknown; project?: unknown; }; type DownloadAssetInput = { projectId?: unknown; }; type SpeechTranscriptionInput = { accessToken?: unknown; audioBase64?: unknown; fileName?: unknown; mimeType?: unknown; language?: unknown; prompt?: unknown; model?: unknown; }; type ImageGenerationInput = Record; type AgentProfileUpdateInput = Record; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Missing ${field}`); } return value.trim(); } function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function readRequiredHeader(req: IncomingMessage, name: string): string { const value = req.headers[name.toLowerCase()]; const firstValue = Array.isArray(value) ? value[0] : value; return readRequiredString(firstValue, name); } function readOptionalHeader(req: IncomingMessage, name: string): string | undefined { const value = req.headers[name.toLowerCase()]; return readOptionalString(Array.isArray(value) ? value[0] : value); } function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string { const apiBase = value.replace(/\/+$/, ''); if (!/^https?:\/\//i.test(apiBase)) { throw new Error('Works Square API base URL must start with http:// or https://'); } return apiBase; } function createWorksUrl(pathname: string): URL { return new URL(`${normalizeWorksBase()}${pathname}`); } async function readResponsePayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; try { return JSON.parse(text) as unknown; } catch { return text; } } function getErrorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === 'object') { const record = payload as Record; for (const field of ['msg', 'message', 'error_description', 'error', 'detail']) { const value = record[field]; if (typeof value === 'string' && value.trim()) { return value; } } } if (typeof payload === 'string' && payload.trim()) { return payload; } return fallback; } async function sendUpstreamError( res: ServerResponse, response: Response, fallback: string, ): Promise { const payload = await readResponsePayload(response); sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, { success: false, status: response.status, error: getErrorMessage(payload, fallback), }); } function sendPublishSourceFailure( res: ServerResponse, status: number, code: string, error: string, ): void { sendJson(res, 200, { success: false, status, code, error }); } async function sendPublishSourceUpstreamError( res: ServerResponse, response: Response, fallbackCode: string, fallbackMessage: string, ): Promise { let code = fallbackCode; let error = fallbackMessage; if (response.status === 401) { code = 'AUTH_REQUIRED'; error = '登录状态已失效,请重新登录。'; } else if (response.status === 403) { code = 'PUBLISH_FORBIDDEN'; error = '当前账号不能发布这个作品。'; } else if (response.status === 409) { code = 'PROJECT_SUBMISSION_CONFLICT'; error = '这个作品已有版本正在构建或审核。'; } else if (response.status === 413) { code = 'ARCHIVE_TOO_LARGE'; error = '项目压缩包超过平台限制。'; } else if (response.status === 408 || response.status >= 500) { code = 'WORKS_SQUARE_UNAVAILABLE'; error = '发布服务暂时不可用,请稍后重试。'; } await response.body?.cancel().catch(() => undefined); sendPublishSourceFailure(res, response.status, code, error); } function appendOptionalSearchParam(target: URL, source: URLSearchParams, name: string): void { const value = source.get(name); if (value !== null && value.trim()) { target.searchParams.set(name, value.trim()); } } function unwrapPayload(payload: unknown, field: string): unknown { if (payload && typeof payload === 'object' && !Array.isArray(payload)) { const record = payload as Record; if (field in record) return record[field]; } return payload; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function readNullableStringField( source: Record, field: string, ): string | null | undefined { const value = source[field]; if (value === undefined) return null; if (value === null) return null; return typeof value === 'string' ? value : undefined; } function projectSafeProject(value: unknown): Record | null { if (!isRecord(value)) return null; const appId = readOptionalString(value.app_id); const title = readOptionalString(value.title); const summary = readOptionalString(value.summary); if (!appId || !title || !summary) return null; const projected: Record = { app_id: appId, title, summary }; for (const field of [ 'cover_url', 'category', 'age_band', 'difficulty', 'status', 'updated_at', 'play_url', 'runtime_url', 'creator_name', 'buddy_name', 'buddy_sprite_url', 'buddy_pose_url', 'testing_ask', 'update_note', 'remix_note', 'how_to', 'learning_note', 'visual_alt', ]) { const fieldValue = value[field]; if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue; } const versionName = readOptionalString(value.version_name); if (value.version_name !== undefined) projected.version_name = versionName ?? null; projected.playable = false; projected.play_url = null; projected.runtime_url = null; if (value.playable === true && versionName) { const playUrlPresent = value.play_url !== undefined && value.play_url !== null; const primaryPlayUrl = readOptionalString(value.play_url); const fallbackRuntimeUrl = readOptionalString(value.runtime_url); const candidate = playUrlPresent ? primaryPlayUrl : fallbackRuntimeUrl; const trusted = candidate ? trustedWorksProjectPlayUrl( candidate, new URL(`${normalizeWorksBase()}/`), appId, ) : null; if (trusted) { projected.playable = true; if (playUrlPresent) projected.play_url = trusted; else projected.runtime_url = trusted; } } return projected; } function projectSafeProjectPage(value: unknown): Record | null { if (!isRecord(value) || !Array.isArray(value.items)) return null; const items = value.items.map(projectSafeProject); if (items.some((item) => item === null)) return null; const nextCursor = readNullableStringField(value, 'next_cursor'); if (nextCursor === undefined || typeof value.limit !== 'number' || !Number.isFinite(value.limit)) { return null; } return { items, next_cursor: nextCursor, limit: value.limit }; } function projectSafeVersion(value: unknown): Record | null { if (!isRecord(value)) return null; const id = readOptionalString(value.id); const versionName = readOptionalString(value.version_name); const reviewStatus = readOptionalString(value.review_status); const changeLog = typeof value.change_log === 'string' ? value.change_log : null; const createdAt = readOptionalString(value.created_at); if (!id || !versionName || !reviewStatus || changeLog === null || !createdAt) return null; const buildJobId = readNullableStringField(value, 'build_job_id'); const buildStatus = readNullableStringField(value, 'build_status'); const buildErrorCode = readNullableStringField(value, 'build_error_code'); const releaseId = readNullableStringField(value, 'release_id'); if ( buildJobId === undefined || buildStatus === undefined || buildErrorCode === undefined || releaseId === undefined ) return null; const projected: Record = { id, version_name: versionName, review_status: reviewStatus, change_log: changeLog, build_job_id: buildJobId, build_status: buildStatus, build_error_code: buildErrorCode, release_id: releaseId, created_at: createdAt, }; const rejectionReason = readNullableStringField(value, 'rejection_reason'); if (rejectionReason !== undefined) projected.rejection_reason = rejectionReason; return projected; } function projectSafeStatusPayload(value: unknown): Record | null { if (!isRecord(value) || !Array.isArray(value.versions)) return null; const project = projectSafeProject(value.project); if (!project) return null; const versions = value.versions.map(projectSafeVersion); if (versions.some((version) => version === null)) return null; const latestVersion = value.latest_version === null || value.latest_version === undefined ? null : projectSafeVersion(value.latest_version); if (value.latest_version !== null && value.latest_version !== undefined && !latestVersion) { return null; } return { project, latest_version: latestVersion, versions }; } function projectSafeUploadPayload(value: unknown): Record | null { if (!isRecord(value)) return null; const versionId = readOptionalString(value.version_id); const reviewStatus = readOptionalString(value.review_status); if (!versionId || !reviewStatus) return null; const projected: Record = { version_id: versionId, review_status: reviewStatus, }; for (const field of ['build_job_id', 'build_status']) { const fieldValue = readNullableStringField(value, field); if (fieldValue === undefined) return null; projected[field] = fieldValue; } return projected; } async function handleListProjects(res: ServerResponse, url: URL): Promise { const upstreamUrl = createWorksUrl('/api/projects'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'q'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'category'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit'); const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square list failed (${response.status})`); return; } const page = projectSafeProjectPage(await readResponsePayload(response)); if (!page) { sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' }); return; } sendJson(res, 200, { success: true, page }); } async function handleGetProject(res: ServerResponse, appId: string): Promise { const upstreamUrl = createWorksUrl(`/api/projects/${encodeURIComponent(appId)}`); const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square detail failed (${response.status})`); return; } const project = projectSafeProject(await readResponsePayload(response)); if (!project) { sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' }); return; } sendJson(res, 200, { success: true, project }); } async function handleListAssets(res: ServerResponse, url: URL): Promise { const upstreamUrl = createWorksUrl('/api/assets'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'q'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'category'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'tag'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'source'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit'); const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square asset list failed (${response.status})`); return; } sendJson(res, 200, { success: true, assets: await readResponsePayload(response) }); } async function handleGetAsset(res: ServerResponse, slug: string): Promise { const upstreamUrl = createWorksUrl(`/api/assets/${encodeURIComponent(slug)}`); const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square asset detail failed (${response.status})`); return; } sendJson(res, 200, { success: true, asset: await readResponsePayload(response) }); } function assetZipFileName(slug: string): string { if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) { throw new Error('Invalid asset slug'); } return `${slug}.zip`; } function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } async function readArchiveBytesFromDownloadUrl(downloadUrl: string): Promise { let currentUrl = downloadUrl; for (let redirects = 0; redirects <= 5; redirects += 1) { const response = await proxyAwareFetch(currentUrl, { method: 'GET', redirect: 'manual' }); if (isRedirectStatus(response.status)) { const location = response.headers.get('location'); if (!location) { throw new Error(`Works Square asset download redirect missing Location (${response.status})`); } currentUrl = new URL(location, currentUrl).toString(); continue; } if (!response.ok) { throw new Error(`Works Square asset download failed (${response.status})`); } return Buffer.from(await response.arrayBuffer()); } throw new Error('Works Square asset download redirected too many times'); } async function handleDownloadAssetToProject( req: IncomingMessage, res: ServerResponse, slug: string, ctx: HostApiContext, ): Promise { const body = await parseJsonBody(req); const projectId = readRequiredString(body.projectId, 'projectId'); const fileName = assetZipFileName(slug); const projects = await ctx.opencodeProjectStore.listProjects(); const project = projects.find((candidate) => candidate.id === projectId); if (!project) { throw new Error('Project not found'); } const projectStat = await stat(project.path); if (!projectStat.isDirectory()) { throw new Error('Project path must point to a directory'); } const archiveBytes = await readArchiveBytesFromDownloadUrl( createWorksUrl(`/api/assets/${encodeURIComponent(slug)}/download`).toString(), ); const relativePath = `assets/resource-square/${fileName}`; const targetDir = join(project.path, 'assets', 'resource-square'); await mkdir(targetDir, { recursive: true }); const filePath = join(targetDir, fileName); await writeFile(filePath, archiveBytes); sendJson(res, 200, { success: true, download: { slug, filePath, relativePath, bytesWritten: archiveBytes.byteLength, }, }); } async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise { const body = await parseJsonBody(req); const accessToken = readRequiredString(body.accessToken, 'accessToken'); if (!body.project || typeof body.project !== 'object' || Array.isArray(body.project)) { throw new Error('Missing project'); } const response = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body.project), }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square project create failed (${response.status})`); return; } const project = projectSafeProject(await readResponsePayload(response)); if (!project) { sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' }); return; } sendJson(res, response.status, { success: true, project }); } async function handleListMyProjects( req: IncomingMessage, res: ServerResponse, url: URL, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const upstreamUrl = createWorksUrl('/api/projects/mine'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit'); const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square my projects failed (${response.status})`); return; } const page = projectSafeProjectPage(await readResponsePayload(response)); if (!page) { sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' }); return; } sendJson(res, response.status, { success: true, page }); } async function handleGetBillingTokenUsage( req: IncomingMessage, res: ServerResponse, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const response = await proxyAwareFetch(createWorksUrl('/api/billing/token-usage').toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square token usage failed (${response.status})`); return; } sendJson(res, response.status, { success: true, usage: await readResponsePayload(response) }); } async function handleAgentProfile( req: IncomingMessage, res: ServerResponse, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const body = req.method === 'PUT' ? await parseJsonBody(req) : undefined; const response = await proxyAwareFetch(createWorksUrl('/api/user/agent-profile').toString(), { method: req.method, headers: { Authorization: `Bearer ${accessToken}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); const payload = await readResponsePayload(response); if (!response.ok) { const detail = payload && typeof payload === 'object' && !Array.isArray(payload) ? (payload as Record).detail : undefined; // Keep the local Host API response successful so the renderer can inspect // the upstream status and conflict detail instead of losing it in the // generic Host API error parser. sendJson(res, 200, { success: false, status: response.status, error: getErrorMessage(payload, `Agent Profile request failed (${response.status})`), ...(detail === undefined ? {} : { detail }), }); return; } sendJson(res, response.status, { success: true, profile: payload }); } async function handleSubmitImageGeneration( req: IncomingMessage, res: ServerResponse, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const body = await parseJsonBody(req); const response = await proxyAwareFetch(createWorksUrl('/api/ai-gateway/images/generations').toString(), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square image generation submit failed (${response.status})`); return; } const payload = await readResponsePayload(response); sendJson(res, response.status, { success: true, job: unwrapPayload(payload, 'job'), }); } async function handleGetImageGenerationTask( req: IncomingMessage, res: ServerResponse, taskId: string, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const response = await proxyAwareFetch( createWorksUrl(`/api/ai-gateway/images/tasks/${encodeURIComponent(taskId)}`).toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { await sendUpstreamError(res, response, `Works Square image generation task failed (${response.status})`); return; } const payload = await readResponsePayload(response); sendJson(res, response.status, { success: true, job: unwrapPayload(payload, 'job'), }); } async function handleGetMyProjectStatus( req: IncomingMessage, res: ServerResponse, appId: string, ): Promise { const accessToken = readOptionalHeader(req, 'x-niancode-access-token') ?? await getValidWorksSquareAccessToken(); if (!accessToken) { sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。'); return; } const response = await proxyAwareFetch( createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { await sendPublishSourceUpstreamError( res, response, 'PROJECT_STATUS_UNAVAILABLE', '暂时无法获取作品处理状态,请稍后重试。', ); return; } const statusPayload = projectSafeStatusPayload(await readResponsePayload(response)); if (!statusPayload) { sendPublishSourceFailure( res, 502, 'PROJECT_STATUS_UNAVAILABLE', '暂时无法获取作品处理状态,请稍后重试。', ); return; } sendJson(res, response.status, { success: true, status: statusPayload }); } const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交'; const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]); const VERSION_FILE_MAX_BYTES = 64 * 1024; const LOCAL_PREVIEW_BINDING_WARNING = { code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED', message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', } as const; function createFallbackVersionName(now = new Date()): string { return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`; } async function readAutomaticVersionName(projectPath: string): Promise { let handle: Awaited> | null = null; try { const versionPath = join(projectPath, 'VERSION.md'); const scanned = await lstat(versionPath); if (!scanned.isFile() || scanned.isSymbolicLink() || scanned.size > VERSION_FILE_MAX_BYTES) { return createFallbackVersionName(); } handle = await open(versionPath, 'r'); const before = await handle.stat(); if ( !before.isFile() || before.size !== scanned.size || before.dev !== scanned.dev || before.ino !== scanned.ino || before.mtimeMs !== scanned.mtimeMs || before.ctimeMs !== scanned.ctimeMs ) return createFallbackVersionName(); const versionDocument = await handle.readFile('utf8'); const after = await handle.stat(); if ( after.size !== before.size || after.dev !== before.dev || after.ino !== before.ino || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ) return createFallbackVersionName(); const currentVersion = versionDocument.match(/^Current:\s*(.+)$/m)?.[1]?.trim(); if (currentVersion && currentVersion.length <= 80) return currentVersion; } catch { // Imported projects do not have to contain VERSION.md. } finally { await handle?.close().catch(() => undefined); } return createFallbackVersionName(); } function createSourceUploadForm( archiveBytes: Buffer, archiveName: string, versionName: string, ): FormData { const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' }); const form = new FormData(); form.set('version_name', versionName); form.set('change_log', SOURCE_PUBLISH_CHANGE_LOG); form.set('archive', archiveBlob, archiveName); return form; } async function uploadSourceProjectVersion(input: { accessToken: string; appId: string; archiveBytes: Buffer; archiveName: string; versionName: string; idempotencyKey: string; }): Promise { let lastError: unknown; for (let attempt = 0; attempt < 2; attempt += 1) { try { const response = await proxyAwareFetch( createWorksUrl(`/api/projects/${encodeURIComponent(input.appId)}/versions/upload`).toString(), { method: 'POST', headers: { Authorization: `Bearer ${input.accessToken}`, 'Idempotency-Key': input.idempotencyKey, }, body: createSourceUploadForm( input.archiveBytes, input.archiveName, input.versionName, ), }, ); if (attempt === 0 && RETRYABLE_SOURCE_UPLOAD_STATUSES.has(response.status)) { await response.body?.cancel().catch(() => undefined); continue; } return response; } catch (error) { lastError = error; if (attempt === 1) throw error; } } throw lastError instanceof Error ? lastError : new Error('Source upload retry exhausted'); } function readProjectMetadata(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const source = value as Record; try { const metadata: Record = { app_id: readRequiredString(source.app_id, 'project.app_id'), 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 fieldValue = readOptionalString(source[field]); if (fieldValue) metadata[field] = fieldValue; } return metadata; } catch { return null; } } async function handlePublishProjectSource( req: IncomingMessage, res: ServerResponse, ctx: HostApiContext, ): Promise { const body = await parseJsonBody(req); const accessToken = await getValidWorksSquareAccessToken(); if (!accessToken) { sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。'); return; } const projectId = readOptionalString(body.projectId); const projectMetadata = readProjectMetadata(body.project); if (!projectId || !projectMetadata) { sendPublishSourceFailure( res, 400, 'PROJECT_METADATA_INVALID', '作品信息不完整,请返回项目后重试。', ); return; } const appId = projectMetadata.app_id as string; const localProject = (await ctx.opencodeProjectStore.listProjects()) .find((candidate) => candidate.id === projectId); if (!localProject) { sendPublishSourceFailure(res, 404, 'PROJECT_NOT_FOUND', '本地项目不存在,请重新选择项目。'); return; } const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-')); const archivePath = join(temporaryDirectory, 'project.zip'); try { const packageSummary = await createStaticProjectPackage({ projectPath: localProject.path, archivePath, }); const archiveBytes = await readFile(archivePath); const versionName = await readAutomaticVersionName(localProject.path); const idempotencyKey = `makelore-${randomUUID()}`; const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(projectMetadata), }); if (!createResponse.ok && createResponse.status !== 409) { await sendPublishSourceUpstreamError( res, createResponse, 'PROJECT_CREATE_REJECTED', '平台没有接受作品信息,请修改后重试。', ); return; } await createResponse.body?.cancel().catch(() => undefined); if (createResponse.status === 409) { const ownershipResponse = await proxyAwareFetch( createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, }, ); if (!ownershipResponse.ok) { await sendPublishSourceUpstreamError( res, ownershipResponse, 'PROJECT_OWNERSHIP_UNCONFIRMED', '这个作品 ID 已被占用,请更换后重试。', ); return; } await ownershipResponse.body?.cancel().catch(() => undefined); } const uploadResponse = await uploadSourceProjectVersion({ accessToken, appId, archiveBytes, archiveName: packageSummary.archiveName, versionName, idempotencyKey, }); if (!uploadResponse.ok) { await sendPublishSourceUpstreamError( res, uploadResponse, 'SOURCE_PACKAGE_REJECTED', '项目没有通过平台检查,请修复后重试。', ); return; } const uploadPayload = projectSafeUploadPayload(await readResponsePayload(uploadResponse)); if (!uploadPayload) { sendPublishSourceFailure( res, 502, 'WORKS_SQUARE_UNAVAILABLE', '发布服务返回结果异常,请稍后重试。', ); return; } let bindingWarning: typeof LOCAL_PREVIEW_BINDING_WARNING | undefined; if (ctx.worksSubmissionBinding) { try { await ctx.worksSubmissionBinding.recordSubmitted(projectId, { appId, versionId: uploadPayload.version_id, versionName, reviewStatus: uploadPayload.review_status, zipSha256: packageSummary.sha256, }); } catch { logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved'); bindingWarning = LOCAL_PREVIEW_BINDING_WARNING; } } else { bindingWarning = LOCAL_PREVIEW_BINDING_WARNING; } const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary; sendJson(res, uploadResponse.status, { success: true, package: rendererPackageSummary, upload: uploadPayload, ...(bindingWarning ? { binding_warning: bindingWarning } : {}), }); } finally { await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); } } function createSpeechTranscriptionFormData(body: SpeechTranscriptionInput): FormData { const audioBase64 = readRequiredString(body.audioBase64, 'audioBase64'); const fileName = readOptionalString(body.fileName) ?? 'voice.wav'; const mimeType = readOptionalString(body.mimeType) ?? 'audio/wav'; const audioBytes = Buffer.from(audioBase64, 'base64'); if (audioBytes.length === 0) { throw new Error('audioBase64 must contain audio data'); } const form = new FormData(); const audioBlob = new Blob([new Uint8Array(audioBytes)], { type: mimeType }); form.set('audio', audioBlob, fileName); const language = readOptionalString(body.language); const prompt = readOptionalString(body.prompt); const model = readOptionalString(body.model); if (language) form.set('language', language); if (prompt) form.set('prompt', prompt); if (model) form.set('model', model); return form; } async function handleSpeechTranscription( req: IncomingMessage, res: ServerResponse, ): Promise { const body = await parseJsonBody(req); const accessToken = readRequiredString(body.accessToken, 'accessToken'); const form = createSpeechTranscriptionFormData(body); const response = await proxyAwareFetch(createWorksUrl('/api/speech/transcriptions').toString(), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, }, body: form, }); if (!response.ok) { await sendUpstreamError(res, response, `Works Square speech transcription failed (${response.status})`); return; } sendJson(res, response.status, { success: true, transcription: await readResponsePayload(response) }); } async function handleListProjectVersions( req: IncomingMessage, res: ServerResponse, appId: string, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); const response = await proxyAwareFetch( createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions`).toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { await sendUpstreamError(res, response, `Works Square versions failed (${response.status})`); return; } sendJson(res, response.status, { success: true, versions: await readResponsePayload(response) }); } export async function handleWorksRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (!url.pathname.startsWith('/api/works')) { return false; } try { if (url.pathname === '/api/works/user/agent-profile' && (req.method === 'GET' || req.method === 'PUT')) { await handleAgentProfile(req, res); return true; } if (url.pathname === '/api/works/speech/transcriptions' && req.method === 'POST') { await handleSpeechTranscription(req, res); return true; } if (url.pathname === '/api/works/billing/token-usage' && req.method === 'GET') { await handleGetBillingTokenUsage(req, res); return true; } if (url.pathname === '/api/works/ai-gateway/images/generations' && req.method === 'POST') { await handleSubmitImageGeneration(req, res); return true; } const imageTaskPrefix = '/api/works/ai-gateway/images/tasks/'; if (url.pathname.startsWith(imageTaskPrefix) && req.method === 'GET') { const taskId = decodeURIComponent(url.pathname.slice(imageTaskPrefix.length)); if (!taskId) { sendJson(res, 400, { success: false, error: 'Missing task_id' }); return true; } await handleGetImageGenerationTask(req, res, taskId); return true; } if (url.pathname === '/api/works/assets' && req.method === 'GET') { await handleListAssets(res, url); return true; } const assetDownloadMatch = url.pathname.match(/^\/api\/works\/assets\/([^/]+)\/download$/); if (assetDownloadMatch && req.method === 'POST') { await handleDownloadAssetToProject(req, res, decodeURIComponent(assetDownloadMatch[1]), ctx); return true; } const assetDetailMatch = url.pathname.match(/^\/api\/works\/assets\/([^/]+)$/); if (assetDetailMatch && req.method === 'GET') { await handleGetAsset(res, decodeURIComponent(assetDetailMatch[1])); return true; } if (url.pathname === '/api/works/projects' && req.method === 'GET') { await handleListProjects(res, url); return true; } if (url.pathname === '/api/works/projects' && req.method === 'POST') { await handleCreateProject(req, res); return true; } if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') { if (!hasRendererCapability(req)) { sendJson(res, 403, { success: false, status: 403, code: 'RENDERER_CAPABILITY_REQUIRED', error: 'Renderer capability required', }); return true; } await handlePublishProjectSource(req, res, ctx); return true; } if (url.pathname === '/api/works/projects/mine' && req.method === 'GET') { await handleListMyProjects(req, res, url); return true; } const myProjectStatusMatch = url.pathname.match(/^\/api\/works\/projects\/mine\/([^/]+)\/status$/); if (myProjectStatusMatch && req.method === 'GET') { await handleGetMyProjectStatus(req, res, decodeURIComponent(myProjectStatusMatch[1])); return true; } const detailMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)$/); if (detailMatch && req.method === 'GET') { await handleGetProject(res, decodeURIComponent(detailMatch[1])); return true; } const versionsMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions$/); if (versionsMatch && req.method === 'GET') { await handleListProjectVersions(req, res, decodeURIComponent(versionsMatch[1])); return true; } sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` }); return true; } catch (error) { const isProjectSourcePublish = url.pathname === '/api/works/projects/publish-source'; const isProjectStatus = /^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(url.pathname); if (isProjectSourcePublish) { const isPackageError = error instanceof ProjectPackageError; sendPublishSourceFailure( res, isPackageError ? 400 : 503, isPackageError ? error.code : 'WORKS_SQUARE_UNAVAILABLE', isPackageError ? error.message : '发布服务暂时不可用,请稍后重试。', ); } else if (isProjectStatus) { sendPublishSourceFailure( res, 503, 'PROJECT_STATUS_UNAVAILABLE', '暂时无法获取作品处理状态,请稍后重试。', ); } else { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error), }); } return true; } }