@@ -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');
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user