Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

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

View File

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

View 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

View File

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

View File

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

View File

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