11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import fs from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
@@ -209,6 +209,12 @@ test('character visual generation converts public reference images into data url
};
};
const content = createPayload.input.messages[0]?.content ?? [];
assert.match(content[0]?.text ?? '', //u);
assert.match(content[0]?.text ?? '', /绿绿/u);
assert.match(content[0]?.text ?? '', /1:1 |1:1 /u);
assert.match(content[0]?.text ?? '', /2 3 /u);
assert.match(content[0]?.text ?? '', //u);
assert.doesNotMatch(content[0]?.text ?? '', //u);
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
@@ -218,6 +224,186 @@ test('character visual generation converts public reference images into data url
);
});
test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'));
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
const response = await fetch(`${assetBaseUrl}/api/assets/character-prompts/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roleKind: 'story',
characterName: '港口向导',
roleTitle: '潮灯守望者',
roleLabel: '旧港引路人',
description: '熟悉黑潮与暗礁,身上带着潮雾气息。',
backstory: '常年守在废弃灯塔附近,为误入者指路。',
personality: '冷静克制,但会在关键时刻出手。',
motivation: '想守住最后一段仍能靠岸的航道。',
combatStyle: '短刀与信号灯配合,动作利落。',
tags: ['潮雾', '守望', '引路'],
characterBriefText: '角色名称:港口向导\n角色头衔潮灯守望者\n世界身份旧港引路人',
}),
});
assert.equal(response.status, 200);
const payload = (await response.json()) as {
source: string;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
assert.equal(payload.source, 'fallback');
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, /绿绿/u);
assert.match(payload.visualPromptText, /2 3 /u);
assert.match(payload.animationPromptText, //u);
assert.match(payload.scenePromptText, //u);
});
});
test('character workflow cache persists unsaved studio state', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'));
await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => {
const saveResponse = await fetch(`${assetBaseUrl}/api/assets/character-workflow-cache`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
visualPromptText: '潮雾港守望者',
animationPromptText: '短刀起手,收招利落',
visualDrafts: [
{
id: 'draft-1',
label: '候选 1',
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
width: 1024,
height: 1536,
},
],
selectedVisualDraftId: 'draft-1',
selectedAnimation: 'idle',
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
generatedVisualAssetId: 'visual-1',
generatedAnimationSetId: 'animation-set-1',
animationMap: {
idle: {
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
},
},
}),
});
assert.equal(saveResponse.status, 200);
const readResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`,
);
assert.equal(readResponse.status, 200);
const payload = (await readResponse.json()) as {
cache: {
characterId: string;
selectedVisualDraftId: string;
generatedVisualAssetId?: string;
animationMap?: Record<string, { basePath?: string }>;
} | null;
};
assert.equal(payload.cache?.characterId, 'harbor-guide');
assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1');
assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1');
assert.equal(
payload.cache?.animationMap?.idle?.basePath,
'/generated-animations/harbor-guide/animation-set-1/idle',
);
});
});
test('character workflow cache skips rewriting unchanged payloads', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-stable-'),
);
await withAssetRouteServer(
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
async (assetBaseUrl) => {
const payload = {
characterId: 'harbor-guide',
visualPromptText: '潮雾港守望者',
animationPromptText: '短刀起手,收招利落',
visualDrafts: [
{
id: 'draft-1',
label: '候选 1',
imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png',
width: 1024,
height: 1024,
},
],
selectedVisualDraftId: 'draft-1',
selectedAnimation: 'idle',
imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png',
generatedVisualAssetId: 'visual-1',
generatedAnimationSetId: 'animation-set-1',
animationMap: {
idle: {
basePath: '/generated-animations/harbor-guide/animation-set-1/idle',
},
},
};
const firstSaveResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);
assert.equal(firstSaveResponse.status, 200);
const firstSavePayload = (await firstSaveResponse.json()) as {
cache: {
updatedAt: string;
};
};
const secondSaveResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);
assert.equal(secondSaveResponse.status, 200);
const secondSavePayload = (await secondSaveResponse.json()) as {
saveMessage: string;
cache: {
updatedAt: string;
};
};
assert.equal(secondSavePayload.saveMessage, '角色形象生成缓存无变化。');
assert.equal(
secondSavePayload.cache.updatedAt,
firstSavePayload.cache.updatedAt,
);
},
);
});
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
const publicDir = path.join(tempRoot, 'public');

View File

