Files
Genarrative/scripts/dev-server/localApiPlugins.ts

1494 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { loadEnv, 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<Record<string, unknown>>((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<string, unknown> {
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 resolveRuntimeEnv(
rootDir: string,
mode: string,
env: Record<string, string>,
) {
return {
...env,
...loadEnv(mode, rootDir, ''),
};
}
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<string, string>;
bodyText?: string;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
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<string, string>;
} = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
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<string, unknown>,
extraHeaders: Record<string, string> = {},
) {
return new Promise<{
statusCode: number;
headers: Record<string, string | string[] | undefined>;
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<string, unknown>,
res: ServerResponse,
) {
return new Promise<void>((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(
rootDir: string,
mode: string,
env: Record<string, string>,
): Plugin {
const handler = async (req: IncomingMessage, res: ServerResponse) => {
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
const upstreamBaseUrl = normalizeUpstreamBaseUrl(
runtimeEnv.VITE_LLM_BASE_URL ||
runtimeEnv.LLM_BASE_URL ||
'https://ark.cn-beijing.volces.com/api/v3',
);
const apiKey =
runtimeEnv.LLM_API_KEY ||
runtimeEnv.ARK_API_KEY ||
runtimeEnv.VITE_LLM_API_KEY ||
'';
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<string, unknown>;
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<string, unknown>)
: {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
async function writeJsonObjectFile(
filePath: string,
payload: Record<string, unknown>,
) {
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<string, unknown> | 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<string, unknown>) {
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<string, unknown>;
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,
mode: string,
env: Record<string, string>,
): Plugin {
const handler = async (req: IncomingMessage, res: ServerResponse) => {
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
const baseUrl = normalizeDashScopeBaseUrl(
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
);
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
const defaultModel =
runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
const taskTimeoutMs = Number(
runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS ||
runtimeEnv.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS ||
DASHSCOPE_TASK_TIMEOUT_MS,
);
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<string, unknown>;
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<string, unknown> | 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<string[]> {
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<string, unknown>;
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<string, unknown>) }
: {};
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<string, unknown>;
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<string, unknown>)
: 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<string, Record<string, unknown>> = {};
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<string, unknown>) }
: {};
const existingAnimationMap =
nextOverride.animationMap &&
typeof nextOverride.animationMap === 'object' &&
!Array.isArray(nextOverride.animationMap)
? (nextOverride.animationMap as Record<string, unknown>)
: {};
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,
mode: string,
env: Record<string, string>,
): Plugin[] {
return [
createLlmProxyPlugin(rootDir, mode, env),
createCustomWorldSceneImagePlugin(rootDir, mode, env),
createItemCatalogPlugin(rootDir),
createItemOverridesPlugin(rootDir),
createNpcVisualOverridePlugin(rootDir),
createNpcLayoutConfigPlugin(rootDir),
createCharacterOverridesPlugin(rootDir),
createMonsterOverridesPlugin(rootDir),
createSceneOverridesPlugin(rootDir),
createSceneNpcOverridesPlugin(rootDir),
createStateFunctionOverridesPlugin(rootDir),
createCharacterVisualPublishPlugin(rootDir),
createCharacterAnimationPublishPlugin(rootDir),
];
}