import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { deleteApiKey, getApiKey, storeApiKey } from '../../utils/secure-storage'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { readBundledMeowaApiKey, readEmbeddedMeowaApiKey, } from '../../services/meowa-game-assets-release-credential'; export const MEOWA_GAME_ASSETS_ACCOUNT_ID = 'meowa-game-assets'; export const MEOWA_GAME_ASSETS_API_BASE_URL = 'https://api.meowa.ai'; export const MEOWA_GAME_ASSETS_SKILL_VERSION = '2026.06.19.1'; const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024; const MAX_REFERENCE_BYTES = 16 * 1024 * 1024; const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024; const SUPPORTED_KINDS = ['pixel', 'hd'] as const; type MeowaGameAssetKind = typeof SUPPORTED_KINDS[number]; type JsonRecord = Record; function asRecord(value: unknown): JsonRecord | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : null; } function readRequiredString(value: unknown, field: string, maxLength = 2_000): string { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Missing ${field}`); } const normalized = value.trim(); if (normalized.length > maxLength) { throw new Error(`${field} is too long`); } return normalized; } function readOptionalString(value: unknown, maxLength = 2_000): string | undefined { if (typeof value !== 'string' || !value.trim()) return undefined; const normalized = value.trim(); if (normalized.length > maxLength) throw new Error('Optional field is too long'); return normalized; } function readKind(value: unknown): MeowaGameAssetKind { if (value === 'pixel' || value === 'hd') return value; throw new Error('kind must be pixel or hd'); } function endpoint(kind: MeowaGameAssetKind, suffix: string): string { return `${MEOWA_GAME_ASSETS_API_BASE_URL}/api/${kind}-gen${suffix}`; } function upstreamHeaders(apiKey?: string): Record { return { Accept: 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }; } 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.slice(0, 500); } } function messageFromPayload(payload: unknown, fallback: string): string { const record = asRecord(payload); if (record) { for (const field of ['message', 'error', 'detail', 'msg']) { const value = record[field]; if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 500); } } if (typeof payload === 'string' && payload.trim()) return payload.trim().slice(0, 500); return fallback; } async function sendUpstreamError( res: ServerResponse, response: Response, operation: string, ): Promise { const payload = await readResponsePayload(response); const status = response.status >= 400 && response.status < 500 ? response.status : 502; sendJson(res, status, { success: false, error: `${operation}: ${messageFromPayload(payload, `upstream status ${response.status}`)}`, }); } type MeowaCredentialSource = 'secure-store' | 'environment' | 'release-bundle' | 'embedded' | 'none'; async function resolveCredential(): Promise<{ apiKey: string | null; source: MeowaCredentialSource }> { const stored = await getApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID); if (stored?.trim()) return { apiKey: stored.trim(), source: 'secure-store' }; const environment = process.env.MEOWART_API_KEY?.trim(); if (environment) { await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, environment); return { apiKey: environment, source: 'environment' }; } const bundled = await readBundledMeowaApiKey(); if (bundled) { await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, bundled); return { apiKey: bundled, source: 'release-bundle' }; } const embedded = readEmbeddedMeowaApiKey(); if (embedded) { await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, embedded); return { apiKey: embedded, source: 'embedded' }; } return { apiKey: null, source: 'none' }; } export async function initializeMeowaGameAssetsCredential(): Promise>> { return resolveCredential(); } async function requireCredential(res: ServerResponse): Promise { const credential = await resolveCredential(); if (credential.apiKey) return credential.apiKey; sendJson(res, 409, { success: false, error: 'Meowa 素材服务尚未配置,请联系管理员。', code: 'MEOWA_API_KEY_MISSING', }); return null; } function readTemplateConfig(value: unknown): string { if (value === undefined || value === null || value === '') return '{}'; if (typeof value === 'string') { try { const parsed = JSON.parse(value) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('templateConfig must be an object'); return JSON.stringify(parsed); } catch (error) { throw new Error(`Invalid templateConfig: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } if (!asRecord(value)) throw new Error('templateConfig must be an object'); return JSON.stringify(value); } function readBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } function readNumber(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function readReferenceFiles(value: unknown): Array<{ name: string; mimeType: string; bytes: Buffer }> { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error('referenceFiles must be an array'); let totalBytes = 0; return value.map((item, index) => { const record = asRecord(item); if (!record) throw new Error(`referenceFiles[${index}] must be an object`); const encoded = readRequiredString(record.dataBase64, `referenceFiles[${index}].dataBase64`, MAX_REFERENCE_FILE_BYTES * 2); if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error(`referenceFiles[${index}] has invalid base64`); const bytes = Buffer.from(encoded, 'base64'); if (bytes.length > MAX_REFERENCE_FILE_BYTES) throw new Error(`referenceFiles[${index}] is too large`); totalBytes += bytes.length; if (totalBytes > MAX_REFERENCE_BYTES) throw new Error('referenceFiles are too large'); return { name: readOptionalString(record.name, 160) ?? `reference-${index + 1}.png`, mimeType: readOptionalString(record.mimeType, 120) ?? 'image/png', bytes, }; }); } async function buildGenerationForm(kind: MeowaGameAssetKind, body: JsonRecord): Promise { const form = new FormData(); form.set('template_name', readRequiredString(body.templateName, 'templateName', 200)); form.set('template_config', readTemplateConfig(body.templateConfig)); form.set('requirement', readRequiredString(body.requirement, 'requirement', 12_000)); form.set('aspect_ratio', readOptionalString(body.aspectRatio, 40) ?? '1:1'); form.set('temperature', String(readNumber(body.temperature, 0))); form.set('include_base64', readBoolean(body.includeBase64, false) ? 'true' : 'false'); const optionalFields: Array<[string, unknown, number]> = [ ['job_name', body.jobName, 200], ['model_name', body.modelName, 200], ['resolution', body.resolution, 40], ['hd_remove_bg_mode', body.hdRemoveBgMode, 80], ['project_id', body.projectId, 200], ['thread_id', body.threadId, 200], ]; for (const [field, value, maxLength] of optionalFields) { const normalized = readOptionalString(value, maxLength); if (normalized) form.set(field, normalized); } const references = readReferenceFiles(body.referenceFiles); references.forEach((reference, index) => { const blob = new Blob([reference.bytes], { type: reference.mimeType }); form.append(index === 0 ? 'reference_file' : 'reference_files', blob, reference.name); }); // Keep this explicit so adding a third generation kind cannot silently send // HD-only fields to a different Meowa endpoint. if (kind === 'pixel') { form.delete('hd_remove_bg_mode'); } return form; } async function forwardJson( res: ServerResponse, url: string, init: RequestInit, operation: string, ): Promise { const response = await proxyAwareFetch(url, init); if (!response.ok) { await sendUpstreamError(res, response, operation); return; } const payload = await readResponsePayload(response); sendJson(res, response.status, payload); } function parseOutputIndex(value: string | null): number | null { if (value === null || value.trim() === '') return null; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0 || parsed > 99) throw new Error('outputIndex must be an integer from 0 to 99'); return parsed; } function responseFileName(response: Response, jobId: string): string { const disposition = response.headers.get('content-disposition') ?? ''; const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]).replace(/[\\/\0]/g, '_'); const match = disposition.match(/filename="?([^";]+)"?/i); if (match?.[1]) return match[1].replace(/[\\/\0]/g, '_'); const mime = response.headers.get('content-type')?.toLowerCase() ?? ''; const suffix = mime.includes('zip') ? '.zip' : mime.includes('jpeg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png'; return `${jobId}${suffix}`; } async function handleDownload( res: ServerResponse, url: URL, kind: MeowaGameAssetKind, apiKey: string, ): Promise { const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200); const outputIndex = parseOutputIndex(url.searchParams.get('outputIndex')); const suffix = outputIndex === null ? `/jobs/${encodeURIComponent(jobId)}/download` : `/jobs/${encodeURIComponent(jobId)}/outputs/${outputIndex}/download`; const response = await proxyAwareFetch(endpoint(kind, suffix), { method: 'GET', headers: upstreamHeaders(apiKey), }); if (!response.ok) { await sendUpstreamError(res, response, 'Meowa 素材下载失败'); return; } const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length > MAX_DOWNLOAD_BYTES) { sendJson(res, 413, { success: false, error: 'Meowa 输出文件超过本地代理大小限制' }); return; } sendJson(res, 200, { success: true, fileName: responseFileName(response, jobId), mimeType: response.headers.get('content-type') || 'application/octet-stream', bytes: bytes.length, dataBase64: bytes.toString('base64'), }); } export async function handleMeowaGameAssetsRoutes( req: IncomingMessage, res: ServerResponse, url: URL, _ctx: HostApiContext, ): Promise { if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'GET') { const credential = await resolveCredential(); sendJson(res, 200, { success: true, configured: Boolean(credential.apiKey), credentialSource: credential.source, provider: 'meowa', apiBaseUrl: MEOWA_GAME_ASSETS_API_BASE_URL, skillVersion: MEOWA_GAME_ASSETS_SKILL_VERSION, supportedKinds: [...SUPPORTED_KINDS], }); return true; } if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'PUT') { try { const body = await parseJsonBody(req); const apiKey = readRequiredString(body.apiKey, 'apiKey', 512); const stored = await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, apiKey); if (!stored) throw new Error('安全存储写入失败'); sendJson(res, 200, { success: true, configured: true, credentialSource: 'secure-store', }); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'DELETE') { const deleted = await deleteApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID); const credential = deleted ? await resolveCredential() : null; sendJson(res, deleted ? 200 : 500, { success: deleted, configured: Boolean(credential?.apiKey), credentialSource: credential?.source ?? 'none', }); return true; } if (url.pathname === '/api/meowa/game-assets/skill-doc' && req.method === 'GET') { try { const remoteUrl = new URL(`${MEOWA_GAME_ASSETS_API_BASE_URL}/api/agent-skills/game-assets/doc`); const task = url.searchParams.get('task'); const topic = url.searchParams.get('topic'); if (task) remoteUrl.searchParams.set('task', task.slice(0, 2_000)); if (topic) remoteUrl.searchParams.set('topic', topic.slice(0, 200)); await forwardJson(res, remoteUrl.toString(), { method: 'GET', headers: upstreamHeaders() }, 'Meowa Skill 文档获取失败'); } catch (error) { sendJson(res, 502, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/template-info' && req.method === 'GET') { try { const kind = readKind(url.searchParams.get('kind')); const apiKey = await requireCredential(res); if (!apiKey) return true; await forwardJson( res, endpoint(kind, '/template-info'), { method: 'GET', headers: upstreamHeaders(apiKey) }, `Meowa ${kind} 模版信息获取失败`, ); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/generate' && req.method === 'POST') { try { const apiKey = await requireCredential(res); if (!apiKey) return true; const body = await parseJsonBody(req); const kind = readKind(body.kind); const form = await buildGenerationForm(kind, body); const response = await proxyAwareFetch(endpoint(kind, ''), { method: 'POST', headers: upstreamHeaders(apiKey), body: form, }); if (!response.ok) { await sendUpstreamError(res, response, `Meowa ${kind} 素材生成提交失败`); return true; } sendJson(res, response.status, await readResponsePayload(response)); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/jobs' && req.method === 'GET') { try { const kind = readKind(url.searchParams.get('kind')); const apiKey = await requireCredential(res); if (!apiKey) return true; const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200); await forwardJson( res, endpoint(kind, `/jobs?id=${encodeURIComponent(jobId)}`), { method: 'GET', headers: upstreamHeaders(apiKey) }, `Meowa ${kind} 任务查询失败`, ); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/download' && req.method === 'GET') { try { const kind = readKind(url.searchParams.get('kind')); const apiKey = await requireCredential(res); if (!apiKey) return true; await handleDownload(res, url, kind, apiKey); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } if (url.pathname === '/api/meowa/game-assets/credits' && req.method === 'GET') { try { const apiKey = await requireCredential(res); if (!apiKey) return true; await forwardJson( res, `${MEOWA_GAME_ASSETS_API_BASE_URL}/api/credits/balance`, { method: 'GET', headers: upstreamHeaders(apiKey) }, 'Meowa 额度查询失败', ); } catch (error) { sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); } return true; } return false; }