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

440 lines
16 KiB
TypeScript

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<string, unknown>;
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<string, string> {
return {
Accept: 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
}
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.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<void> {
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<Awaited<ReturnType<typeof resolveCredential>>> {
return resolveCredential();
}
async function requireCredential(res: ServerResponse): Promise<string | null> {
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<FormData> {
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<void> {
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<void> {
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<boolean> {
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<JsonRecord>(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<JsonRecord>(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;
}