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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user