import { webcrypto } from 'node:crypto'; import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import http, { type IncomingMessage, type RequestOptions, type ServerResponse, } from 'node:http'; import https from 'node:https'; import path from 'node:path'; import type { Plugin } from 'vite'; const LLM_PROXY_PATH = '/api/llm/chat/completions'; const ITEM_CATALOG_PATH = '/api/item-catalog'; const ITEM_OVERRIDES_PATH = '/api/item-overrides'; const NPC_VISUAL_OVERRIDES_PATH = '/api/npc-visual-overrides'; const NPC_LAYOUT_CONFIG_PATH = '/api/npc-layout-config'; const CHARACTER_OVERRIDES_PATH = '/api/character-overrides'; const MONSTER_OVERRIDES_PATH = '/api/monster-overrides'; const SCENE_OVERRIDES_PATH = '/api/scene-overrides'; const SCENE_NPC_OVERRIDES_PATH = '/api/scene-npc-overrides'; const STATE_FUNCTION_OVERRIDES_PATH = '/api/state-function-overrides'; const CHARACTER_VISUAL_PUBLISH_PATH = '/api/character-visual/publish'; const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/animation/publish'; const CUSTOM_WORLD_SCENE_IMAGE_PATH = '/api/custom-world/scene-image'; const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.2-t2i-flash'; const DASHSCOPE_TASK_POLL_INTERVAL_MS = 2000; const DASHSCOPE_TASK_TIMEOUT_MS = 150000; if ( !globalThis.crypto || typeof globalThis.crypto.getRandomValues !== 'function' ) { Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true, }); } function readJsonBody(req: IncomingMessage) { return new Promise>((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); req.on('end', () => { try { const raw = Buffer.concat(chunks).toString('utf8') || '{}'; resolve(JSON.parse(raw)); } catch (error) { reject(error); } }); req.on('error', reject); }); } function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } function isRecordValue(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function hashText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash >>> 0; } function buildAssetPathSegment(value: string, fallback: string) { const sanitized = sanitizePathSegment(value); const suffix = hashText(value || fallback) .toString(36) .slice(0, 6); return `${sanitized || fallback}-${suffix}`; } function normalizeDashScopeBaseUrl(value: string) { return value.replace(/\/$/u, ''); } function extractApiErrorMessage(responseText: string, fallbackMessage: string) { if (!responseText.trim()) { return fallbackMessage; } try { const parsed = JSON.parse(responseText) as { code?: string; message?: string; error?: { message?: string }; }; if ( typeof parsed.error?.message === 'string' && parsed.error.message.trim() ) { return parsed.error.message; } if (typeof parsed.message === 'string' && parsed.message.trim()) { return parsed.message; } if (typeof parsed.code === 'string' && parsed.code.trim()) { return `${fallbackMessage} (${parsed.code})`; } } catch { // Fall through to the raw response text below. } return responseText; } function requestTextResponse( urlString: string, options: { method?: string; headers?: Record; bodyText?: string; } = {}, ) { return new Promise<{ statusCode: number; headers: Record; bodyText: string; }>((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const payload = options.bodyText; const requestOptions: RequestOptions = { protocol: url.protocol, hostname: url.hostname, port: url.port ? Number(url.port) : undefined, path: `${url.pathname}${url.search}`, method: options.method ?? 'GET', headers: { ...(options.headers ?? {}), ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), }, }; const request = transport.request(requestOptions, (upstreamRes) => { const chunks: Buffer[] = []; upstreamRes.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); upstreamRes.on('end', () => { resolve({ statusCode: upstreamRes.statusCode ?? 502, headers: upstreamRes.headers, bodyText: Buffer.concat(chunks).toString('utf8'), }); }); upstreamRes.on('error', reject); }); request.on('error', reject); if (payload) { request.write(payload); } request.end(); }); } function requestBinaryResponse( urlString: string, options: { method?: string; headers?: Record; } = {}, ) { return new Promise<{ statusCode: number; headers: Record; body: Buffer; }>((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const requestOptions: RequestOptions = { protocol: url.protocol, hostname: url.hostname, port: url.port ? Number(url.port) : undefined, path: `${url.pathname}${url.search}`, method: options.method ?? 'GET', headers: options.headers ?? {}, }; const request = transport.request(requestOptions, (upstreamRes) => { const chunks: Buffer[] = []; upstreamRes.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); upstreamRes.on('end', () => { resolve({ statusCode: upstreamRes.statusCode ?? 502, headers: upstreamRes.headers, body: Buffer.concat(chunks), }); }); upstreamRes.on('error', reject); }); request.on('error', reject); request.end(); }); } function normalizeUpstreamBaseUrl(value: string) { return value.replace(/\/chat\/completions\/?$/u, '').replace(/\/$/u, ''); } function proxyJsonRequest( urlString: string, apiKey: string, body: Record, extraHeaders: Record = {}, ) { return new Promise<{ statusCode: number; headers: Record; bodyText: string; }>((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const payload = JSON.stringify(body); const options: RequestOptions = { protocol: url.protocol, hostname: url.hostname, port: url.port ? Number(url.port) : undefined, path: `${url.pathname}${url.search}`, method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...extraHeaders, }, }; const upstreamReq = transport.request(options, (upstreamRes) => { const chunks: Buffer[] = []; upstreamRes.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); upstreamRes.on('end', () => { resolve({ statusCode: upstreamRes.statusCode ?? 502, headers: upstreamRes.headers, bodyText: Buffer.concat(chunks).toString('utf8'), }); }); }); upstreamReq.on('error', reject); upstreamReq.write(payload); upstreamReq.end(); }); } function proxyStreamingRequest( urlString: string, apiKey: string, body: Record, res: ServerResponse, ) { return new Promise((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const payload = JSON.stringify(body); const options: RequestOptions = { protocol: url.protocol, hostname: url.hostname, port: url.port ? Number(url.port) : undefined, path: `${url.pathname}${url.search}`, method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), }, }; const upstreamReq = transport.request(options, (upstreamRes) => { res.statusCode = upstreamRes.statusCode ?? 502; res.setHeader( 'Content-Type', String( upstreamRes.headers['content-type'] || 'text/event-stream; charset=utf-8', ), ); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); upstreamRes.on('data', (chunk) => { res.write(chunk); }); upstreamRes.on('end', () => { res.end(); resolve(); }); upstreamRes.on('error', (error) => { if (!res.writableEnded) { res.end(); } reject(error); }); }); upstreamReq.on('error', reject); res.on('close', () => { upstreamReq.destroy(); }); upstreamReq.write(payload); upstreamReq.end(); }); } function createLlmProxyPlugin(env: Record): Plugin { const upstreamBaseUrl = normalizeUpstreamBaseUrl( env.VITE_LLM_BASE_URL || env.LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3', ); const apiKey = env.LLM_API_KEY || env.ARK_API_KEY || env.VITE_LLM_API_KEY || ''; const handler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } if (!apiKey) { sendJson(res, 500, { error: { message: 'Missing LLM API key on server' }, }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } try { if (body.stream === true) { await proxyStreamingRequest( `${upstreamBaseUrl}/chat/completions`, apiKey, body, res, ); return; } const upstreamResponse = await proxyJsonRequest( `${upstreamBaseUrl}/chat/completions`, apiKey, body, ); res.statusCode = upstreamResponse.statusCode; res.setHeader( 'Content-Type', String( upstreamResponse.headers['content-type'] || 'application/json; charset=utf-8', ), ); res.end(upstreamResponse.bodyText); } catch (error) { sendJson(res, 502, { error: { message: error instanceof Error ? error.message : 'LLM proxy request failed', }, }); } }; return { name: 'local-llm-proxy', configureServer(server) { server.middlewares.use(LLM_PROXY_PATH, handler); }, configurePreviewServer(server) { server.middlewares.use(LLM_PROXY_PATH, handler); }, }; } async function handleJsonFileRead( filePath: string, res: ServerResponse, readErrorMessage: string, ) { try { const content = await readFile(filePath, 'utf8'); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(content); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : readErrorMessage, }, }); } } async function readJsonObjectFile(filePath: string) { try { const content = await readFile(filePath, 'utf8'); const parsed = JSON.parse(content); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return {}; } throw error; } } async function writeJsonObjectFile( filePath: string, payload: Record, ) { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); } function sanitizePathSegment(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9-_]+/gu, '-') .replace(/-+/gu, '-') .replace(/^-|-$/gu, ''); return normalized || 'asset'; } function createTimestampId(prefix: string) { return `${prefix}-${Date.now()}`; } function isStringArray(value: unknown): value is string[] { return ( Array.isArray(value) && value.every((item) => typeof item === 'string' && item.trim().length > 0) ); } function decodeDataUrl(dataUrl: string) { const matched = /^data:(image\/png|image\/jpeg);base64,(.+)$/u.exec(dataUrl); if (!matched) { throw new Error( 'Unsupported image payload. Expected PNG or JPEG data URL.', ); } const mimeType = matched[1]; const base64Payload = matched[2]; return { buffer: Buffer.from(base64Payload, 'base64'), extension: mimeType === 'image/jpeg' ? 'jpg' : 'png', }; } function resolveImageExtension( contentTypeHeader: string | string[] | undefined, sourceUrl: string, ) { const contentType = Array.isArray(contentTypeHeader) ? (contentTypeHeader[0] ?? '') : (contentTypeHeader ?? ''); if (/image\/jpeg/u.test(contentType)) { return 'jpg'; } if (/image\/png/u.test(contentType)) { return 'png'; } try { const extension = path.posix .extname(new URL(sourceUrl).pathname) .toLowerCase(); if (extension === '.jpg' || extension === '.jpeg') { return 'jpg'; } if (extension === '.png') { return 'png'; } } catch { // Ignore malformed URLs and fall back to png below. } return 'png'; } async function waitForDashScopeTask( baseUrl: string, apiKey: string, taskId: string, timeoutMs = DASHSCOPE_TASK_TIMEOUT_MS, ) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, }, }); if (response.statusCode < 200 || response.statusCode >= 300) { throw new Error( extractApiErrorMessage( response.bodyText, `查询场景图片生成任务失败(${response.statusCode})。`, ), ); } let parsed: Record | null = null; try { const candidate = JSON.parse(response.bodyText); parsed = isRecordValue(candidate) ? candidate : null; } catch { parsed = null; } if (!parsed) { throw new Error('场景图片生成任务返回了无法解析的结果。'); } const output = isRecordValue(parsed.output) ? parsed.output : null; const taskStatus = output && typeof output.task_status === 'string' ? output.task_status : ''; if (taskStatus === 'SUCCEEDED') { return parsed; } if (taskStatus === 'FAILED') { throw new Error( extractApiErrorMessage(response.bodyText, '场景图片生成任务失败。'), ); } if (taskStatus === 'UNKNOWN') { throw new Error('场景图片生成任务状态未知,请重新发起生成。'); } await sleep(DASHSCOPE_TASK_POLL_INTERVAL_MS); } throw new Error('场景图片生成超时,请稍后重试。'); } function getDashScopeImageUrl(taskResponse: Record) { const output = isRecordValue(taskResponse.output) ? taskResponse.output : null; const results = output && Array.isArray(output.results) ? output.results : []; for (const entry of results) { if (!isRecordValue(entry)) { continue; } if (typeof entry.url === 'string' && entry.url.trim()) { return { url: entry.url.trim(), actualPrompt: typeof entry.actual_prompt === 'string' && entry.actual_prompt.trim() ? entry.actual_prompt.trim() : undefined, }; } } throw new Error('场景图片生成成功,但没有返回可下载的图片地址。'); } type PublishedVisualManifest = { id: string; characterId: string; sourceMode: string; promptText?: string; masterImagePath: string; previewImagePaths: string[]; width: number; height: number; facing: 'right'; locked: boolean; }; type PublishedAnimationManifest = { id: string; animationSetId: string; characterId: string; visualAssetId: string; action: string; frameCount: number; fps: number; loop: boolean; frameWidth: number; frameHeight: number; framePaths: string[]; }; function createJsonFileEditorPlugin({ name, routePath, filePath, invalidPayloadMessage, readErrorMessage, saveErrorMessage, }: { name: string; routePath: string; filePath: string; invalidPayloadMessage: string; readErrorMessage: string; saveErrorMessage: string; }): Plugin { const readOnlyHandler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'GET') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } await handleJsonFileRead(filePath, res, readErrorMessage); }; const readWriteHandler = async ( req: IncomingMessage, res: ServerResponse, ) => { if (req.method === 'GET') { await handleJsonFileRead(filePath, res, readErrorMessage); return; } if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } if (!body || typeof body !== 'object' || Array.isArray(body)) { sendJson(res, 400, { error: { message: invalidPayloadMessage } }); return; } try { await writeFile(filePath, JSON.stringify(body, null, 2) + '\n', 'utf8'); sendJson(res, 200, { ok: true }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : saveErrorMessage, }, }); } }; return { name, configureServer(server) { server.middlewares.use(routePath, readWriteHandler); }, configurePreviewServer(server) { server.middlewares.use(routePath, readOnlyHandler); }, }; } function createNpcVisualOverridePlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'npc-visual-overrides', routePath: NPC_VISUAL_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/npcVisualOverrides.json'), invalidPayloadMessage: 'Override payload must be an object map', readErrorMessage: 'Failed to read override file', saveErrorMessage: 'Failed to save override file', }); } function createNpcLayoutConfigPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'npc-layout-config', routePath: NPC_LAYOUT_CONFIG_PATH, filePath: path.resolve(rootDir, 'src/data/npcLayoutConfig.json'), invalidPayloadMessage: 'Layout payload must be an object', readErrorMessage: 'Failed to read layout file', saveErrorMessage: 'Failed to save layout file', }); } function createCharacterOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'character-overrides', routePath: CHARACTER_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/characterOverrides.json'), invalidPayloadMessage: 'Character override payload must be an object map', readErrorMessage: 'Failed to read character override file', saveErrorMessage: 'Failed to save character override file', }); } function createMonsterOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'monster-overrides', routePath: MONSTER_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/monsterOverrides.json'), invalidPayloadMessage: 'Monster override payload must be an object map', readErrorMessage: 'Failed to read monster override file', saveErrorMessage: 'Failed to save monster override file', }); } function createSceneOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'scene-overrides', routePath: SCENE_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/sceneOverrides.json'), invalidPayloadMessage: 'Scene override payload must be an object map', readErrorMessage: 'Failed to read scene override file', saveErrorMessage: 'Failed to save scene override file', }); } function createSceneNpcOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'scene-npc-overrides', routePath: SCENE_NPC_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/sceneNpcOverrides.json'), invalidPayloadMessage: 'Scene NPC override payload must be an object map', readErrorMessage: 'Failed to read scene NPC override file', saveErrorMessage: 'Failed to save scene NPC override file', }); } function createStateFunctionOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'state-function-overrides', routePath: STATE_FUNCTION_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/stateFunctionOverrides.json'), invalidPayloadMessage: 'State function override payload must be an object map', readErrorMessage: 'Failed to read state function override file', saveErrorMessage: 'Failed to save state function override file', }); } function createCustomWorldSceneImagePlugin( rootDir: string, env: Record, ): Plugin { const baseUrl = normalizeDashScopeBaseUrl( env.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, ); const apiKey = env.DASHSCOPE_API_KEY || ''; const defaultModel = env.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL; const taskTimeoutMs = Number( env.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS || DASHSCOPE_TASK_TIMEOUT_MS, ); const handler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } if (!apiKey) { sendJson(res, 500, { error: { message: 'Missing DASHSCOPE_API_KEY on server' }, }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; const negativePrompt = typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; const size = typeof body.size === 'string' && body.size.trim() ? body.size.trim() : '1280*720'; const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : defaultModel; const worldName = typeof body.worldName === 'string' ? body.worldName.trim() : ''; const profileId = typeof body.profileId === 'string' ? body.profileId.trim() : ''; const landmarkName = typeof body.landmarkName === 'string' ? body.landmarkName.trim() : ''; const landmarkId = typeof body.landmarkId === 'string' ? body.landmarkId.trim() : ''; if (!prompt) { sendJson(res, 400, { error: { message: 'prompt is required.' } }); return; } if (!landmarkName && !landmarkId) { sendJson(res, 400, { error: { message: 'landmarkName or landmarkId is required.' }, }); return; } try { const createTaskResponse = await proxyJsonRequest( `${baseUrl}/services/aigc/text2image/image-synthesis`, apiKey, { model, input: { prompt, ...(negativePrompt ? { negative_prompt: negativePrompt } : {}), }, parameters: { n: 1, size, prompt_extend: true, watermark: false, }, }, { 'X-DashScope-Async': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建场景图片生成任务失败。', ), }, }); return; } let taskPayload: Record | null = null; try { const candidate = JSON.parse(createTaskResponse.bodyText); taskPayload = isRecordValue(candidate) ? candidate : null; } catch { taskPayload = null; } const output = taskPayload && isRecordValue(taskPayload.output) ? taskPayload.output : null; const taskId = output && typeof output.task_id === 'string' ? output.task_id.trim() : ''; if (!taskId) { throw new Error('场景图片生成任务未返回 task_id。'); } const taskResponse = await waitForDashScopeTask( baseUrl, apiKey, taskId, Number.isFinite(taskTimeoutMs) && taskTimeoutMs > 0 ? taskTimeoutMs : DASHSCOPE_TASK_TIMEOUT_MS, ); const imageResult = getDashScopeImageUrl(taskResponse); const imageResponse = await requestBinaryResponse(imageResult.url); if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { throw new Error(`下载生成图片失败(${imageResponse.statusCode})。`); } const assetId = createTimestampId('custom-scene'); const worldSegment = buildAssetPathSegment( profileId || worldName || 'custom-world', 'world', ); const landmarkSegment = buildAssetPathSegment( landmarkId || landmarkName || 'landmark', 'landmark', ); const relativeDir = path.posix.join( 'generated-custom-world-scenes', worldSegment, landmarkSegment, assetId, ); const outputDir = path.resolve( rootDir, 'public', ...relativeDir.split('/'), ); const extension = resolveImageExtension( imageResponse.headers['content-type'], imageResult.url, ); const fileName = `scene.${extension}`; const imageSrc = `/${path.posix.join(relativeDir, fileName)}`; await mkdir(outputDir, { recursive: true }); await writeFile(path.join(outputDir, fileName), imageResponse.body); await writeFile( path.join(outputDir, 'manifest.json'), JSON.stringify( { assetId, taskId, model, size, prompt, negativePrompt, actualPrompt: imageResult.actualPrompt, remoteUrl: imageResult.url, imageSrc, worldName, landmarkName, createdAt: new Date().toISOString(), }, null, 2, ) + '\n', 'utf8', ); sendJson(res, 200, { ok: true, imageSrc, assetId, taskId, model, size, prompt, actualPrompt: imageResult.actualPrompt, }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : '场景图片生成失败。', }, }); } }; return { name: 'custom-world-scene-image', configureServer(server) { server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler); }, configurePreviewServer(server) { server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler); }, }; } async function collectPngAssetPaths( rootDir: string, relativeDir = 'Icons', ): Promise { const entries = await readdir(rootDir, { withFileTypes: true }); const collected: string[] = []; for (const entry of entries) { const absolutePath = path.join(rootDir, entry.name); const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/'); if (entry.isDirectory()) { collected.push( ...(await collectPngAssetPaths(absolutePath, relativePath)), ); continue; } if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) { collected.push(relativePath); } } return collected.sort((left, right) => left.localeCompare(right)); } function createItemCatalogPlugin(rootDir: string): Plugin { let cachedAssetPaths: string[] | null = null; const handler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'GET') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } try { if (!cachedAssetPaths) { cachedAssetPaths = await collectPngAssetPaths( path.resolve(rootDir, 'public/Icons'), ); } sendJson(res, 200, { assetPaths: cachedAssetPaths, }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : 'Failed to read item catalog assets', }, }); } }; return { name: 'item-catalog', configureServer(server) { server.middlewares.use(ITEM_CATALOG_PATH, handler); }, configurePreviewServer(server) { server.middlewares.use(ITEM_CATALOG_PATH, handler); }, }; } function createItemOverridesPlugin(rootDir: string): Plugin { return createJsonFileEditorPlugin({ name: 'item-overrides', routePath: ITEM_OVERRIDES_PATH, filePath: path.resolve(rootDir, 'src/data/itemOverrides.json'), invalidPayloadMessage: 'Item override payload must be an object map', readErrorMessage: 'Failed to read item override file', saveErrorMessage: 'Failed to save item override file', }); } function createCharacterVisualPublishPlugin(rootDir: string): Plugin { const characterOverridesFilePath = path.resolve( rootDir, 'src/data/characterOverrides.json', ); const handler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const characterId = typeof body.characterId === 'string' ? body.characterId.trim() : ''; const sourceMode = typeof body.sourceMode === 'string' ? body.sourceMode.trim() : 'upload'; const promptText = typeof body.promptText === 'string' && body.promptText.trim() ? body.promptText.trim() : undefined; const selectedPreviewDataUrl = typeof body.selectedPreviewDataUrl === 'string' ? body.selectedPreviewDataUrl : ''; const previewDataUrls = isStringArray(body.previewDataUrls) ? body.previewDataUrls : []; const width = typeof body.width === 'number' && Number.isFinite(body.width) ? body.width : 1024; const height = typeof body.height === 'number' && Number.isFinite(body.height) ? body.height : 1536; if (!characterId) { sendJson(res, 400, { error: { message: 'characterId is required.' } }); return; } if (!selectedPreviewDataUrl) { sendJson(res, 400, { error: { message: 'selectedPreviewDataUrl is required.' }, }); return; } try { const assetId = createTimestampId('visual'); const visualDir = path.resolve( rootDir, 'public/generated-characters', sanitizePathSegment(characterId), 'visual', assetId, ); await mkdir(visualDir, { recursive: true }); const masterPayload = decodeDataUrl(selectedPreviewDataUrl); const masterFileName = `master.${masterPayload.extension}`; const masterFilePath = path.join(visualDir, masterFileName); await writeFile(masterFilePath, masterPayload.buffer); const previewImagePaths: string[] = []; for (let index = 0; index < previewDataUrls.length; index += 1) { const previewPayload = decodeDataUrl(previewDataUrls[index] ?? ''); const previewFileName = `preview-${index + 1}.${previewPayload.extension}`; await writeFile( path.join(visualDir, previewFileName), previewPayload.buffer, ); previewImagePaths.push( `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${previewFileName}`, ); } const masterImagePath = `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${masterFileName}`; const manifest: PublishedVisualManifest = { id: assetId, characterId, sourceMode, promptText, masterImagePath, previewImagePaths, width, height, facing: 'right', locked: true, }; await writeFile( path.join(visualDir, 'visual-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8', ); const overrideMap = await readJsonObjectFile(characterOverridesFilePath); const existingOverride = overrideMap[characterId]; const nextOverride = existingOverride && typeof existingOverride === 'object' && !Array.isArray(existingOverride) ? { ...(existingOverride as Record) } : {}; nextOverride.generatedVisualAssetId = assetId; nextOverride.portrait = masterImagePath; overrideMap[characterId] = nextOverride; await writeJsonObjectFile(characterOverridesFilePath, overrideMap); sendJson(res, 200, { ok: true, assetId, portraitPath: masterImagePath, overrideMap, saveMessage: '主形象已发布到 public/generated-characters,并更新角色覆盖。', }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : 'Failed to publish character visual asset', }, }); } }; return { name: 'character-visual-publish', configureServer(server) { server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler); }, configurePreviewServer(server) { server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler); }, }; } function createCharacterAnimationPublishPlugin(rootDir: string): Plugin { const characterOverridesFilePath = path.resolve( rootDir, 'src/data/characterOverrides.json', ); const handler = async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const characterId = typeof body.characterId === 'string' ? body.characterId.trim() : ''; const visualAssetId = typeof body.visualAssetId === 'string' ? body.visualAssetId.trim() : ''; const animations = body.animations && typeof body.animations === 'object' && !Array.isArray(body.animations) ? (body.animations as Record) : null; if (!characterId) { sendJson(res, 400, { error: { message: 'characterId is required.' } }); return; } if (!visualAssetId) { sendJson(res, 400, { error: { message: 'visualAssetId is required.' } }); return; } if (!animations || Object.keys(animations).length === 0) { sendJson(res, 400, { error: { message: 'animations is required.' } }); return; } try { const animationSetId = createTimestampId('animation-set'); const baseAnimationDir = path.resolve( rootDir, 'public/generated-animations', sanitizePathSegment(characterId), animationSetId, ); await mkdir(baseAnimationDir, { recursive: true }); const actionManifests: PublishedAnimationManifest[] = []; const nextAnimationMap: Record> = {}; for (const [action, rawAnimation] of Object.entries(animations)) { if ( !rawAnimation || typeof rawAnimation !== 'object' || Array.isArray(rawAnimation) ) { continue; } const typedAnimation = rawAnimation as { framesDataUrls?: unknown; fps?: unknown; loop?: unknown; frameWidth?: unknown; frameHeight?: unknown; }; const framesDataUrls = isStringArray(typedAnimation.framesDataUrls) ? typedAnimation.framesDataUrls : []; if (framesDataUrls.length === 0) { continue; } const fps = typeof typedAnimation.fps === 'number' && Number.isFinite(typedAnimation.fps) ? typedAnimation.fps : 8; const loop = typedAnimation.loop === true; const frameWidth = typeof typedAnimation.frameWidth === 'number' && Number.isFinite(typedAnimation.frameWidth) ? typedAnimation.frameWidth : 192; const frameHeight = typeof typedAnimation.frameHeight === 'number' && Number.isFinite(typedAnimation.frameHeight) ? typedAnimation.frameHeight : 256; const actionKey = sanitizePathSegment(action); const actionDir = path.join(baseAnimationDir, actionKey); await mkdir(actionDir, { recursive: true }); const framePaths: string[] = []; for (let index = 0; index < framesDataUrls.length; index += 1) { const framePayload = decodeDataUrl(framesDataUrls[index] ?? ''); const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; await writeFile( path.join(actionDir, frameFileName), framePayload.buffer, ); framePaths.push( `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, ); } const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`; const manifest: PublishedAnimationManifest = { id: `${animationSetId}-${actionKey}`, animationSetId, characterId, visualAssetId, action, frameCount: framePaths.length, fps, loop, frameWidth, frameHeight, framePaths, }; await writeFile( path.join(actionDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8', ); actionManifests.push(manifest); nextAnimationMap[action] = { folder: action, prefix: 'frame', frames: framePaths.length, startFrame: 1, extension: 'png', basePath, }; } await writeFile( path.join(baseAnimationDir, 'manifest.json'), JSON.stringify( { animationSetId, characterId, visualAssetId, actions: actionManifests, }, null, 2, ) + '\n', 'utf8', ); const overrideMap = await readJsonObjectFile(characterOverridesFilePath); const existingOverride = overrideMap[characterId]; const nextOverride = existingOverride && typeof existingOverride === 'object' && !Array.isArray(existingOverride) ? { ...(existingOverride as Record) } : {}; const existingAnimationMap = nextOverride.animationMap && typeof nextOverride.animationMap === 'object' && !Array.isArray(nextOverride.animationMap) ? (nextOverride.animationMap as Record) : {}; nextOverride.generatedAnimationSetId = animationSetId; nextOverride.generatedVisualAssetId = visualAssetId; nextOverride.animationMap = { ...existingAnimationMap, ...nextAnimationMap, }; overrideMap[characterId] = nextOverride; await writeJsonObjectFile(characterOverridesFilePath, overrideMap); sendJson(res, 200, { ok: true, animationSetId, overrideMap, saveMessage: '基础动作资源已发布到 public/generated-animations,并更新角色覆盖。', }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : 'Failed to publish character animation asset', }, }); } }; return { name: 'character-animation-publish', configureServer(server) { server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler); }, configurePreviewServer(server) { server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler); }, }; } export function createLocalApiPlugins( rootDir: string, env: Record, ): Plugin[] { return [ createLlmProxyPlugin(env), createCustomWorldSceneImagePlugin(rootDir, env), createItemCatalogPlugin(rootDir), createItemOverridesPlugin(rootDir), createNpcVisualOverridePlugin(rootDir), createNpcLayoutConfigPlugin(rootDir), createCharacterOverridesPlugin(rootDir), createMonsterOverridesPlugin(rootDir), createSceneOverridesPlugin(rootDir), createSceneNpcOverridesPlugin(rootDir), createStateFunctionOverridesPlugin(rootDir), createCharacterVisualPublishPlugin(rootDir), createCharacterAnimationPublishPlugin(rootDir), ]; }