Files
makelore/electron/api/routes/works.ts
2026-07-29 17:22:35 +08:00

688 lines
23 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'http';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { basename, extname, isAbsolute, join, normalize, resolve } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check';
import { readWorksPublishFile } from '../../opencode/works-publish-file';
type CreateProjectInput = {
accessToken?: unknown;
project?: unknown;
};
type UploadProjectVersionInput = {
accessToken?: unknown;
projectId?: unknown;
versionName?: unknown;
changeLog?: unknown;
zipFilePath?: unknown;
};
type DownloadAssetInput = {
projectId?: unknown;
};
type SpeechTranscriptionInput = {
accessToken?: unknown;
audioBase64?: unknown;
fileName?: unknown;
mimeType?: unknown;
language?: unknown;
prompt?: unknown;
model?: unknown;
};
type ImageGenerationInput = Record<string, unknown>;
type AgentProfileUpdateInput = Record<string, unknown>;
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 resolveProjectFilePath(projectPath: string, value: string): string {
return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) ? value : resolve(projectPath, value);
}
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 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<unknown> {
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<string, unknown>;
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<void> {
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 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<string, unknown>;
if (field in record) return record[field];
}
return payload;
}
async function handleListProjects(res: ServerResponse, url: URL): Promise<void> {
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;
}
sendJson(res, 200, { success: true, page: await readResponsePayload(response) });
}
async function handleGetProject(res: ServerResponse, appId: string): Promise<void> {
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;
}
sendJson(res, 200, { success: true, project: await readResponsePayload(response) });
}
async function handleListAssets(res: ServerResponse, url: URL): Promise<void> {
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<void> {
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<Buffer> {
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<void> {
const body = await parseJsonBody<DownloadAssetInput>(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<void> {
const body = await parseJsonBody<CreateProjectInput>(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;
}
sendJson(res, response.status, { success: true, project: await readResponsePayload(response) });
}
async function handleListMyProjects(
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
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;
}
sendJson(res, response.status, { success: true, page: await readResponsePayload(response) });
}
async function handleGetBillingTokenUsage(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
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<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const body = req.method === 'PUT'
? await parseJsonBody<AgentProfileUpdateInput>(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<string, unknown>).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<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const body = await parseJsonBody<ImageGenerationInput>(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<void> {
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<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const response = await proxyAwareFetch(
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!response.ok) {
await sendUpstreamError(res, response, `Works Square project status failed (${response.status})`);
return;
}
sendJson(res, response.status, { success: true, status: await readResponsePayload(response) });
}
async function createArchiveFormData(
body: UploadProjectVersionInput,
ctx: HostApiContext,
appId: string,
): Promise<FormData> {
const versionName = readRequiredString(body.versionName, 'versionName');
const changeLog = readRequiredString(body.changeLog, 'changeLog');
const zipFilePath = readRequiredString(body.zipFilePath, 'zipFilePath');
const projectId = readRequiredString(body.projectId, 'projectId');
const project = (await ctx.opencodeProjectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const publish = await readWorksPublishFile(project.path);
if (publish.status !== 'ready') throw new Error('BLOCKED: works-publish.json is missing or invalid');
if (publish.publish.app_id !== appId) throw new Error('BLOCKED: upload app id does not match project publish data');
const resolvedZipPath = resolveProjectFilePath(project.path, zipFilePath);
const publishZipPath = resolveProjectFilePath(project.path, publish.publish.zip_file_path);
if (normalize(publishZipPath).toLowerCase() !== normalize(resolvedZipPath).toLowerCase()) {
throw new Error('BLOCKED: upload zip path does not match project publish data');
}
const deployCheck = await readWorksDeployCheck(project.path, publish.publish);
if (deployCheck.status !== 'pass' && deployCheck.status !== 'warning') {
throw new Error(`BLOCKED: ${deployCheck.error || 'deployment checks did not pass'}`);
}
if (extname(resolvedZipPath).toLowerCase() !== '.zip') {
throw new Error('zipFilePath must point to a .zip file');
}
const archiveStat = await stat(resolvedZipPath);
if (!archiveStat.isFile()) {
throw new Error('zipFilePath must point to a file');
}
const archiveBytes = await readFile(resolvedZipPath);
const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' });
const form = new FormData();
form.set('version_name', versionName);
form.set('change_log', changeLog);
form.set('archive', archiveBlob, basename(resolvedZipPath));
return form;
}
async function handleUploadProjectVersion(
req: IncomingMessage,
res: ServerResponse,
appId: string,
ctx: HostApiContext,
): Promise<void> {
const body = await parseJsonBody<UploadProjectVersionInput>(req);
const accessToken = readRequiredString(body.accessToken, 'accessToken');
const form = await createArchiveFormData(body, ctx, appId);
const response = await proxyAwareFetch(
createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions/upload`).toString(),
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: form,
},
);
if (!response.ok) {
await sendUpstreamError(res, response, `Works Square upload failed (${response.status})`);
return;
}
sendJson(res, response.status, { success: true, upload: await readResponsePayload(response) });
}
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<void> {
const body = await parseJsonBody<SpeechTranscriptionInput>(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<void> {
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<boolean> {
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/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;
}
const uploadMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions\/upload$/);
if (uploadMatch && req.method === 'POST') {
await handleUploadProjectVersion(req, res, decodeURIComponent(uploadMatch[1]), ctx);
return true;
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
return true;
} catch (error) {
sendJson(res, 400, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
return true;
}
}