1849 lines
56 KiB
TypeScript
1849 lines
56 KiB
TypeScript
import type {
|
||
CustomWorldGenerationStep,
|
||
GenerateCustomWorldProfileInput,
|
||
GenerateCustomWorldProfileOptions,
|
||
} from '../../packages/shared/src/contracts/runtime';
|
||
import { unwrapApiResponse } from '../../packages/shared/src/http';
|
||
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
|
||
import {
|
||
buildEncounterFromSceneNpc,
|
||
getScenePresetById,
|
||
isHostileSceneNpc,
|
||
} from '../data/scenePresets';
|
||
import {
|
||
FunctionAvailabilityContext,
|
||
getDefaultFunctionIdsForContext,
|
||
resolveFunctionOption,
|
||
} from '../data/stateFunctions';
|
||
import {
|
||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||
buildCustomWorldFrameworkPrompt,
|
||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||
buildCustomWorldRoleBatchPrompt,
|
||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||
buildCustomWorldRoleOutlineBatchPrompt,
|
||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||
buildCustomWorldStoryGraphPrompt,
|
||
buildCustomWorldThemePackJsonRepairPrompt,
|
||
buildCustomWorldThemePackPrompt,
|
||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||
} from '../prompts/customWorldPrompts';
|
||
import {
|
||
AIResponse,
|
||
Character,
|
||
CustomWorldCreatorIntent,
|
||
CustomWorldGenerationMode,
|
||
CustomWorldProfile,
|
||
SceneEncounterResult,
|
||
SceneHostileNpc,
|
||
SceneNpc,
|
||
StoryOption,
|
||
ThemePack,
|
||
WorldStoryGraph,
|
||
WorldType,
|
||
} from '../types';
|
||
import {
|
||
CustomWorldSceneImageRequest,
|
||
CustomWorldSceneImageResult,
|
||
StoryGenerationContext,
|
||
StoryRequestOptions,
|
||
TextStreamOptions,
|
||
} from './aiTypes';
|
||
import {
|
||
generateCharacterPanelChatSuggestions as generateCharacterPanelChatSuggestionsFromServer,
|
||
generateCharacterPanelChatSummary as generateCharacterPanelChatSummaryFromServer,
|
||
generateInitialStory as generateInitialStoryFromServer,
|
||
generateNextStep as generateNextStepFromServer,
|
||
streamCharacterPanelChatReply as streamCharacterPanelChatReplyFromServer,
|
||
streamNpcChatDialogue as streamNpcChatDialogueFromServer,
|
||
streamNpcRecruitDialogue as streamNpcRecruitDialogueFromServer,
|
||
} from './aiService';
|
||
import { fetchWithApiAuth } from './apiClient';
|
||
import {
|
||
buildCustomWorldRawProfileFromFramework,
|
||
type CustomWorldGenerationFramework,
|
||
type CustomWorldGenerationLandmarkOutline,
|
||
type CustomWorldGenerationRoleBatchStage,
|
||
type CustomWorldGenerationRoleBatchType,
|
||
type CustomWorldGenerationRoleOutline,
|
||
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 {
|
||
buildCustomWorldAnchorPackFromIntent,
|
||
buildCustomWorldCreatorIntentGenerationText,
|
||
deriveCustomWorldLockStateFromIntent,
|
||
hasMeaningfulCustomWorldCreatorIntent,
|
||
} from './customWorldCreatorIntent';
|
||
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,
|
||
} from './llmClient';
|
||
import { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers';
|
||
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||
import {
|
||
buildFallbackActorNarrativeProfile,
|
||
normalizeActorNarrativeProfile,
|
||
} from './storyEngine/actorNarrativeProfile';
|
||
import {
|
||
buildThemePackFromWorldProfile,
|
||
normalizeThemePack,
|
||
} from './storyEngine/themePack';
|
||
import {
|
||
buildFallbackWorldStoryGraph,
|
||
normalizeWorldStoryGraph,
|
||
} from './storyEngine/worldStoryGraph';
|
||
|
||
export type {
|
||
CustomWorldGenerationProgress,
|
||
GenerateCustomWorldProfileInput,
|
||
GenerateCustomWorldProfileOptions,
|
||
} from '../../packages/shared/src/contracts/runtime';
|
||
export type {
|
||
CustomWorldSceneImageRequest,
|
||
CustomWorldSceneImageResult,
|
||
StoryGenerationContext,
|
||
StoryRequestOptions,
|
||
TextStreamOptions,
|
||
} from './aiTypes';
|
||
|
||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||
|
||
type RawOptionItem = {
|
||
functionId: string;
|
||
actionText?: string;
|
||
};
|
||
|
||
type MergeableCustomWorldRoleEntry = {
|
||
name: string;
|
||
};
|
||
|
||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||
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 STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||
你会收到一个已经解析过的剧情 JSON 对象。
|
||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||
只输出一个 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_PLAYABLE_BATCH_SIZE = 3;
|
||
const CUSTOM_WORLD_STORY_BATCH_SIZE = 5;
|
||
const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||
const rawValue = Number(ENV.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
|
||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||
})();
|
||
|
||
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||
{
|
||
id: 'framework',
|
||
label: '世界框架',
|
||
detail: '解析设定文本,确定世界主题、主目标与基础模板。',
|
||
total: 1,
|
||
weight: 1,
|
||
},
|
||
{
|
||
id: 'theme-pack',
|
||
label: '题材适配层',
|
||
detail: '提炼制度词汇、禁忌词与命名范式。',
|
||
total: 1,
|
||
weight: 1,
|
||
},
|
||
{
|
||
id: 'story-graph',
|
||
label: '世界线程图谱',
|
||
detail: '补出明线、暗线、旧伤与意象母题。',
|
||
total: 1,
|
||
weight: 1,
|
||
},
|
||
{
|
||
id: 'playable-outline',
|
||
label: '可扮演角色骨架',
|
||
detail: '先生成可扮演角色名单与核心定位。',
|
||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT /
|
||
CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'story-outline',
|
||
label: '场景角色骨架',
|
||
detail: '补齐世界里的关键角色与势力关系。',
|
||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT /
|
||
CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'landmark-seed',
|
||
label: '场景骨架',
|
||
detail: '生成地标、区域描述、场景角色与连接关系。',
|
||
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
|
||
CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'playable-narrative',
|
||
label: '可扮演角色叙事',
|
||
detail: '为可扮演角色补充公开背景、动机与风格。',
|
||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'playable-dossier',
|
||
label: '可扮演角色档案',
|
||
detail: '补齐技能、好感章节与初始携带信息。',
|
||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'playable-profile',
|
||
label: '可扮演角色叙事档案',
|
||
detail: '为可扮演角色生成首遇面具、当前压力和暗线钩子。',
|
||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'story-narrative',
|
||
label: '场景角色叙事',
|
||
detail: '扩写场景角色的关系钩子与叙事位置。',
|
||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'story-profile',
|
||
label: '场景角色叙事档案',
|
||
detail: '为场景角色生成首遇面具、当前压力和暗线钩子。',
|
||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'story-dossier',
|
||
label: '场景角色档案',
|
||
detail: '补齐场景角色档案与互动素材。',
|
||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||
weight: Math.max(
|
||
1,
|
||
Math.ceil(
|
||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||
),
|
||
),
|
||
},
|
||
{
|
||
id: 'finalize',
|
||
label: '归档世界',
|
||
detail: '整理最终世界档案并做完整性校验。',
|
||
total: 1,
|
||
weight: 1,
|
||
},
|
||
] as const;
|
||
|
||
export type CustomWorldGenerationStageId =
|
||
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
|
||
|
||
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
|
||
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
|
||
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
|
||
|
||
class CustomWorldGenerationAbortedError extends Error {
|
||
constructor(message = '世界生成已中断。') {
|
||
super(message);
|
||
this.name = 'CustomWorldGenerationAbortedError';
|
||
}
|
||
}
|
||
|
||
function normalizeApiErrorMessage(
|
||
responseText: string,
|
||
fallbackMessage: string,
|
||
) {
|
||
if (!responseText.trim()) {
|
||
return fallbackMessage;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(responseText) as {
|
||
error?: { message?: string };
|
||
message?: string;
|
||
};
|
||
if (
|
||
typeof parsed.error?.message === 'string' &&
|
||
parsed.error.message.trim()
|
||
) {
|
||
return parsed.error.message;
|
||
}
|
||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||
return parsed.message;
|
||
}
|
||
} catch {
|
||
// Fall through to the raw response text below.
|
||
}
|
||
|
||
return responseText;
|
||
}
|
||
|
||
function resolveCustomWorldGenerationInput(
|
||
input: string | GenerateCustomWorldProfileInput,
|
||
): {
|
||
settingText: string;
|
||
generationSeedText: string;
|
||
creatorIntent: CustomWorldCreatorIntent | null;
|
||
generationMode: CustomWorldGenerationMode;
|
||
} {
|
||
if (typeof input === 'string') {
|
||
return {
|
||
settingText: input.trim(),
|
||
generationSeedText: input.trim(),
|
||
creatorIntent: null as CustomWorldCreatorIntent | null,
|
||
generationMode: 'full' as CustomWorldGenerationMode,
|
||
};
|
||
}
|
||
|
||
const normalizedSettingText = input.settingText.trim();
|
||
const creatorIntent =
|
||
(input.creatorIntent as CustomWorldCreatorIntent | null | undefined) ??
|
||
null;
|
||
const generationSeedText =
|
||
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
|
||
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
|
||
: normalizedSettingText;
|
||
|
||
return {
|
||
settingText: normalizedSettingText,
|
||
generationSeedText: generationSeedText.trim(),
|
||
creatorIntent,
|
||
generationMode:
|
||
input.generationMode === 'fast' ? ('fast' as const) : ('full' as const),
|
||
};
|
||
}
|
||
|
||
function getCustomWorldGenerationTargets(
|
||
generationMode: CustomWorldGenerationMode,
|
||
) {
|
||
if (generationMode === 'fast') {
|
||
return {
|
||
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
|
||
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
|
||
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
|
||
generationStatus: 'key_only' as const,
|
||
};
|
||
}
|
||
|
||
return {
|
||
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||
generationStatus: 'complete' as const,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const CUSTOM_WORLD_GENERATION_STAGE_MAP = new Map(
|
||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, stage]),
|
||
);
|
||
const CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT =
|
||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||
(sum, stage) => sum + stage.weight,
|
||
0,
|
||
);
|
||
|
||
function getCustomWorldGenerationStageIdForRoleOutline(
|
||
roleType: CustomWorldGenerationRoleBatchType,
|
||
): CustomWorldGenerationStageId {
|
||
return roleType === 'playable' ? 'playable-outline' : 'story-outline';
|
||
}
|
||
|
||
function getCustomWorldGenerationStageIdForRoleExpansion(
|
||
roleType: CustomWorldGenerationRoleBatchType,
|
||
stage: CustomWorldGenerationRoleBatchStage,
|
||
): CustomWorldGenerationStageId {
|
||
if (roleType === 'playable') {
|
||
return stage === 'narrative' ? 'playable-narrative' : 'playable-dossier';
|
||
}
|
||
|
||
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
|
||
}
|
||
|
||
function getCustomWorldGenerationStageIdForActorProfile(
|
||
roleType: CustomWorldGenerationRoleBatchType,
|
||
): CustomWorldGenerationStageId {
|
||
return roleType === 'playable' ? 'playable-profile' : 'story-profile';
|
||
}
|
||
|
||
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
|
||
if (!signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
throw signal.reason instanceof Error
|
||
? signal.reason
|
||
: new CustomWorldGenerationAbortedError();
|
||
}
|
||
|
||
function isCustomWorldGenerationAbortLikeError(error: unknown) {
|
||
return (
|
||
error instanceof CustomWorldGenerationAbortedError ||
|
||
(typeof DOMException !== 'undefined' &&
|
||
error instanceof DOMException &&
|
||
error.name === 'AbortError')
|
||
);
|
||
}
|
||
|
||
function createCustomWorldGenerationReporter(
|
||
onProgress?: GenerateCustomWorldProfileOptions['onProgress'],
|
||
) {
|
||
const startedAt = performance.now();
|
||
const completedByStage = Object.fromEntries(
|
||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
|
||
) as Record<CustomWorldGenerationStageId, number>;
|
||
|
||
const emit = (
|
||
stageId: CustomWorldGenerationStageId,
|
||
options: Partial<{
|
||
completed: number;
|
||
phaseDetail: string;
|
||
batchLabel: string;
|
||
}> = {},
|
||
) => {
|
||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||
if (!stage) {
|
||
return;
|
||
}
|
||
|
||
if (typeof options.completed === 'number') {
|
||
completedByStage[stageId] = Math.max(
|
||
0,
|
||
Math.min(stage.total, options.completed),
|
||
);
|
||
}
|
||
|
||
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
|
||
const completed = Math.max(
|
||
0,
|
||
Math.min(item.total, completedByStage[item.id]),
|
||
);
|
||
return {
|
||
id: item.id,
|
||
label: item.label,
|
||
detail: item.detail,
|
||
completed,
|
||
total: item.total,
|
||
status:
|
||
completed >= item.total
|
||
? 'completed'
|
||
: item.id === stageId
|
||
? 'active'
|
||
: 'pending',
|
||
} satisfies CustomWorldGenerationStep;
|
||
});
|
||
|
||
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||
(sum, item) =>
|
||
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
|
||
0,
|
||
);
|
||
const progressFraction =
|
||
CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT > 0
|
||
? completedWeight / CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT
|
||
: 0;
|
||
const elapsedMs = Math.max(0, performance.now() - startedAt);
|
||
const estimatedRemainingMs =
|
||
progressFraction > 0 && progressFraction < 1
|
||
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
|
||
: progressFraction >= 1
|
||
? 0
|
||
: null;
|
||
|
||
onProgress?.({
|
||
phaseId: stage.id,
|
||
phaseLabel: stage.label,
|
||
phaseDetail: options.phaseDetail ?? stage.detail,
|
||
batchLabel: options.batchLabel,
|
||
overallProgress: Math.max(
|
||
0,
|
||
Math.min(100, Math.round(progressFraction * 100)),
|
||
),
|
||
completedWeight,
|
||
totalWeight: CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT,
|
||
elapsedMs: Math.round(elapsedMs),
|
||
estimatedRemainingMs,
|
||
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
|
||
(item) => item.id === stage.id,
|
||
),
|
||
steps,
|
||
});
|
||
};
|
||
|
||
return {
|
||
begin(
|
||
stageId: CustomWorldGenerationStageId,
|
||
options: Partial<{
|
||
phaseDetail: string;
|
||
batchLabel: string;
|
||
}> = {},
|
||
) {
|
||
emit(stageId, {
|
||
completed: completedByStage[stageId],
|
||
...options,
|
||
});
|
||
},
|
||
update(
|
||
stageId: CustomWorldGenerationStageId,
|
||
completed: number,
|
||
options: Partial<{
|
||
phaseDetail: string;
|
||
batchLabel: string;
|
||
}> = {},
|
||
) {
|
||
emit(stageId, {
|
||
completed,
|
||
...options,
|
||
});
|
||
},
|
||
complete(
|
||
stageId: CustomWorldGenerationStageId,
|
||
options: Partial<{
|
||
phaseDetail: string;
|
||
batchLabel: string;
|
||
}> = {},
|
||
) {
|
||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||
if (!stage) {
|
||
return;
|
||
}
|
||
|
||
emit(stageId, {
|
||
completed: stage.total,
|
||
...options,
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
type CustomWorldGenerationReporter = ReturnType<
|
||
typeof createCustomWorldGenerationReporter
|
||
>;
|
||
|
||
async function generateCustomWorldRoleOutlineEntries(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
roleType: CustomWorldGenerationRoleBatchType;
|
||
totalCount: number;
|
||
batchSize: number;
|
||
reporter?: CustomWorldGenerationReporter;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const {
|
||
framework,
|
||
roleType,
|
||
totalCount,
|
||
batchSize,
|
||
reporter = createCustomWorldGenerationReporter(),
|
||
signal,
|
||
} = params;
|
||
const stageId = getCustomWorldGenerationStageIdForRoleOutline(roleType);
|
||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||
let mergedEntries: CustomWorldGenerationRoleOutline[] = [];
|
||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||
|
||
for (
|
||
let batchIndex = 0;
|
||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||
batchIndex += 1
|
||
) {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||
reporter.update(stageId, mergedEntries.length, {
|
||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||
});
|
||
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} 生成失败:模型没有返回有效内容。`,
|
||
signal,
|
||
});
|
||
|
||
mergedEntries = appendUniqueNamedEntries(
|
||
mergedEntries,
|
||
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
|
||
totalCount,
|
||
);
|
||
reporter.update(stageId, mergedEntries.length, {
|
||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||
});
|
||
|
||
if (batchCount <= 0) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function generateCustomWorldLandmarkSeedEntries(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
totalCount: number;
|
||
batchSize: number;
|
||
reporter?: CustomWorldGenerationReporter;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const {
|
||
framework,
|
||
totalCount,
|
||
batchSize,
|
||
reporter = createCustomWorldGenerationReporter(),
|
||
signal,
|
||
} = params;
|
||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||
let mergedEntries: CustomWorldGenerationLandmarkOutline[] = [];
|
||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||
|
||
for (
|
||
let batchIndex = 0;
|
||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||
batchIndex += 1
|
||
) {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||
reporter.update('landmark-seed', mergedEntries.length, {
|
||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||
});
|
||
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} 生成失败:模型没有返回有效内容。`,
|
||
signal,
|
||
});
|
||
|
||
mergedEntries = appendUniqueNamedEntries(
|
||
mergedEntries,
|
||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||
totalCount,
|
||
);
|
||
reporter.update('landmark-seed', mergedEntries.length, {
|
||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||
});
|
||
|
||
if (batchCount <= 0) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function expandCustomWorldRoleEntries<
|
||
T extends MergeableCustomWorldRoleEntry,
|
||
>(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
roleType: CustomWorldGenerationRoleBatchType;
|
||
baseEntries: T[];
|
||
batchSize: number;
|
||
reporter?: CustomWorldGenerationReporter;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const {
|
||
framework,
|
||
roleType,
|
||
baseEntries,
|
||
batchSize,
|
||
reporter = createCustomWorldGenerationReporter(),
|
||
signal,
|
||
} = params;
|
||
const roleBatchSource =
|
||
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
|
||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||
const plannedBatchCount = Math.max(
|
||
1,
|
||
Math.ceil(roleBatchSource.length / batchSize),
|
||
);
|
||
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> =
|
||
{
|
||
narrative: 0,
|
||
dossier: 0,
|
||
};
|
||
|
||
const requestBatchStage = async (
|
||
roleBatch: typeof roleBatchSource,
|
||
batchIndex: number,
|
||
stage: CustomWorldGenerationRoleBatchStage,
|
||
) => {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
|
||
const stageId = getCustomWorldGenerationStageIdForRoleExpansion(
|
||
roleType,
|
||
stage,
|
||
);
|
||
reporter.update(stageId, processedByStage[stage], {
|
||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||
});
|
||
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}生成失败:模型没有返回有效内容。`,
|
||
signal,
|
||
});
|
||
|
||
mergedEntries = mergeRoleBatchDetails(
|
||
mergedEntries,
|
||
toRecordArray(
|
||
stageRaw && typeof stageRaw === 'object'
|
||
? (stageRaw as Record<string, unknown>)[
|
||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||
]
|
||
: [],
|
||
),
|
||
);
|
||
processedByStage[stage] = Math.min(
|
||
roleBatchSource.length,
|
||
processedByStage[stage] + roleBatch.length,
|
||
);
|
||
reporter.update(stageId, processedByStage[stage], {
|
||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||
});
|
||
};
|
||
|
||
for (const [batchIndex, roleBatch] of chunkArray(
|
||
roleBatchSource,
|
||
batchSize,
|
||
).entries()) {
|
||
await requestBatchStage(roleBatch, batchIndex, 'narrative');
|
||
await requestBatchStage(roleBatch, batchIndex, 'dossier');
|
||
}
|
||
|
||
return mergedEntries;
|
||
}
|
||
|
||
async function generateCustomWorldThemePackWithAi(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const { framework, signal } = params;
|
||
const fallback = buildThemePackFromWorldProfile({
|
||
...framework,
|
||
templateWorldType:
|
||
framework.templateWorldType === WorldType.XIANXIA
|
||
? WorldType.XIANXIA
|
||
: WorldType.WUXIA,
|
||
});
|
||
const raw = await requestCustomWorldJsonStage({
|
||
userPrompt: buildCustomWorldThemePackPrompt({ framework }),
|
||
debugLabel: 'custom-world-theme-pack',
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
|
||
repairDebugLabel: 'custom-world-theme-pack-json-repair',
|
||
emptyResponseMessage:
|
||
'自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
|
||
signal,
|
||
});
|
||
|
||
return normalizeThemePack(raw, fallback);
|
||
}
|
||
|
||
async function generateCustomWorldStoryGraphWithAi(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
themePack: ThemePack;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const { framework, themePack, signal } = params;
|
||
const profileSeed = buildExpandedCustomWorldProfile(
|
||
buildCustomWorldRawProfileFromFramework(framework),
|
||
framework.settingText,
|
||
);
|
||
const fallback = buildFallbackWorldStoryGraph(
|
||
{
|
||
...profileSeed,
|
||
themePack,
|
||
},
|
||
themePack,
|
||
);
|
||
const raw = await requestCustomWorldJsonStage({
|
||
userPrompt: buildCustomWorldStoryGraphPrompt({
|
||
framework,
|
||
themePack,
|
||
}),
|
||
debugLabel: 'custom-world-story-graph',
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
|
||
repairDebugLabel: 'custom-world-story-graph-json-repair',
|
||
emptyResponseMessage:
|
||
'自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
|
||
signal,
|
||
});
|
||
|
||
return normalizeWorldStoryGraph(raw, fallback);
|
||
}
|
||
|
||
async function expandCustomWorldActorNarrativeProfiles<
|
||
T extends MergeableCustomWorldRoleEntry,
|
||
>(params: {
|
||
framework: CustomWorldGenerationFramework;
|
||
roleType: CustomWorldGenerationRoleBatchType;
|
||
baseEntries: T[];
|
||
batchSize: number;
|
||
themePack: ThemePack;
|
||
storyGraph: WorldStoryGraph;
|
||
reporter?: CustomWorldGenerationReporter;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const {
|
||
framework,
|
||
roleType,
|
||
baseEntries,
|
||
batchSize,
|
||
themePack,
|
||
storyGraph,
|
||
reporter = createCustomWorldGenerationReporter(),
|
||
signal,
|
||
} = params;
|
||
const roleBatchSource = baseEntries;
|
||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
|
||
const plannedBatchCount = Math.max(
|
||
1,
|
||
Math.ceil(roleBatchSource.length / batchSize),
|
||
);
|
||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||
let processedCount = 0;
|
||
|
||
for (const [batchIndex, roleBatch] of chunkArray(
|
||
roleBatchSource,
|
||
batchSize,
|
||
).entries()) {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
reporter.update(stageId, processedCount, {
|
||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||
});
|
||
const stageRaw = await requestCustomWorldJsonStage({
|
||
userPrompt: buildCustomWorldActorNarrativeProfileBatchPrompt({
|
||
framework,
|
||
roleType,
|
||
roleBatch: roleBatch as Array<Record<string, unknown>>,
|
||
themePack,
|
||
storyGraph,
|
||
}),
|
||
debugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}`,
|
||
repairPromptBuilder: (responseText) =>
|
||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt({
|
||
responseText,
|
||
roleType,
|
||
expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)),
|
||
}),
|
||
repairDebugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}-json-repair`,
|
||
emptyResponseMessage: `自定义世界${roleLabel}叙事档案批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||
signal,
|
||
});
|
||
|
||
mergedEntries = mergeRoleBatchDetails(
|
||
mergedEntries,
|
||
toRecordArray(
|
||
stageRaw && typeof stageRaw === 'object'
|
||
? (stageRaw as Record<string, unknown>)[
|
||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||
]
|
||
: [],
|
||
),
|
||
);
|
||
processedCount = Math.min(
|
||
roleBatchSource.length,
|
||
processedCount + roleBatch.length,
|
||
);
|
||
reporter.update(stageId, processedCount, {
|
||
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}。`,
|
||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||
});
|
||
}
|
||
|
||
return mergedEntries.map((entry) => {
|
||
const item = entry as Record<string, unknown>;
|
||
const fallbackProfile = buildFallbackActorNarrativeProfile(
|
||
entry as unknown as CustomWorldProfile['storyNpcs'][number],
|
||
storyGraph,
|
||
themePack,
|
||
);
|
||
|
||
return {
|
||
...entry,
|
||
narrativeProfile: normalizeActorNarrativeProfile(
|
||
item.narrativeProfile,
|
||
fallbackProfile,
|
||
),
|
||
} as T;
|
||
});
|
||
}
|
||
|
||
async function parseCustomWorldStageResponseJson(params: {
|
||
responseText: string;
|
||
repairPrompt: string;
|
||
repairDebugLabel: string;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const { responseText, repairPrompt, repairDebugLabel, signal } = params;
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
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,
|
||
signal,
|
||
},
|
||
);
|
||
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
return parseJsonResponseTextFromParser(
|
||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function requestCustomWorldJsonStage(params: {
|
||
userPrompt: string;
|
||
debugLabel: string;
|
||
repairPromptBuilder: (responseText: string) => string;
|
||
repairDebugLabel: string;
|
||
emptyResponseMessage: string;
|
||
signal?: AbortSignal;
|
||
}) {
|
||
const {
|
||
userPrompt,
|
||
debugLabel,
|
||
repairPromptBuilder,
|
||
repairDebugLabel,
|
||
emptyResponseMessage,
|
||
signal,
|
||
} = 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 {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
const responseText = await requestPlainTextCompletionFromClient(
|
||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||
userPrompt,
|
||
{
|
||
timeoutMs,
|
||
debugLabel:
|
||
attemptIndex === 0
|
||
? debugLabel
|
||
: `${debugLabel}-retry-${attemptIndex + 1}`,
|
||
signal,
|
||
},
|
||
);
|
||
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,
|
||
signal,
|
||
});
|
||
}
|
||
|
||
function buildFunctionContext(
|
||
worldType: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
): FunctionAvailabilityContext {
|
||
return {
|
||
worldType,
|
||
playerCharacter: character,
|
||
inBattle: context.inBattle,
|
||
currentSceneId: context.sceneId,
|
||
currentSceneName: context.sceneName,
|
||
monsters,
|
||
playerHp: context.playerHp,
|
||
playerMaxHp: context.playerMaxHp,
|
||
playerMana: context.playerMana,
|
||
playerMaxMana: context.playerMaxMana,
|
||
};
|
||
}
|
||
|
||
function normalizeEncounterResult(
|
||
raw: unknown,
|
||
worldType: WorldType,
|
||
context: StoryGenerationContext,
|
||
): SceneEncounterResult | undefined {
|
||
if (!raw || typeof raw !== 'object') return undefined;
|
||
|
||
const scene = getScenePresetById(worldType, context.sceneId);
|
||
const item = raw as Record<string, unknown>;
|
||
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
|
||
|
||
if (kind === 'monster') {
|
||
const fallbackHostileNpc = scene?.npcs.find((npc: SceneNpc) =>
|
||
isHostileSceneNpc(npc),
|
||
);
|
||
|
||
return fallbackHostileNpc
|
||
? { kind: 'npc', npcId: fallbackHostileNpc.id }
|
||
: { kind: 'none' };
|
||
}
|
||
|
||
if (kind === 'npc') {
|
||
const npcId = typeof item.npcId === 'string' ? item.npcId.trim() : '';
|
||
const isValidNpc =
|
||
scene?.npcs?.some((npc: SceneNpc) => npc.id === npcId) ?? false;
|
||
return isValidNpc ? { kind: 'npc', npcId } : { kind: 'none' };
|
||
}
|
||
|
||
if (kind === 'treasure') {
|
||
return {
|
||
kind: 'treasure',
|
||
treasureText:
|
||
typeof item.treasureText === 'string'
|
||
? item.treasureText.trim()
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
return { kind: 'none' };
|
||
}
|
||
|
||
function buildEncounterDrivenResolution(
|
||
worldType: WorldType,
|
||
inputMonsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
encounter: SceneEncounterResult | undefined,
|
||
) {
|
||
const scene = getScenePresetById(worldType, context.sceneId);
|
||
|
||
if (!context.pendingSceneEncounter) {
|
||
return {
|
||
monsters: inputMonsters,
|
||
inBattle: context.inBattle,
|
||
encounter: undefined,
|
||
};
|
||
}
|
||
|
||
if (encounter?.kind === 'npc') {
|
||
const sceneNpc = scene?.npcs.find(
|
||
(npc: SceneNpc) => npc.id === encounter.npcId,
|
||
);
|
||
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
|
||
return {
|
||
monsters: createSceneHostileNpcsFromEncounters(
|
||
worldType,
|
||
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
|
||
context.playerX,
|
||
),
|
||
inBattle: true,
|
||
encounter,
|
||
};
|
||
}
|
||
|
||
return {
|
||
monsters: [],
|
||
inBattle: false,
|
||
encounter,
|
||
};
|
||
}
|
||
|
||
return {
|
||
monsters: [],
|
||
inBattle: false,
|
||
encounter: encounter ?? { kind: 'none' as const },
|
||
};
|
||
}
|
||
|
||
function resolveSafeGeneratedActionText(actionText: string | undefined) {
|
||
const trimmed = actionText?.trim();
|
||
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
|
||
return undefined;
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
function resolveOptionsFromFunctionIds(
|
||
items: RawOptionItem[],
|
||
worldType: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
): StoryOption[] {
|
||
const functionContext = buildFunctionContext(
|
||
worldType,
|
||
character,
|
||
monsters,
|
||
context,
|
||
);
|
||
|
||
return items
|
||
.map((item) =>
|
||
resolveFunctionOption(
|
||
item.functionId,
|
||
functionContext,
|
||
resolveSafeGeneratedActionText(item.actionText),
|
||
),
|
||
)
|
||
.filter(Boolean) as StoryOption[];
|
||
}
|
||
|
||
function cloneStoryOption(option: StoryOption): StoryOption {
|
||
return {
|
||
...option,
|
||
visuals: {
|
||
...option.visuals,
|
||
monsterChanges: option.visuals.monsterChanges.map((change) => ({
|
||
...change,
|
||
})),
|
||
},
|
||
interaction: option.interaction ? { ...option.interaction } : undefined,
|
||
skillProbabilities: option.skillProbabilities
|
||
? { ...option.skillProbabilities }
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
function resolveOptionsFromProvidedOptions(
|
||
items: RawOptionItem[],
|
||
availableOptions: StoryOption[],
|
||
): StoryOption[] {
|
||
if (items.length === 0) {
|
||
return availableOptions.map(cloneStoryOption);
|
||
}
|
||
|
||
const optionBuckets = new Map<string, StoryOption[]>();
|
||
const consumedOptions = new Set<StoryOption>();
|
||
availableOptions.forEach((option) => {
|
||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||
bucket.push(option);
|
||
optionBuckets.set(option.functionId, bucket);
|
||
});
|
||
|
||
const resolved: StoryOption[] = [];
|
||
|
||
items.forEach((item) => {
|
||
const bucket = optionBuckets.get(item.functionId);
|
||
const matchedOption = bucket?.shift();
|
||
if (!matchedOption) return;
|
||
consumedOptions.add(matchedOption);
|
||
|
||
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
|
||
resolved.push({
|
||
...cloneStoryOption(matchedOption),
|
||
actionText: rewrittenText || matchedOption.actionText,
|
||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||
} satisfies StoryOption);
|
||
});
|
||
|
||
if (resolved.length === availableOptions.length) {
|
||
return resolved;
|
||
}
|
||
|
||
const remainingOptions = availableOptions.filter(
|
||
(option) => !consumedOptions.has(option),
|
||
);
|
||
|
||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||
}
|
||
|
||
function resolveOptionsFromOptionCatalog(
|
||
items: RawOptionItem[],
|
||
optionCatalog: StoryOption[],
|
||
): StoryOption[] {
|
||
if (items.length === 0) {
|
||
return optionCatalog.map(cloneStoryOption);
|
||
}
|
||
|
||
const optionBuckets = new Map<string, StoryOption[]>();
|
||
optionCatalog.forEach((option) => {
|
||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||
bucket.push(option);
|
||
optionBuckets.set(option.functionId, bucket);
|
||
});
|
||
|
||
const resolved: StoryOption[] = [];
|
||
|
||
items.forEach((item) => {
|
||
const bucket = optionBuckets.get(item.functionId);
|
||
const matchedOption = bucket?.shift();
|
||
if (!matchedOption) return;
|
||
|
||
const rewrittenText = resolveSafeGeneratedActionText(item.actionText);
|
||
resolved.push({
|
||
...cloneStoryOption(matchedOption),
|
||
actionText: rewrittenText || matchedOption.actionText,
|
||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||
} satisfies StoryOption);
|
||
});
|
||
|
||
return resolved;
|
||
}
|
||
|
||
function getFallbackOptions(
|
||
worldType: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
): StoryOption[] {
|
||
const functionContext = buildFunctionContext(
|
||
worldType,
|
||
character,
|
||
monsters,
|
||
context,
|
||
);
|
||
|
||
return resolveOptionsFromFunctionIds(
|
||
getDefaultFunctionIdsForContext(functionContext).map((functionId) => ({
|
||
functionId,
|
||
})),
|
||
worldType,
|
||
character,
|
||
monsters,
|
||
context,
|
||
);
|
||
}
|
||
|
||
export const generateInitialStoryStrict = generateInitialStoryFromServer;
|
||
|
||
export const generateNextStepStrict = generateNextStepFromServer;
|
||
|
||
export async function generateCustomWorldSceneImage({
|
||
profile,
|
||
landmark,
|
||
userPrompt,
|
||
prompt,
|
||
negativePrompt,
|
||
size = '1280*720',
|
||
referenceImageSrc,
|
||
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
|
||
const resolvedPrompt = prompt?.trim() || userPrompt?.trim() || '';
|
||
const resolvedNegativePrompt =
|
||
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(
|
||
() => controller.abort(),
|
||
CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS,
|
||
);
|
||
|
||
try {
|
||
const response = await fetchWithApiAuth(
|
||
CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
profileId: profile.id,
|
||
worldName: profile.name,
|
||
landmarkId: landmark.id,
|
||
landmarkName: landmark.name,
|
||
...(prompt?.trim() ? { prompt: prompt.trim() } : {}),
|
||
userPrompt: resolvedPrompt,
|
||
negativePrompt: resolvedNegativePrompt,
|
||
size,
|
||
profile: {
|
||
id: profile.id,
|
||
name: profile.name,
|
||
subtitle: profile.subtitle,
|
||
summary: profile.summary,
|
||
tone: profile.tone,
|
||
playerGoal: profile.playerGoal,
|
||
settingText: profile.settingText,
|
||
},
|
||
landmark: {
|
||
id: landmark.id,
|
||
name: landmark.name,
|
||
description: landmark.description,
|
||
},
|
||
...(referenceImageSrc?.trim()
|
||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||
: {}),
|
||
}),
|
||
signal: controller.signal,
|
||
},
|
||
);
|
||
const responseText = await response.text();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
normalizeApiErrorMessage(responseText, '场景图片生成失败。'),
|
||
);
|
||
}
|
||
|
||
const data = unwrapApiResponse(
|
||
JSON.parse(responseText) as Partial<CustomWorldSceneImageResult>,
|
||
) as Partial<CustomWorldSceneImageResult>;
|
||
if (
|
||
!data.imageSrc ||
|
||
!data.assetId ||
|
||
!data.model ||
|
||
!data.size ||
|
||
!data.taskId
|
||
) {
|
||
throw new Error('场景图片生成服务返回的数据不完整,请稍后重试。');
|
||
}
|
||
|
||
return {
|
||
imageSrc: data.imageSrc,
|
||
assetId: data.assetId,
|
||
model: data.model,
|
||
size: data.size,
|
||
taskId: data.taskId,
|
||
prompt: data.prompt || resolvedPrompt,
|
||
actualPrompt:
|
||
typeof data.actualPrompt === 'string' && data.actualPrompt.trim()
|
||
? data.actualPrompt
|
||
: undefined,
|
||
};
|
||
} catch (error) {
|
||
if (
|
||
typeof DOMException !== 'undefined' &&
|
||
error instanceof DOMException &&
|
||
error.name === 'AbortError'
|
||
) {
|
||
throw new Error('场景图片生成超时,请稍后重试。');
|
||
}
|
||
if (error instanceof TypeError) {
|
||
throw new Error('无法连接场景图片生成服务,请确认本地开发服务器已启动。');
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
export async function generateCustomWorldProfile(
|
||
input: string | GenerateCustomWorldProfileInput,
|
||
options: GenerateCustomWorldProfileOptions = {},
|
||
): Promise<CustomWorldProfile> {
|
||
const {
|
||
settingText: normalizedSettingText,
|
||
generationSeedText,
|
||
creatorIntent,
|
||
generationMode,
|
||
} = resolveCustomWorldGenerationInput(input);
|
||
const generationTargets = getCustomWorldGenerationTargets(generationMode);
|
||
const reporter = createCustomWorldGenerationReporter(options.onProgress);
|
||
const signal = options.signal;
|
||
|
||
try {
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
reporter.begin('framework', {
|
||
phaseDetail: '正在解析你的设定文本,准备搭建世界框架。',
|
||
});
|
||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||
userPrompt: buildCustomWorldFrameworkPrompt(generationSeedText),
|
||
debugLabel: 'custom-world-framework',
|
||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||
emptyResponseMessage: '自定义世界框架生成失败:模型没有返回有效内容。',
|
||
signal,
|
||
});
|
||
const frameworkBase = {
|
||
...normalizeCustomWorldGenerationFramework(
|
||
frameworkRaw,
|
||
generationSeedText,
|
||
),
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
} satisfies CustomWorldGenerationFramework;
|
||
reporter.complete('framework', {
|
||
phaseDetail: '世界框架已确定,开始围绕你的设定继续编译题材层与关键对象。',
|
||
});
|
||
reporter.begin('theme-pack', {
|
||
phaseDetail: '正在提炼题材适配层词汇与命名范式。',
|
||
});
|
||
const themePack = await generateCustomWorldThemePackWithAi({
|
||
framework: frameworkBase,
|
||
signal,
|
||
});
|
||
reporter.complete('theme-pack', {
|
||
phaseDetail: `题材适配层已完成,当前题材包为“${themePack.displayName}”。`,
|
||
});
|
||
|
||
reporter.begin('playable-outline', {
|
||
phaseDetail: '正在生成可扮演角色骨架。',
|
||
});
|
||
const playableNpcs = (await generateCustomWorldRoleOutlineEntries({
|
||
framework: frameworkBase,
|
||
roleType: 'playable',
|
||
totalCount: generationTargets.playableCount,
|
||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||
reporter,
|
||
signal,
|
||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||
reporter.complete('playable-outline', {
|
||
phaseDetail: `可扮演角色骨架已完成,共 ${playableNpcs.length} 名。`,
|
||
});
|
||
const frameworkWithPlayable = {
|
||
...frameworkBase,
|
||
playableNpcs,
|
||
} satisfies CustomWorldGenerationFramework;
|
||
|
||
reporter.begin('story-outline', {
|
||
phaseDetail: '正在生成场景角色骨架。',
|
||
});
|
||
const storyNpcs = (await generateCustomWorldRoleOutlineEntries({
|
||
framework: frameworkWithPlayable,
|
||
roleType: 'story',
|
||
totalCount: generationTargets.storyCount,
|
||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||
reporter,
|
||
signal,
|
||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||
reporter.complete('story-outline', {
|
||
phaseDetail: `场景角色骨架已完成,共 ${storyNpcs.length} 名。`,
|
||
});
|
||
const frameworkWithStory = {
|
||
...frameworkWithPlayable,
|
||
storyNpcs,
|
||
} satisfies CustomWorldGenerationFramework;
|
||
|
||
reporter.begin('landmark-seed', {
|
||
phaseDetail: '正在生成场景骨架。',
|
||
});
|
||
const landmarkSeeds = (await generateCustomWorldLandmarkSeedEntries({
|
||
framework: frameworkWithStory,
|
||
totalCount: generationTargets.landmarkCount,
|
||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||
reporter,
|
||
signal,
|
||
})) as CustomWorldGenerationFramework['landmarks'];
|
||
reporter.complete('landmark-seed', {
|
||
phaseDetail: `场景已完成,共 ${landmarkSeeds.length} 个地标。`,
|
||
});
|
||
|
||
const framework = {
|
||
...frameworkWithStory,
|
||
landmarks: landmarkSeeds,
|
||
} satisfies CustomWorldGenerationFramework;
|
||
if (generationMode === 'full') {
|
||
validateCustomWorldGenerationFramework(framework);
|
||
}
|
||
reporter.begin('story-graph', {
|
||
phaseDetail: '正在生成世界线程、旧伤与意象母题。',
|
||
});
|
||
const storyGraph = await generateCustomWorldStoryGraphWithAi({
|
||
framework,
|
||
themePack,
|
||
signal,
|
||
});
|
||
reporter.complete('story-graph', {
|
||
phaseDetail: `世界线程图谱已完成,当前可见线程 ${storyGraph.visibleThreads.length} 条,暗线 ${storyGraph.hiddenThreads.length} 条。`,
|
||
});
|
||
|
||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||
reporter.begin('playable-narrative', {
|
||
phaseDetail: '正在补充可扮演角色的叙事设定。',
|
||
});
|
||
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
|
||
framework,
|
||
roleType: 'playable',
|
||
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
|
||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||
reporter,
|
||
signal,
|
||
});
|
||
|
||
reporter.begin('story-narrative', {
|
||
phaseDetail: '正在补充场景角色的叙事设定。',
|
||
});
|
||
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
|
||
framework,
|
||
roleType: 'story',
|
||
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
|
||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||
reporter,
|
||
signal,
|
||
});
|
||
const profileSeed = buildExpandedCustomWorldProfile(
|
||
{
|
||
...baseRawProfile,
|
||
playableNpcs: mergedPlayableNpcs,
|
||
storyNpcs: mergedStoryNpcs,
|
||
themePack,
|
||
storyGraph,
|
||
creatorIntent,
|
||
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||
generationMode,
|
||
generationStatus: generationTargets.generationStatus,
|
||
},
|
||
generationSeedText,
|
||
);
|
||
reporter.begin('playable-profile', {
|
||
phaseDetail: '正在补充可扮演角色的叙事档案。',
|
||
});
|
||
const playableNpcsWithNarrativeProfile =
|
||
await expandCustomWorldActorNarrativeProfiles({
|
||
framework,
|
||
roleType: 'playable',
|
||
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
|
||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||
themePack,
|
||
storyGraph,
|
||
reporter,
|
||
signal,
|
||
});
|
||
reporter.complete('playable-profile', {
|
||
phaseDetail: `可扮演角色叙事档案已完成,共 ${playableNpcsWithNarrativeProfile.length} 名。`,
|
||
});
|
||
reporter.begin('story-profile', {
|
||
phaseDetail: '正在补充场景角色的叙事档案。',
|
||
});
|
||
const storyNpcsWithNarrativeProfile =
|
||
await expandCustomWorldActorNarrativeProfiles({
|
||
framework,
|
||
roleType: 'story',
|
||
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
|
||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||
themePack,
|
||
storyGraph,
|
||
reporter,
|
||
signal,
|
||
});
|
||
reporter.complete('story-profile', {
|
||
phaseDetail: `场景角色叙事档案已完成,共 ${storyNpcsWithNarrativeProfile.length} 名。`,
|
||
});
|
||
|
||
reporter.begin('finalize', {
|
||
phaseDetail: '正在归档世界并做完整性校验。',
|
||
});
|
||
throwIfCustomWorldGenerationAborted(signal);
|
||
const profile = buildExpandedCustomWorldProfile(
|
||
{
|
||
...baseRawProfile,
|
||
playableNpcs: playableNpcsWithNarrativeProfile,
|
||
storyNpcs: storyNpcsWithNarrativeProfile,
|
||
themePack,
|
||
storyGraph,
|
||
creatorIntent,
|
||
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||
generationMode,
|
||
generationStatus: generationTargets.generationStatus,
|
||
},
|
||
generationSeedText,
|
||
);
|
||
if (generationMode === 'full') {
|
||
validateGeneratedCustomWorldProfile(profile);
|
||
}
|
||
reporter.complete('finalize', {
|
||
phaseDetail: `世界“${profile.name}”已完成归档。`,
|
||
});
|
||
return {
|
||
...profile,
|
||
settingText: normalizedSettingText || profile.settingText,
|
||
creatorIntent,
|
||
anchorPack:
|
||
profile.anchorPack ??
|
||
buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||
lockState:
|
||
profile.lockState ??
|
||
deriveCustomWorldLockStateFromIntent(creatorIntent),
|
||
generationMode,
|
||
generationStatus: generationTargets.generationStatus,
|
||
items: [],
|
||
};
|
||
} catch (error) {
|
||
if (isCustomWorldGenerationAbortLikeError(error) || signal?.aborted) {
|
||
throw error instanceof Error
|
||
? error
|
||
: new CustomWorldGenerationAbortedError();
|
||
}
|
||
if (error instanceof SyntaxError) {
|
||
throw new Error(
|
||
'自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。',
|
||
);
|
||
}
|
||
if (isLlmTimeoutErrorFromClient(error)) {
|
||
throw new Error(
|
||
'自定义世界生成超时:分阶段生成过程中仍有批次未在限定时间内完成返回。已自动延长重试一次;如果仍失败,请稍后重试或提高 VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS。',
|
||
);
|
||
}
|
||
if (isLlmConnectivityErrorFromClient(error)) {
|
||
throw new Error(
|
||
'自定义世界生成无法连接模型服务,请确认本地开发服务器、模型代理和网络连接可用后再试。',
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export const streamCharacterPanelChatReply =
|
||
streamCharacterPanelChatReplyFromServer;
|
||
|
||
export const generateCharacterPanelChatSuggestions =
|
||
generateCharacterPanelChatSuggestionsFromServer;
|
||
|
||
export const generateCharacterPanelChatSummary =
|
||
generateCharacterPanelChatSummaryFromServer;
|
||
|
||
export const generateInitialStory = generateInitialStoryFromServer;
|
||
|
||
export const generateNextStep = generateNextStepFromServer;
|
||
|
||
export const streamNpcChatDialogue = streamNpcChatDialogueFromServer;
|
||
|
||
export const streamNpcRecruitDialogue = streamNpcRecruitDialogueFromServer;
|