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

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