@@ -578,6 +578,14 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: createStoryContext(),
|
||||
combatContext: {
|
||||
summary: '你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口。',
|
||||
logLines: [
|
||||
'你先一步抢进桥心,逼开了对方的起手。',
|
||||
'断桥客被逼退到桥栏边,终于没有再出下一刀。',
|
||||
],
|
||||
battleOutcome: 'victory',
|
||||
},
|
||||
conversationHistory: [
|
||||
{ speaker: 'player', text: '你一直躲着不说完。' },
|
||||
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
|
||||
@@ -658,6 +666,15 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
|
||||
assert.equal(requestMessageCount, 0);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /最后一轮/u);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /推动后续剧情/u);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /刚刚结束的交锋/u);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口/u,
|
||||
);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/你先一步抢进桥心,逼开了对方的起手/u,
|
||||
);
|
||||
|
||||
const eventText = responseChunks.join('');
|
||||
const completeBlock = eventText
|
||||
|
||||
@@ -375,59 +375,6 @@ 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 到 2\.5 头身/u);
|
||||
assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u);
|
||||
assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/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-'),
|
||||
|
||||
@@ -18,25 +18,17 @@ import { PNG } from 'pngjs';
|
||||
|
||||
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.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 {
|
||||
buildArkCharacterAnimationPrompt,
|
||||
buildCharacterPromptBundleUserPrompt,
|
||||
buildFallbackCharacterPromptBundle,
|
||||
buildFallbackModerationSafeAnimationPrompt,
|
||||
buildImageSequencePrompt,
|
||||
buildNpcAnimationPrompt,
|
||||
buildNpcVisualNegativePrompt,
|
||||
buildNpcVisualPrompt,
|
||||
CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
type CharacterPromptBundle,
|
||||
sanitizeCharacterPromptBundle,
|
||||
} from '../../prompts/characterAssetPrompts.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';
|
||||
@@ -1050,106 +1042,6 @@ function getLowestSupportedVideoResolution(model: string, fallback: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -3107,12 +2999,6 @@ export function createCharacterAssetRoutes(
|
||||
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) =>
|
||||
|
||||
@@ -572,9 +572,13 @@ function buildFallbackCustomWorldCampScene(profile: {
|
||||
} as const;
|
||||
|
||||
return {
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: descriptionByMode[themeMode],
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1034,9 +1038,27 @@ function normalizeCampOutline(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) ||
|
||||
toText(connection.position) ||
|
||||
'forward',
|
||||
summary:
|
||||
toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1502,10 +1524,22 @@ function normalizeCampScene(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position) || 'forward',
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ export interface SceneActBlueprint {
|
||||
summary: string;
|
||||
stageCoverage: SceneActStage[];
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
primaryNpcId: string;
|
||||
linkedThreadIds: string[];
|
||||
@@ -263,10 +264,21 @@ export interface SceneChapterBlueprint {
|
||||
}
|
||||
|
||||
export interface CustomWorldCampScene {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
narrativeResidues?:
|
||||
| Array<{
|
||||
summary?: string;
|
||||
changeHint?: string;
|
||||
hiddenTruth?: string;
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface CustomWorldLandmark {
|
||||
|
||||
Reference in New Issue
Block a user