Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,22 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
|
||||
const {
|
||||
connectivityError,
|
||||
fetchMock,
|
||||
requestChatMessageContentMock,
|
||||
requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletionMock,
|
||||
timeoutError,
|
||||
} = vi.hoisted(() => ({
|
||||
connectivityError: new Error('LLM unavailable'),
|
||||
fetchMock: vi.fn(),
|
||||
requestChatMessageContentMock: vi.fn(),
|
||||
requestPlainTextCompletionMock: vi.fn(),
|
||||
streamPlainTextCompletionMock: vi.fn(),
|
||||
timeoutError: new Error('LLM timed out'),
|
||||
}));
|
||||
|
||||
vi.mock('./llmClient', () => ({
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 45000,
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS: 120000,
|
||||
isLlmConnectivityError: (error: unknown) => error === connectivityError,
|
||||
isLlmTimeoutError: (error: unknown) => error === timeoutError,
|
||||
requestChatMessageContent: requestChatMessageContentMock,
|
||||
requestPlainTextCompletion: requestPlainTextCompletionMock,
|
||||
streamPlainTextCompletion: streamPlainTextCompletionMock,
|
||||
@@ -46,6 +51,13 @@ import {
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
function createCharacter(overrides: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
@@ -144,6 +156,53 @@ function createPlayableNpc(index: number) {
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,14 +219,139 @@ function createStoryNpc(index: number) {
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `世界公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-story-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-story-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-story-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-story-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
|
||||
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
|
||||
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
|
||||
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
|
||||
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number) {
|
||||
function createLandmark(
|
||||
index: number,
|
||||
options?: {
|
||||
storyNpcNames?: string[];
|
||||
landmarkCount?: number;
|
||||
},
|
||||
) {
|
||||
const landmarkCount = options?.landmarkCount ?? 10;
|
||||
const nextName = `场景${((index + 1) % landmarkCount) + 1}`;
|
||||
const prevName = `场景${((index - 1 + landmarkCount) % landmarkCount) + 1}`;
|
||||
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: options?.storyNpcNames ?? [
|
||||
`世界NPC${index + 1}`,
|
||||
`世界NPC${index + 2}`,
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
connections:
|
||||
landmarkCount > 1
|
||||
? [
|
||||
{
|
||||
targetLandmarkName: nextName,
|
||||
relativePosition: 'forward',
|
||||
summary: `沿主路可到${nextName}`,
|
||||
},
|
||||
{
|
||||
targetLandmarkName: prevName,
|
||||
relativePosition: 'back',
|
||||
summary: `回身可返${prevName}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomWorldResponse(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: 'WUXIA' | 'XIANXIA';
|
||||
playableNpcs: ReturnType<typeof createPlayableNpc>[];
|
||||
storyNpcs: ReturnType<typeof createStoryNpc>[];
|
||||
landmarks: ReturnType<typeof createLandmark>[];
|
||||
items: Array<Record<string, unknown>>;
|
||||
}> = {},
|
||||
) {
|
||||
const storyNpcs =
|
||||
overrides.storyNpcs ??
|
||||
Array.from({ length: 25 }, (_, index) => createStoryNpc(index));
|
||||
const landmarks =
|
||||
overrides.landmarks ??
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index, {
|
||||
landmarkCount: 10,
|
||||
storyNpcNames: [
|
||||
storyNpcs[index % storyNpcs.length]?.name ?? `世界NPC${index + 1}`,
|
||||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 2}`,
|
||||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||||
`世界NPC${index + 3}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA' as const,
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,60 +466,40 @@ describe('ai orchestration fallbacks', () => {
|
||||
|
||||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
storyNpcs: Array.from({ length: 10 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 4 }, (_, index) =>
|
||||
createLandmark(index, { landmarkCount: 4 }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateCustomWorldProfile('一个需要很多角色和场景的世界'),
|
||||
).rejects.toThrow(
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景/i,
|
||||
/requires at least 30 unique NPCs|requires at least 10 generated scenes|did not return enough non-playable NPCs|至少产出 30 名唯一角色|至少产出 10 个场景|至少需要 25 名场景角色/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the generated custom world dossier item-free when the model output is valid', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
),
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index),
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
items: [
|
||||
{
|
||||
name: '不应保留的物品',
|
||||
category: '材料',
|
||||
rarity: 'rare',
|
||||
description: '这个字段应该被清空',
|
||||
tags: ['测试'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile =
|
||||
@@ -344,9 +508,108 @@ describe('ai orchestration fallbacks', () => {
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(profile.storyNpcs).toHaveLength(25);
|
||||
expect(profile.landmarks).toHaveLength(10);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3),
|
||||
).toBe(true);
|
||||
expect(
|
||||
profile.landmarks.every((landmark) => landmark.connections.length > 0),
|
||||
).toBe(true);
|
||||
expect(profile.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates custom worlds through a framework stage plus segmented narrative and dossier batches', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify(createCustomWorldResponse()),
|
||||
);
|
||||
|
||||
await generateCustomWorldProfile('一个需要拆分生成的世界');
|
||||
|
||||
const debugLabels = requestPlainTextCompletionMock.mock.calls.map(
|
||||
(call) => (call[2] as { debugLabel?: string } | undefined)?.debugLabel,
|
||||
);
|
||||
|
||||
expect(debugLabels).toContain('custom-world-framework');
|
||||
expect(debugLabels).toContain('custom-world-playable-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-outline-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-seed-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-landmark-network-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-playable-dossier-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-narrative-batch-1');
|
||||
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
||||
});
|
||||
|
||||
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '重试世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个生成很慢的世界');
|
||||
|
||||
expect(profile.name).toBe('重试世界');
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 120000,
|
||||
debugLabel: 'custom-world-framework',
|
||||
}),
|
||||
);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 180000,
|
||||
debugLabel: 'custom-world-framework-retry-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs invalid custom world json through a follow-up formatting request', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockResolvedValueOnce(
|
||||
`{
|
||||
"name": "修复世界",
|
||||
"subtitle": "副标题",
|
||||
"summary": "概述",
|
||||
"tone": "基调",
|
||||
"playerGoal": "目标",
|
||||
"templateWorldType": "WUXIA",
|
||||
"playableNpcs": [{ name: "角色1" }],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
}`,
|
||||
)
|
||||
.mockResolvedValue(
|
||||
JSON.stringify(
|
||||
createCustomWorldResponse({
|
||||
name: '修复世界',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const profile = await generateCustomWorldProfile('一个格式容易损坏的世界');
|
||||
|
||||
expect(profile.name).toBe('修复世界');
|
||||
expect(profile.playableNpcs).toHaveLength(5);
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('你是 JSON 修复器'),
|
||||
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
|
||||
expect.objectContaining({
|
||||
debugLabel: 'custom-world-framework-json-repair',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -45,16 +45,36 @@ import {
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import {
|
||||
buildCustomWorldGenerationPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
normalizeCustomWorldGenerationFramework,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||||
validateCustomWorldGenerationFramework,
|
||||
validateGeneratedCustomWorldProfile,
|
||||
} from './customWorld';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import {
|
||||
CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
isLlmConnectivityError as isLlmConnectivityErrorFromClient,
|
||||
isLlmTimeoutError as isLlmTimeoutErrorFromClient,
|
||||
requestChatMessageContent,
|
||||
requestPlainTextCompletion as requestPlainTextCompletionFromClient,
|
||||
streamPlainTextCompletion as streamPlainTextCompletionFromClient,
|
||||
@@ -84,9 +104,26 @@ type RawOptionItem = {
|
||||
actionText?: string;
|
||||
};
|
||||
|
||||
type MergeableCustomWorldRoleEntry = {
|
||||
name: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
'/api/custom-world/scene-image';
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
const CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_PLAYABLE_BATCH_SIZE = 3;
|
||||
const CUSTOM_WORLD_STORY_BATCH_SIZE = 5;
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||||
const rawValue = Number(import.meta.env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||||
@@ -151,6 +188,423 @@ function normalizeApiErrorMessage(
|
||||
return responseText;
|
||||
}
|
||||
|
||||
function sanitizeJsonLikeText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||||
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
|
||||
const firstBrace = unfenced.indexOf('{');
|
||||
const lastBrace = unfenced.lastIndexOf('}');
|
||||
const extracted =
|
||||
firstBrace >= 0 && lastBrace > firstBrace
|
||||
? unfenced.slice(firstBrace, lastBrace + 1)
|
||||
: unfenced;
|
||||
|
||||
return extracted
|
||||
.replace(/^\uFEFF/u, '')
|
||||
.replace(/[\u201C\u201D]/gu, '"')
|
||||
.replace(/[\u2018\u2019]/gu, "'")
|
||||
.replace(/\u00A0/gu, ' ')
|
||||
.replace(/,\s*([}\]])/gu, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
}
|
||||
|
||||
function getNamedRecordKey(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function chunkArray<T>(items: T[], size: number) {
|
||||
if (size <= 0) {
|
||||
return [items];
|
||||
}
|
||||
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeRoleBatchDetails<T extends MergeableCustomWorldRoleEntry>(
|
||||
baseEntries: T[],
|
||||
detailEntries: Array<Record<string, unknown>>,
|
||||
) {
|
||||
const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const availableIndexes = new Set(nextEntries.map((_, index) => index));
|
||||
const indexByName = new Map<string, number>();
|
||||
|
||||
nextEntries.forEach((entry, index) => {
|
||||
const name = getNamedRecordKey(entry.name);
|
||||
if (name) {
|
||||
indexByName.set(name, index);
|
||||
}
|
||||
});
|
||||
|
||||
detailEntries.forEach((detail) => {
|
||||
const detailName = getNamedRecordKey(detail.name);
|
||||
let targetIndex =
|
||||
detailName && indexByName.has(detailName)
|
||||
? indexByName.get(detailName)
|
||||
: undefined;
|
||||
|
||||
if (targetIndex === undefined) {
|
||||
for (const index of availableIndexes) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseEntry = nextEntries[targetIndex];
|
||||
if (!baseEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextEntries[targetIndex] = {
|
||||
...baseEntry,
|
||||
...detail,
|
||||
name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name,
|
||||
} as T;
|
||||
availableIndexes.delete(targetIndex);
|
||||
});
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
function appendUniqueNamedEntries<T extends MergeableCustomWorldRoleEntry>(
|
||||
baseEntries: T[],
|
||||
nextEntries: T[],
|
||||
maxCount: number,
|
||||
) {
|
||||
const merged = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const existingNames = new Set(
|
||||
merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean),
|
||||
);
|
||||
|
||||
nextEntries.forEach((entry) => {
|
||||
if (merged.length >= maxCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = getNamedRecordKey(entry.name);
|
||||
if (!name || existingNames.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
merged.push({ ...entry, name } as T);
|
||||
existingNames.add(name);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, roleType, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
let batchIndex = 0;
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
|
||||
framework,
|
||||
roleType,
|
||||
batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
debugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
roleType,
|
||||
expectedCount: batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}名单批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
|
||||
totalCount,
|
||||
);
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
let batchIndex = 0;
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
|
||||
framework,
|
||||
batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
debugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
expectedCount: batchCount,
|
||||
forbiddenNames: mergedEntries.map((entry) => entry.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景骨架批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||||
totalCount,
|
||||
);
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
|
||||
baseEntries: MergeableCustomWorldRoleEntry[];
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, storyNpcs, baseEntries, batchSize } = params;
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry }));
|
||||
|
||||
for (const [batchIndex, landmarkBatch] of chunkArray(
|
||||
framework.landmarks,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
|
||||
framework,
|
||||
landmarkBatch,
|
||||
storyNpcs,
|
||||
}),
|
||||
debugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
expectedNames: landmarkBatch.map((landmark) => landmark.name),
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景连接批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
mergedEntries,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw).map(
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function expandCustomWorldRoleEntries<
|
||||
T extends MergeableCustomWorldRoleEntry,
|
||||
>(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
baseEntries: T[];
|
||||
batchSize: number;
|
||||
}) {
|
||||
const { framework, roleType, baseEntries, batchSize } = params;
|
||||
const roleBatchSource =
|
||||
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
|
||||
const requestBatchStage = async (
|
||||
roleBatch: typeof roleBatchSource,
|
||||
batchIndex: number,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
) => {
|
||||
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
|
||||
const stageRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleBatchPrompt({
|
||||
framework,
|
||||
roleType,
|
||||
roleBatch,
|
||||
stage,
|
||||
}),
|
||||
debugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}`,
|
||||
repairPromptBuilder: (responseText) =>
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt({
|
||||
responseText,
|
||||
roleType,
|
||||
expectedNames: roleBatch.map((role) => role.name),
|
||||
stage,
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleLabel}批次 ${batchIndex + 1} 的${stageLabel}生成失败:模型没有返回有效内容。`,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
mergedEntries,
|
||||
toRecordArray(
|
||||
stageRaw && typeof stageRaw === 'object'
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
for (const [batchIndex, roleBatch] of chunkArray(
|
||||
roleBatchSource,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
await requestBatchStage(roleBatch, batchIndex, 'narrative');
|
||||
await requestBatchStage(roleBatch, batchIndex, 'dossier');
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
}
|
||||
|
||||
async function parseCustomWorldStageResponseJson(params: {
|
||||
responseText: string;
|
||||
repairPrompt: string;
|
||||
repairDebugLabel: string;
|
||||
}) {
|
||||
const { responseText, repairPrompt, repairDebugLabel } = params;
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(responseText);
|
||||
} catch {
|
||||
const sanitized = sanitizeJsonLikeText(responseText);
|
||||
if (sanitized && sanitized !== responseText.trim()) {
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(sanitized);
|
||||
} catch {
|
||||
// Fall through to model-assisted repair.
|
||||
}
|
||||
}
|
||||
|
||||
const repairedText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
repairPrompt,
|
||||
{
|
||||
timeoutMs: Math.max(
|
||||
30000,
|
||||
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
|
||||
),
|
||||
debugLabel: repairDebugLabel,
|
||||
},
|
||||
);
|
||||
|
||||
return parseJsonResponseTextFromParser(
|
||||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCustomWorldJsonStage(params: {
|
||||
userPrompt: string;
|
||||
debugLabel: string;
|
||||
repairPromptBuilder: (responseText: string) => string;
|
||||
repairDebugLabel: string;
|
||||
emptyResponseMessage: string;
|
||||
}) {
|
||||
const {
|
||||
userPrompt,
|
||||
debugLabel,
|
||||
repairPromptBuilder,
|
||||
repairDebugLabel,
|
||||
emptyResponseMessage,
|
||||
} = params;
|
||||
const timeoutPlan = [
|
||||
CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
Math.max(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS, 180000),
|
||||
].filter((timeoutMs, index, array) => array.indexOf(timeoutMs) === index);
|
||||
|
||||
let text = '';
|
||||
let lastTimeoutError: unknown = null;
|
||||
|
||||
for (const [attemptIndex, timeoutMs] of timeoutPlan.entries()) {
|
||||
try {
|
||||
const responseText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
{
|
||||
timeoutMs,
|
||||
debugLabel:
|
||||
attemptIndex === 0
|
||||
? debugLabel
|
||||
: `${debugLabel}-retry-${attemptIndex + 1}`,
|
||||
},
|
||||
);
|
||||
text = typeof responseText === 'string' ? responseText : '';
|
||||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
isLlmTimeoutErrorFromClient(error) &&
|
||||
attemptIndex < timeoutPlan.length - 1
|
||||
) {
|
||||
lastTimeoutError = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
throw lastTimeoutError ?? new Error(emptyResponseMessage);
|
||||
}
|
||||
|
||||
return parseCustomWorldStageResponseJson({
|
||||
responseText: text,
|
||||
repairPrompt: repairPromptBuilder(text),
|
||||
repairDebugLabel,
|
||||
});
|
||||
}
|
||||
|
||||
function buildFunctionContext(
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
@@ -683,16 +1137,92 @@ export async function generateCustomWorldProfile(
|
||||
const normalizedSettingText = settingText.trim();
|
||||
|
||||
try {
|
||||
const text = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT,
|
||||
buildCustomWorldGenerationPrompt(normalizedSettingText),
|
||||
{
|
||||
timeoutMs: CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
debugLabel: 'custom-world-profile',
|
||||
},
|
||||
);
|
||||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
|
||||
debugLabel: 'custom-world-framework',
|
||||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||||
emptyResponseMessage: '自定义世界框架生成失败:模型没有返回有效内容。',
|
||||
});
|
||||
const frameworkBase = {
|
||||
...normalizeCustomWorldGenerationFramework(
|
||||
frameworkRaw,
|
||||
normalizedSettingText,
|
||||
),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const playableNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
const frameworkWithPlayable = {
|
||||
...frameworkBase,
|
||||
playableNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const storyNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
const frameworkWithStory = {
|
||||
...frameworkWithPlayable,
|
||||
storyNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
const frameworkWithLandmarkSeeds = {
|
||||
...frameworkWithStory,
|
||||
landmarks: landmarkSeeds,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
const landmarks =
|
||||
(await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
|
||||
const framework = {
|
||||
...frameworkWithStory,
|
||||
landmarks,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
|
||||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||||
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'playable',
|
||||
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
});
|
||||
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'story',
|
||||
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
});
|
||||
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
parseJsonResponseTextFromParser(text),
|
||||
{
|
||||
...baseRawProfile,
|
||||
playableNpcs: mergedPlayableNpcs,
|
||||
storyNpcs: mergedStoryNpcs,
|
||||
},
|
||||
normalizedSettingText,
|
||||
);
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
@@ -703,12 +1233,17 @@ export async function generateCustomWorldProfile(
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'自定义世界生成失败:模型没有返回有效的 JSON,请稍后重试。',
|
||||
'自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。',
|
||||
);
|
||||
}
|
||||
if (isLlmTimeoutErrorFromClient(error)) {
|
||||
throw new Error(
|
||||
'自定义世界生成超时:分阶段生成过程中仍有批次未在限定时间内完成返回。已自动延长重试一次;如果仍失败,请稍后重试或提高 VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS。',
|
||||
);
|
||||
}
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
throw new Error(
|
||||
'自定义世界生成需要真实模型产出场景角色与场景内容,请恢复模型连接后再试。',
|
||||
'自定义世界生成无法连接模型服务,请确认本地开发服务器、模型代理和网络连接可用后再试。',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
||||
227
src/services/customWorld.test.ts
Normal file
227
src/services/customWorld.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
describe('normalizeCustomWorldProfile', () => {
|
||||
it('forces NPC backstory chapter thresholds to match shared affinity levels', () => {
|
||||
const rawChapterThresholds = [20, 40, 65, 85];
|
||||
const rawProfile = {
|
||||
name: '裂谷边城',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '常年带人穿过裂谷旧道。',
|
||||
backstory: '曾在塌桥夜里失去整支同行队伍。',
|
||||
personality: '谨慎寡言,却记得每一道风口。',
|
||||
motivation: '想查清旧道频繁异变的根源。',
|
||||
combatStyle: '短弓牵制后再逼近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '旧案'],
|
||||
tags: ['裂谷', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉旧道。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `playable-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `提示${index + 1}`,
|
||||
content: `内容${index + 1}`,
|
||||
contextSnippet: `摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '灰炬起手', summary: '先以火光扰乱视线。', style: '起手压制' },
|
||||
{ name: '窄道游移', summary: '借地形不断换位牵制。', style: '机动周旋' },
|
||||
{ name: '崖风绝射', summary: '抓住破绽给出终结一箭。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '旧道短弓', category: '武器', quantity: 1, rarity: 'rare', description: '磨损严重却极趁手。', tags: ['裂谷'] },
|
||||
{ name: '裂谷补给', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '防风与止血一并备齐。', tags: ['补给'] },
|
||||
{ name: '断绳铜哨', category: '专属物品', quantity: 1, rarity: 'rare', description: '那场事故后仅存的信物。', tags: ['旧案'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '裂谷巡哨蛛',
|
||||
title: '巡哨怪',
|
||||
role: '怪物哨兵',
|
||||
description: '伏在岩壁缝间监视往来活物。',
|
||||
backstory: '长期吞食矿脉异潮后逐渐拥有巡猎习性。',
|
||||
personality: '极度警觉,会反复试探猎物退路。',
|
||||
motivation: '守住巢穴上层不断扩大的裂口。',
|
||||
combatStyle: '吐丝封路,再借高处俯冲撕咬。',
|
||||
initialAffinity: -20,
|
||||
relationshipHooks: ['巢穴', '异潮'],
|
||||
tags: ['怪物', '裂谷'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '它始终盘踞在峭壁阴影里。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `story-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `怪物提示${index + 1}`,
|
||||
content: `怪物内容${index + 1}`,
|
||||
contextSnippet: `怪物摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '蛛丝封步', summary: '先缠住脚步再逼近。', style: '起手压制' },
|
||||
{ name: '壁缝换位', summary: '沿岩壁快速转移位置。', style: '机动周旋' },
|
||||
{ name: '坠崖扑杀', summary: '从高处俯冲撕裂目标。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '硬化毒牙', category: '材料', quantity: 1, rarity: 'rare', description: '可提炼出刺激性毒液。', tags: ['怪物'] },
|
||||
{ name: '粘稠丝囊', category: '材料', quantity: 2, rarity: 'uncommon', description: '能用于制作束缚陷阱。', tags: ['巢穴'] },
|
||||
{ name: '矿潮节壳', category: '稀有品', quantity: 1, rarity: 'rare', description: '受异潮侵染后的外壳碎片。', tags: ['异潮'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '横跨裂谷的旧桥只剩半截石拱。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(rawProfile, '玩家想要一个裂谷边城与怪物共存的世界。');
|
||||
|
||||
expect(
|
||||
profile.playableNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
expect(
|
||||
profile.storyNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
});
|
||||
|
||||
it('resolves landmark scene NPCs and relative connections into the final scene graph', () => {
|
||||
const rawProfile = {
|
||||
name: '裂界巡旅',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '岑舟',
|
||||
title: '裂界行脚',
|
||||
role: '引路人',
|
||||
description: '擅长在断层边缘辨路。',
|
||||
backstory: '长期在裂界边缘押送队伍。',
|
||||
personality: '稳重少言,但反应很快。',
|
||||
motivation: '想把几条旧通路重新串起来。',
|
||||
combatStyle: '短兵贴身后迅速换位。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '断层'],
|
||||
tags: ['裂界', '向导'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '梁砺',
|
||||
title: '桥索修补匠',
|
||||
role: '修桥人',
|
||||
description: '守着断桥口修缮索道。',
|
||||
backstory: '曾在崩桥夜里救下半队人。',
|
||||
personality: '谨慎,习惯先看绳结再说话。',
|
||||
motivation: '想守住最后几条安全通路。',
|
||||
combatStyle: '铁钩牵制后贴近补击。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['断桥', '索道'],
|
||||
tags: ['桥', '工匠'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '苏雾',
|
||||
title: '雾港采录者',
|
||||
role: '记录员',
|
||||
description: '在雾港整理各路来客口供。',
|
||||
backstory: '长期记录裂雾里消失的队伍名单。',
|
||||
personality: '敏感细致,总在核对细节。',
|
||||
motivation: '查清名单上重复出现的名字。',
|
||||
combatStyle: '保持距离,借器物扰乱节奏。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['雾港', '名单'],
|
||||
tags: ['港口', '记录'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '顾岚',
|
||||
title: '界崖巡哨',
|
||||
role: '巡哨',
|
||||
description: '沿着崖线巡查异动和回声。',
|
||||
backstory: '常年住在界崖边的哨点里。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '找出最近总在夜里响起的回声来源。',
|
||||
combatStyle: '长兵抢先压住身位。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['巡查', '崖线'],
|
||||
tags: ['哨点', '崖线'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '闻砂',
|
||||
title: '砂塔守更人',
|
||||
role: '守更人',
|
||||
description: '夜里守着砂塔边的旧灯火。',
|
||||
backstory: '见过太多从塔下走失的人。',
|
||||
personality: '冷静克制,习惯留后手。',
|
||||
motivation: '想确认旧塔下方的回响是否重新苏醒。',
|
||||
combatStyle: '借高差压制后再收拢路线。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['守夜', '砂塔'],
|
||||
tags: ['砂塔', '旧灯'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '断桥上方还残留着旧索道。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['梁砺'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '雾潮码头',
|
||||
relativePosition: 'south',
|
||||
summary: '顺着残桥往南下坡可到雾港。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '潮雾会把来路和去路都遮住一半。',
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: ['苏雾', '顾岚'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(
|
||||
rawProfile,
|
||||
'玩家想要一个围绕裂界断桥与雾港巡旅展开的世界。',
|
||||
);
|
||||
|
||||
expect(profile.landmarks).toHaveLength(2);
|
||||
expect(profile.landmarks[0]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[1]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[0]?.connections[0]?.targetLandmarkId).toBe(
|
||||
profile.landmarks[1]?.id,
|
||||
);
|
||||
expect(profile.landmarks[1]?.connections.some(
|
||||
(connection) => connection.targetLandmarkId === profile.landmarks[0]?.id,
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import {
|
||||
buildItemAttributeResonance,
|
||||
} from '../data/attributeProfileGenerator';
|
||||
import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
@@ -95,37 +96,91 @@ export function buildExpandedCustomWorldProfile(
|
||||
): CustomWorldProfile {
|
||||
const profile = normalizeCustomWorldProfile(raw, settingText);
|
||||
const attributeSchema = profile.attributeSchema;
|
||||
const playableNpcs = dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
});
|
||||
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
}));
|
||||
const storyNpcIdByReference = new Map<string, string>();
|
||||
storyNpcs.forEach((npc) => {
|
||||
storyNpcIdByReference.set(npc.id, npc.id);
|
||||
storyNpcIdByReference.set(npc.name, npc.id);
|
||||
});
|
||||
profile.storyNpcs.forEach((npc) => {
|
||||
const nextNpc = storyNpcs.find((entry) => entry.name === npc.name);
|
||||
if (!nextNpc) {
|
||||
return;
|
||||
}
|
||||
storyNpcIdByReference.set(npc.id, nextNpc.id);
|
||||
storyNpcIdByReference.set(npc.name, nextNpc.id);
|
||||
});
|
||||
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
}));
|
||||
const landmarkIdByReference = new Map<string, string>();
|
||||
landmarkDrafts.forEach((landmark) => {
|
||||
landmarkIdByReference.set(landmark.id, landmark.id);
|
||||
landmarkIdByReference.set(landmark.name, landmark.id);
|
||||
});
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const nextLandmark = landmarkDrafts.find(
|
||||
(entry) => entry.name === landmark.name,
|
||||
);
|
||||
if (!nextLandmark) {
|
||||
return;
|
||||
}
|
||||
landmarkIdByReference.set(landmark.id, nextLandmark.id);
|
||||
landmarkIdByReference.set(landmark.name, nextLandmark.id);
|
||||
});
|
||||
const landmarks = normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.map(
|
||||
(npcId) => storyNpcIdByReference.get(npcId) ?? npcId,
|
||||
),
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkId:
|
||||
landmarkIdByReference.get(connection.targetLandmarkId) ??
|
||||
connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
})),
|
||||
storyNpcs,
|
||||
});
|
||||
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: createEntryId('playable-npc', npc.name, index),
|
||||
templateCharacterId,
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
}),
|
||||
storyNpcs: dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
})),
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: createEntryId('item', item.name, index),
|
||||
@@ -134,13 +189,6 @@ export function buildExpandedCustomWorldProfile(
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
})),
|
||||
landmarks: dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
})),
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ export class LlmConnectivityError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class LlmTimeoutError extends LlmConnectivityError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) {
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
@@ -26,7 +33,7 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number)
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 45000),
|
||||
Math.max(REQUEST_TIMEOUT_MS, 120000),
|
||||
);
|
||||
|
||||
function logLlmDebug(title: string, payload: unknown) {
|
||||
@@ -39,7 +46,7 @@ function logLlmDebug(title: string, payload: unknown) {
|
||||
|
||||
function normalizeLlmError(error: unknown): never {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new LlmConnectivityError('The LLM request timed out. Please check the network or endpoint.');
|
||||
throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.');
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
@@ -53,6 +60,10 @@ export function isLlmConnectivityError(error: unknown): error is LlmConnectivity
|
||||
return error instanceof LlmConnectivityError;
|
||||
}
|
||||
|
||||
export function isLlmTimeoutError(error: unknown): error is LlmTimeoutError {
|
||||
return error instanceof LlmTimeoutError;
|
||||
}
|
||||
|
||||
async function requestMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
|
||||
@@ -89,6 +89,6 @@ export async function generateRuntimeItemAiIntents(params: {
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]),
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user