@@ -7,7 +7,7 @@ import http, {
import https from 'node:https';
import path from 'node:path';
import { Router, type NextFunction, type Request, type Response } from 'express';
import { type NextFunction, type Request, type Response,Router } from 'express';
import {
buildMasterPrompt,
@@ -15,8 +15,12 @@ import {
getActionTemplateById,
} from '../../../../packages/shared/src/assets/qwenSprite.js';
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { AppConfig } from '../../config.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = '/api/assets/character-prompts/generate';
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/';
@@ -34,6 +38,24 @@ const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000;
const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000;
const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000;
const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
输出格式必须严格为:
{
"visualPromptText": "角色主图提示词",
"animationPromptText": "角色动作提示词",
"scenePromptText": "角色关联场景提示词"
}
硬性约束:
- 所有字段都必须是自然中文。
- visualPromptText 用于角色主图候选必须是角色标准设定图而不是场景海报突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
const BUILT_IN_MOTION_TEMPLATES = [
{
@@ -85,6 +107,83 @@ type DecodedMediaPayload = {
extension: string;
};
type CharacterPromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
type CharacterAssetWorkflowCacheRecord = {
characterId: string;
visualPromptText: string;
animationPromptText: string;
visualDrafts: Array<{
id: string;
label: string;
imageSrc: string;
width: number;
height: number;
}>;
selectedVisualDraftId: string;
selectedAnimation: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
updatedAt: string;
};
function serializeWorkflowCacheComparableValue(
value: CharacterAssetWorkflowCacheRecord | Record<string, unknown>,
) {
const visualDrafts = Array.isArray(value.visualDrafts)
? value.visualDrafts
.map((item) => {
if (!isRecordValue(item)) {
return null;
}
return {
id: typeof item.id === 'string' ? item.id : '',
label: typeof item.label === 'string' ? item.label : '',
imageSrc: typeof item.imageSrc === 'string' ? item.imageSrc : '',
width: typeof item.width === 'number' ? item.width : 0,
height: typeof item.height === 'number' ? item.height : 0,
};
})
.filter(Boolean)
: [];
return JSON.stringify({
characterId: typeof value.characterId === 'string' ? value.characterId : '',
visualPromptText:
typeof value.visualPromptText === 'string' ? value.visualPromptText : '',
animationPromptText:
typeof value.animationPromptText === 'string'
? value.animationPromptText
: '',
visualDrafts,
selectedVisualDraftId:
typeof value.selectedVisualDraftId === 'string'
? value.selectedVisualDraftId
: '',
selectedAnimation:
typeof value.selectedAnimation === 'string' ? value.selectedAnimation : '',
imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '',
generatedVisualAssetId:
typeof value.generatedVisualAssetId === 'string'
? value.generatedVisualAssetId
: '',
generatedAnimationSetId:
typeof value.generatedAnimationSetId === 'string'
? value.generatedAnimationSetId
: '',
animationMap: isRecordValue(value.animationMap) ? value.animationMap : null,
});
}
function readJsonBody(req: IncomingMessage & { body?: unknown }) {
const parsedBody = req.body;
if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) {
@@ -154,6 +253,144 @@ function sanitizePathSegment(value: string) {
return normalized || 'asset';
}
function clampPromptSeedText(value: unknown, maxLength: number) {
if (typeof value !== 'string') {
return '';
}
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
function buildFallbackCharacterPromptBundle(params: {
characterName: string;
roleKind: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
const roleAnchor =
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
const characterAnchor = params.characterName || '该角色';
const descriptionAnchor =
params.description || params.backstory || params.personality || '气质鲜明';
const combatAnchor = params.combatStyle || params.motivation || '动作发力清晰';
const tagAnchor =
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
return {
visualPromptText: [
`${characterAnchor}${roleAnchor}`,
'单人全身2D 横版 RPG 角色标准设定图1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
combatAnchor ? `战斗识别点:${combatAnchor}` : '',
tagAnchor,
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterAnchor}的核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
combatAnchor ? `动作气质参考:${combatAnchor}` : '',
params.personality ? `角色气质补充:${params.personality}` : '',
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
params.backstory ? `背景线索可参考:${params.backstory}` : '',
params.motivation ? `环境中可埋入与当前目标相关的暗示:${params.motivation}` : '',
'整体风格克制统一,适合剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
source: 'fallback' as const,
model: null,
};
}
function sanitizePromptBundleValue(
value: unknown,
fallback: string,
maxLength: number,
) {
const normalized = clampPromptSeedText(value, maxLength);
return normalized || fallback;
}
function sanitizeCharacterPromptBundle(
value: unknown,
fallback: CharacterPromptBundle,
model: string,
) {
const record = isRecordValue(value) ? value : {};
return {
visualPromptText: sanitizePromptBundleValue(
record.visualPromptText,
fallback.visualPromptText,
280,
),
animationPromptText: sanitizePromptBundleValue(
record.animationPromptText,
fallback.animationPromptText,
280,
),
scenePromptText: sanitizePromptBundleValue(
record.scenePromptText,
fallback.scenePromptText,
320,
),
source: 'llm' as const,
model: model.trim() || null,
};
}
function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string;
characterBriefText: string;
characterName: string;
roleTitle: string;
roleLabel: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
tags: string[];
}) {
return [
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
'',
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
params.characterName ? `角色名称:${params.characterName}` : '',
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
params.description ? `角色描述:${params.description}` : '',
params.backstory ? `角色背景:${params.backstory}` : '',
params.personality ? `角色性格:${params.personality}` : '',
params.motivation ? `角色动机:${params.motivation}` : '',
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
'',
'角色卡全文:',
params.characterBriefText,
]
.filter(Boolean)
.join('\n');
}
function createTimestampId(prefix: string) {
return `${prefix}-${Date.now()}`;
}
@@ -173,6 +410,16 @@ function getJobRecordPath(
);
}
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
return path.resolve(
rootDir,
'public',
'generated-character-drafts',
sanitizePathSegment(characterId),
'workflow-cache.json',
);
}
async function writeJobRecord(
rootDir: string,
kind: 'visual' | 'animation',
@@ -766,6 +1013,103 @@ function buildNpcAnimationPrompt(options: {
.join(' ');
}
async function handleGenerateCharacterPromptBundle(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
res: ServerResponse,
llmClient?: UpstreamLlmClient | null,
) {
if (req.method !== 'POST') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
const body = await readJsonBody(req);
const roleKind =
typeof body.roleKind === 'string' && body.roleKind.trim()
? body.roleKind.trim()
: 'story';
const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400);
const characterName = clampPromptSeedText(body.characterName, 40);
const roleTitle = clampPromptSeedText(body.roleTitle, 60);
const roleLabel = clampPromptSeedText(body.roleLabel, 60);
const description = clampPromptSeedText(body.description, 240);
const backstory = clampPromptSeedText(body.backstory, 320);
const personality = clampPromptSeedText(body.personality, 180);
const motivation = clampPromptSeedText(body.motivation, 180);
const combatStyle = clampPromptSeedText(body.combatStyle, 180);
const tags = isStringArray(body.tags)
? body.tags.map((item) => clampPromptSeedText(item, 24)).filter(Boolean).slice(0, 8)
: [];
if (!characterBriefText) {
sendJson(res, 400, {
error: { message: '生成默认提示词前需要提供角色设定摘要。' },
});
return;
}
const fallbackBundle = buildFallbackCharacterPromptBundle({
characterName,
roleKind,
roleTitle,
roleLabel,
description,
backstory,
personality,
motivation,
combatStyle,
tags,
});
const llmApiKey =
typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : '';
const llmModel =
typeof config.llm?.model === 'string' ? config.llm.model : '';
if (!llmClient || !llmApiKey) {
sendJson(res, 200, {
ok: true,
...fallbackBundle,
});
return;
}
try {
const responseText = await llmClient.requestMessageContent({
systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
userPrompt: buildCharacterPromptBundleUserPrompt({
roleKind,
characterBriefText,
characterName,
roleTitle,
roleLabel,
description,
backstory,
personality,
motivation,
combatStyle,
tags,
}),
debugLabel: 'character-prompt-bundle',
timeoutMs: 30000,
});
sendJson(res, 200, {
ok: true,
...sanitizeCharacterPromptBundle(
parseJsonResponseText(responseText),
fallbackBundle,
llmModel,
),
});
} catch {
sendJson(res, 200, {
ok: true,
...fallbackBundle,
});
}
}
async function writeDraftBinaryFile(
rootDir: string,
relativePath: string,
@@ -847,7 +1191,7 @@ async function handleGenerateCharacterVisuals(
const size =
typeof body.size === 'string' && body.size.trim()
? body.size.trim()
: '1024*1536';
: '1024*1024';
if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) {
sendJson(res, 400, {
@@ -982,7 +1326,7 @@ async function handleGenerateCharacterVisuals(
label: `候选 ${index + 1}`,
imageSrc,
width: 1024,
height: 1536,
height: 1024,
};
}),
);
@@ -2081,6 +2425,198 @@ function handleListAnimationTemplates(
});
}
async function handleGetCharacterWorkflowCache(
config: AppConfig,
req: IncomingMessage & { originalUrl?: string },
res: ServerResponse,
) {
if (req.method !== 'GET') {
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
return;
}
const rawUrl = req.originalUrl ?? req.url ?? '';
const characterId = decodeURIComponent(
rawUrl.slice(rawUrl.lastIndexOf('/') + 1),
).trim();
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
return;
}
try {
const cache = (await readJsonObjectFile(
getCharacterWorkflowCachePath(config.projectRoot, characterId),
)) as CharacterAssetWorkflowCacheRecord | Record<string, never>;
sendJson(res, 200, {
ok: true,
cache:
isRecordValue(cache) && typeof cache.characterId === 'string'
? cache
: null,
});
} catch (error) {
sendJson(res, 500, {
error: {
message:
error instanceof Error ? error.message : '读取角色形象生成缓存失败。',
},
});
}
}
async function handleSaveCharacterWorkflowCache(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
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() : '';
if (!characterId) {
sendJson(res, 400, { error: { message: 'characterId is required.' } });
return;
}
const visualDrafts = Array.isArray(body.visualDrafts)
? body.visualDrafts
.map((item, index) => {
if (!isRecordValue(item)) {
return null;
}
const imageSrc =
typeof item.imageSrc === 'string' ? item.imageSrc.trim() : '';
if (!imageSrc) {
return null;
}
const id =
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: `${characterId}-draft-${index + 1}`;
const label =
typeof item.label === 'string' && item.label.trim()
? item.label.trim()
: `候选 ${index + 1}`;
return {
id,
label,
imageSrc,
width:
typeof item.width === 'number' && Number.isFinite(item.width)
? item.width
: 1024,
height:
typeof item.height === 'number' && Number.isFinite(item.height)
? item.height
: 1536,
};
})
.filter(
(
item,
): item is CharacterAssetWorkflowCacheRecord['visualDrafts'][number] =>
Boolean(item),
)
: [];
const cacheFilePath = getCharacterWorkflowCachePath(
config.projectRoot,
characterId,
);
const payloadBase = {
characterId,
visualPromptText: clampPromptSeedText(body.visualPromptText, 280),
animationPromptText: clampPromptSeedText(body.animationPromptText, 280),
visualDrafts,
selectedVisualDraftId:
typeof body.selectedVisualDraftId === 'string'
? body.selectedVisualDraftId.trim()
: '',
selectedAnimation:
typeof body.selectedAnimation === 'string'
? body.selectedAnimation.trim()
: 'idle',
imageSrc:
typeof body.imageSrc === 'string' && body.imageSrc.trim()
? body.imageSrc.trim()
: undefined,
generatedVisualAssetId:
typeof body.generatedVisualAssetId === 'string' &&
body.generatedVisualAssetId.trim()
? body.generatedVisualAssetId.trim()
: undefined,
generatedAnimationSetId:
typeof body.generatedAnimationSetId === 'string' &&
body.generatedAnimationSetId.trim()
? body.generatedAnimationSetId.trim()
: undefined,
animationMap: isRecordValue(body.animationMap) ? body.animationMap : null,
};
try {
const existingCache = (await readJsonObjectFile(cacheFilePath)) as
| CharacterAssetWorkflowCacheRecord
| Record<string, never>;
const comparablePayload = serializeWorkflowCacheComparableValue(payloadBase);
const comparableExisting = serializeWorkflowCacheComparableValue(
existingCache,
);
if (
isRecordValue(existingCache) &&
typeof existingCache.characterId === 'string' &&
comparableExisting === comparablePayload
) {
sendJson(res, 200, {
ok: true,
cache: existingCache,
saveMessage: '角色形象生成缓存无变化。',
});
return;
}
const payload: CharacterAssetWorkflowCacheRecord = {
...payloadBase,
updatedAt: new Date().toISOString(),
};
await writeJsonObjectFile(
cacheFilePath,
payload as unknown as Record<string, unknown>,
);
sendJson(res, 200, {
ok: true,
cache: payload,
saveMessage: '角色形象生成缓存已更新。',
});
} catch (error) {
sendJson(res, 500, {
error: {
message:
error instanceof Error ? error.message : '保存角色形象生成缓存失败。',
},
});
}
}
async function handlePublishCharacterVisual(
config: AppConfig,
req: IncomingMessage & { body?: unknown },
@@ -2126,7 +2662,7 @@ async function handlePublishCharacterVisual(
const height =
typeof body.height === 'number' && Number.isFinite(body.height)
? body.height
: 1536;
: 1024;
const updateCharacterOverride = body.updateCharacterOverride !== false;
if (!characterId) {
@@ -2443,7 +2979,10 @@ function toExpressHandler(
};
}
export function createCharacterAssetRoutes(config: AppConfig) {
export function createCharacterAssetRoutes(
config: AppConfig,
llmClient?: UpstreamLlmClient | null,
) {
const router = Router();
router.use((request, response, next) => {
@@ -2466,6 +3005,21 @@ export function createCharacterAssetRoutes(config: AppConfig) {
next();
});
router.use(
CHARACTER_WORKFLOW_CACHE_PATH,
toExpressHandler((request, response) => {
if (request.method === 'GET') {
return handleGetCharacterWorkflowCache(config, request, response);
}
return handleSaveCharacterWorkflowCache(config, request, response);
}),
);
router.use(
CHARACTER_PROMPT_BUNDLE_GENERATE_PATH,
toExpressHandler((request, response) =>
handleGenerateCharacterPromptBundle(config, request, response, llmClient),
),
);
router.use(
CHARACTER_VISUAL_GENERATE_PATH,
toExpressHandler((request, response) =>