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

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -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

View File

@@ -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-'),

View File

@@ -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) =>

View File

@@ -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,
};
}

View File

@@ -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 {