import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs'; import { buildEncounterFromSceneNpc, getScenePresetById, isHostileSceneNpc, } from '../data/scenePresets'; import { FunctionAvailabilityContext, getDefaultFunctionIdsForContext, resolveFunctionOption, } from '../data/stateFunctions'; import { AIResponse, Character, CharacterChatTurn, CustomWorldCreatorIntent, CustomWorldGenerationMode, CustomWorldProfile, Encounter, SceneEncounterResult, SceneHostileNpc, SceneNpc, StoryMoment, StoryOption, ThemePack, WorldStoryGraph, WorldType, } from '../types'; import { buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback, buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback, buildOfflineCharacterPanelChatSummary as buildOfflineCharacterPanelChatSummaryFromFallback, buildOfflineNpcChatDialogue as buildOfflineNpcChatDialogueFromFallback, buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback, } from './aiFallbacks'; import type { StoryGenerationContext, StoryRequestOptions, TextStreamOptions, } from './aiTypes'; import { fetchWithApiAuth } from './apiClient'; import { buildCharacterPanelChatPrompt, buildCharacterPanelChatSuggestionPrompt, buildCharacterPanelChatSummaryPrompt, CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, CharacterChatPromptContext, CharacterChatTargetStatus, } from './characterChatPrompt'; import { buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt, buildCustomWorldActorNarrativeProfileBatchPrompt, buildCustomWorldFrameworkJsonRepairPrompt, buildCustomWorldFrameworkPrompt, buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt, buildCustomWorldLandmarkNetworkBatchPrompt, buildCustomWorldLandmarkSeedBatchJsonRepairPrompt, buildCustomWorldLandmarkSeedBatchPrompt, buildCustomWorldRawProfileFromFramework, buildCustomWorldRoleBatchJsonRepairPrompt, buildCustomWorldRoleBatchPrompt, buildCustomWorldRoleOutlineBatchJsonRepairPrompt, buildCustomWorldRoleOutlineBatchPrompt, buildCustomWorldSceneImagePrompt, buildCustomWorldStoryGraphJsonRepairPrompt, buildCustomWorldStoryGraphPrompt, buildCustomWorldThemePackJsonRepairPrompt, buildCustomWorldThemePackPrompt, type CustomWorldGenerationFramework, type CustomWorldGenerationLandmarkOutline, type CustomWorldGenerationRoleBatchStage, type CustomWorldGenerationRoleBatchType, type CustomWorldGenerationRoleOutline, 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 { 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, parseLineListContent as parseLineListContentFromParser, } from './llmParsers'; import { hasMixedNarrativeLanguage } from './narrativeLanguage'; import { buildNpcRecruitDialoguePrompt, buildStrictNpcChatDialoguePrompt, buildUserPrompt, describeWorld, NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, SYSTEM_PROMPT, } from './prompt'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, } from './storyEngine/actorNarrativeProfile'; import { buildThemePackFromWorldProfile, normalizeThemePack, } from './storyEngine/themePack'; import { buildFallbackWorldStoryGraph, normalizeWorldStoryGraph, } from './storyEngine/worldStoryGraph'; export type { StoryGenerationContext, StoryRequestOptions, TextStreamOptions, } from './aiTypes'; const ENV: Partial = 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_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(ENV.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS); return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000; })(); export interface CustomWorldSceneImageRequest { profile: Pick< CustomWorldProfile, | 'id' | 'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText' >; landmark: Pick< CustomWorldProfile['landmarks'][number], 'id' | 'name' | 'description' | 'dangerLevel' >; userPrompt?: string; prompt?: string; negativePrompt?: string; size?: string; referenceImageSrc?: string; } export interface CustomWorldSceneImageResult { imageSrc: string; assetId: string; model: string; size: string; taskId: string; prompt: string; actualPrompt?: string; } 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: 'landmark-network', label: '场景连接', detail: '建立场景连接关系与场景内角色分布。', total: MIN_CUSTOM_WORLD_LANDMARK_COUNT, weight: Math.max( 1, Math.ceil( MIN_CUSTOM_WORLD_LANDMARK_COUNT / CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_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']; export interface CustomWorldGenerationStep { id: CustomWorldGenerationStageId; label: string; detail: string; completed: number; total: number; status: 'pending' | 'active' | 'completed'; } export interface CustomWorldGenerationProgress { phaseId: CustomWorldGenerationStageId; phaseLabel: string; phaseDetail: string; batchLabel?: string; overallProgress: number; completedWeight: number; totalWeight: number; elapsedMs: number; estimatedRemainingMs: number | null; activeStepIndex: number; steps: CustomWorldGenerationStep[]; } export interface GenerateCustomWorldProfileOptions { onProgress?: (progress: CustomWorldGenerationProgress) => void; signal?: AbortSignal; } export interface GenerateCustomWorldProfileInput { settingText: string; creatorIntent?: CustomWorldCreatorIntent | null; generationMode?: CustomWorldGenerationMode; } 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 ?? 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 >) : []; } function getNamedRecordKey(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function chunkArray(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( baseEntries: T[], detailEntries: Array>, ) { const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[]; const availableIndexes = new Set(nextEntries.map((_, index) => index)); const indexByName = new Map(); 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( 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; 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 expandCustomWorldLandmarkNetworkEntries(params: { framework: CustomWorldGenerationFramework; storyNpcs: CustomWorldGenerationFramework['storyNpcs']; baseEntries: CustomWorldGenerationLandmarkOutline[]; batchSize: number; reporter?: CustomWorldGenerationReporter; signal?: AbortSignal; }) { const { framework, storyNpcs, baseEntries, batchSize, reporter = createCustomWorldGenerationReporter(), signal, } = params; const plannedBatchCount = Math.max( 1, Math.ceil(framework.landmarks.length / batchSize), ); let mergedEntries = baseEntries.map((entry) => ({ ...entry })); let processedCount = 0; for (const [batchIndex, landmarkBatch] of chunkArray( framework.landmarks, batchSize, ).entries()) { throwIfCustomWorldGenerationAborted(signal); reporter.update('landmark-network', processedCount, { phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`, batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`, }); 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} 生成失败:模型没有返回有效内容。`, signal, }); mergedEntries = mergeRoleBatchDetails( mergedEntries, normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw).map( (entry) => ({ ...entry }), ), ); processedCount = Math.min( framework.landmarks.length, processedCount + landmarkBatch.length, ); reporter.update('landmark-network', processedCount, { phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`, batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`, }); } 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 = { 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)[ 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>, 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)[ 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; 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; 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 buildCharacterChatPromptContext( context: StoryGenerationContext, ): CharacterChatPromptContext { return { playerHp: context.playerHp, playerMaxHp: context.playerMaxHp, playerMana: context.playerMana, playerMaxMana: context.playerMaxMana, inBattle: context.inBattle, playerFacing: context.playerFacing, playerAnimation: context.playerAnimation, sceneName: context.sceneName ?? null, sceneDescription: context.sceneDescription ?? null, customWorldProfile: context.customWorldProfile ?? null, }; } function resolveOptionsFromProvidedOptions( items: RawOptionItem[], availableOptions: StoryOption[], ): StoryOption[] { if (items.length === 0) { return availableOptions.map(cloneStoryOption); } const optionBuckets = new Map(); const consumedOptions = new Set(); 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(); 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, ); } function buildOfflineResponse( world: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, choice?: string, requestOptions: StoryRequestOptions = {}, ): AIResponse { const scene = getScenePresetById(world, context.sceneId); const fallbackEncounter = context.pendingSceneEncounter ? normalizeEncounterResult( scene?.npcs[0] ? { kind: 'npc', npcId: scene.npcs[0].id } : { kind: 'none' }, world, context, ) : undefined; const resolution = buildEncounterDrivenResolution( world, monsters, context, fallbackEncounter, ); const constrainedOptions = requestOptions.availableOptions?.map(cloneStoryOption) ?? requestOptions.optionCatalog?.map(cloneStoryOption); const options = constrainedOptions ?? getFallbackOptions(world, character, resolution.monsters, { ...context, inBattle: resolution.inBattle, }); const primaryMonster = resolution.monsters.find((monster) => monster.hp > 0) ?? resolution.monsters[0]; const encounterName = context.encounterName || '前方的人影'; if (!resolution.inBattle || !primaryMonster) { return { storyText: constrainedOptions ? choice ? `${encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。` : `${context.sceneName || describeWorld(world)}的气氛仍在缓慢推进,眼前的${encounterName}正等待你的下一步反应。` : choice ? `主角暂时脱离了正面厮杀,四周重新安静下来,${context.sceneName || describeWorld(world)}的前路正等着继续探索。` : `主角踏入${describeWorld(world)}世界的${context.sceneName || '前方区域'},眼前暂时没有新的敌对角色逼近。`, options, encounter: resolution.encounter, }; } return { storyText: choice ? `主角刚做出新的动作,前方的${primaryMonster.name}${primaryMonster.action},局势仍在持续绷紧。` : `主角刚踏入战场,前方的${primaryMonster.name}${primaryMonster.action},战斗压力已经逼到眼前。`, options, encounter: resolution.encounter, }; } function buildStoryLanguageRepairPrompt(response: AIResponse) { return [ '请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。', '只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。', '如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。', JSON.stringify( { storyText: response.storyText, encounter: response.encounter ?? null, options: response.options.map((option) => ({ functionId: option.functionId, actionText: option.actionText, })), }, null, 2, ), ].join('\n\n'); } function needsStoryLanguageRepair(response: AIResponse) { return hasMixedNarrativeLanguage(response.storyText); } function buildStoryLanguageFallbackText( context: StoryGenerationContext, inBattle: boolean, ) { if (inBattle) { return '敌意仍压在眼前,战斗局势还没有真正松开。'; } if (context.encounterName) { return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`; } return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`; } function finalizeStoryNarrativeLanguage( response: AIResponse, context: StoryGenerationContext, inBattle: boolean, ): AIResponse { if (!needsStoryLanguageRepair(response)) { return response; } return { ...response, storyText: buildStoryLanguageFallbackText(context, inBattle), }; } async function repairStoryNarrativeLanguage( response: AIResponse, worldType: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, requestOptions: StoryRequestOptions, ) { const responseBattleState = buildEncounterDrivenResolution( worldType, monsters, context, response.encounter, ).inBattle; if (!needsStoryLanguageRepair(response)) { return finalizeStoryNarrativeLanguage( response, context, responseBattleState, ); } try { const repairedContent = await requestChatMessageContent( STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT, buildStoryLanguageRepairPrompt(response), { debugLabel: 'story-language-repair', }, ); const repairedResponse = normalizeResponse( parseJsonResponseTextFromParser(repairedContent), worldType, character, monsters, context, requestOptions, ); const repairedBattleState = buildEncounterDrivenResolution( worldType, monsters, context, repairedResponse.encounter, ).inBattle; return finalizeStoryNarrativeLanguage( repairedResponse, context, repairedBattleState, ); } catch (error) { console.warn('Failed to repair mixed-language story response:', error); return finalizeStoryNarrativeLanguage( response, context, responseBattleState, ); } } function normalizeResponse( raw: unknown, worldType: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): AIResponse { const parsedEncounter = normalizeEncounterResult( (raw as Record | null)?.encounter, worldType, context, ); const resolution = buildEncounterDrivenResolution( worldType, monsters, context, parsedEncounter, ); const responseContext = { ...context, inBattle: resolution.inBattle, }; const fallbackOptions = requestOptions.availableOptions?.map(cloneStoryOption) ?? requestOptions.optionCatalog?.map(cloneStoryOption) ?? getFallbackOptions( worldType, character, resolution.monsters, responseContext, ); if (!raw || typeof raw !== 'object') { return { storyText: responseContext.inBattle ? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。' : '周围暂时平静下来,你可以继续探索或前往别处。', options: fallbackOptions, encounter: resolution.encounter, }; } const data = raw as Record; const rawOptions = Array.isArray(data.options) ? data.options : []; const optionItems = rawOptions .map((option) => { if (!option || typeof option !== 'object') return null; const item = option as Record; const functionId = typeof item.functionId === 'string' ? item.functionId.trim() : ''; if (!functionId) return null; return { functionId, actionText: typeof item.actionText === 'string' ? item.actionText.trim() : undefined, } satisfies RawOptionItem; }) .filter(Boolean) as RawOptionItem[]; const options = requestOptions.availableOptions ? resolveOptionsFromProvidedOptions( optionItems, requestOptions.availableOptions, ) : requestOptions.optionCatalog ? resolveOptionsFromOptionCatalog( optionItems, requestOptions.optionCatalog, ) : resolveOptionsFromFunctionIds( optionItems, worldType, character, resolution.monsters, responseContext, ); return { storyText: typeof data.storyText === 'string' && data.storyText.trim() ? data.storyText.trim() : responseContext.inBattle ? '敌人仍在前方压迫而来,战斗还没有结束。' : '前路重新安静下来,可以继续决定接下来的探索方向。', options: options.length > 0 ? options : fallbackOptions, encounter: resolution.encounter, }; } async function requestCompletion( userPrompt: string, worldType: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, { debugLabel: 'story-completion', }); const response = normalizeResponse( parseJsonResponseTextFromParser(content), worldType, character, monsters, context, requestOptions, ); return repairStoryNarrativeLanguage( response, worldType, character, monsters, context, requestOptions, ); } export async function generateInitialStoryStrict( world: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { return requestCompletion( buildUserPrompt( world, character, monsters, [], context, undefined, requestOptions.availableOptions, requestOptions.optionCatalog, ), world, character, monsters, context, requestOptions, ); } export async function generateNextStepStrict( world: WorldType, character: Character, monsters: SceneHostileNpc[], history: StoryMoment[], choice: string, context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { return requestCompletion( buildUserPrompt( world, character, monsters, history, context, choice, requestOptions.availableOptions, requestOptions.optionCatalog, ), world, character, monsters, context, requestOptions, ); } export async function generateCustomWorldSceneImage({ profile, landmark, userPrompt, prompt, negativePrompt, size = '1280*720', referenceImageSrc, }: CustomWorldSceneImageRequest): Promise { const resolvedPrompt = prompt?.trim() || buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, { hasReferenceImage: Boolean(referenceImageSrc?.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: resolvedPrompt, negativePrompt: resolvedNegativePrompt, size, ...(referenceImageSrc?.trim() ? { referenceImageSrc: referenceImageSrc.trim() } : {}), }), signal: controller.signal, }); const responseText = await response.text(); if (!response.ok) { throw new Error( normalizeApiErrorMessage(responseText, '场景图片生成失败。'), ); } const data = JSON.parse( responseText, ) as Partial; 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 { 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 frameworkWithLandmarkSeeds = { ...frameworkWithStory, landmarks: landmarkSeeds, } satisfies CustomWorldGenerationFramework; reporter.begin('landmark-network', { phaseDetail: '正在建立场景连接与场景角色分布。', }); const landmarks = (await expandCustomWorldLandmarkNetworkEntries({ framework: frameworkWithLandmarkSeeds, storyNpcs, baseEntries: landmarkSeeds, batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE, reporter, signal, })) as CustomWorldGenerationFramework['landmarks']; reporter.complete('landmark-network', { phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`, }); const framework = { ...frameworkWithStory, landmarks, } 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 async function streamCharacterPanelChatReply( world: WorldType, playerCharacter: Character, targetCharacter: Character, storyHistory: StoryMoment[], context: StoryGenerationContext, conversationHistory: CharacterChatTurn[], conversationSummary: string, playerMessage: string, targetStatus: CharacterChatTargetStatus, options: TextStreamOptions = {}, ) { const userPrompt = buildCharacterPanelChatPrompt({ world, playerCharacter, targetCharacter, storyHistory, context: buildCharacterChatPromptContext(context), conversationHistory, conversationSummary, playerMessage, targetStatus, }); try { const reply = await streamPlainTextCompletionFromClient( CHARACTER_PANEL_CHAT_SYSTEM_PROMPT, userPrompt, options, ); return ( reply.trim() || buildOfflineCharacterPanelChatReplyFromFallback( targetCharacter, playerMessage, conversationSummary, ) ); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { const fallbackText = buildOfflineCharacterPanelChatReplyFromFallback( targetCharacter, playerMessage, conversationSummary, ); options.onUpdate?.(fallbackText); return fallbackText; } throw error; } } export async function generateCharacterPanelChatSuggestions( world: WorldType, playerCharacter: Character, targetCharacter: Character, storyHistory: StoryMoment[], context: StoryGenerationContext, conversationHistory: CharacterChatTurn[], conversationSummary: string, targetStatus: CharacterChatTargetStatus, ) { const fallbackSuggestions = buildOfflineCharacterPanelChatSuggestionsFromFallback(targetCharacter); const userPrompt = buildCharacterPanelChatSuggestionPrompt({ world, playerCharacter, targetCharacter, storyHistory, context: buildCharacterChatPromptContext(context), conversationHistory, conversationSummary, targetStatus, }); try { const text = await requestPlainTextCompletionFromClient( CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT, userPrompt, ); const parsedSuggestions = parseLineListContentFromParser(text, 3); if (parsedSuggestions.length === 0) { return fallbackSuggestions; } return [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { return fallbackSuggestions; } throw error; } } export async function generateCharacterPanelChatSummary( world: WorldType, playerCharacter: Character, targetCharacter: Character, storyHistory: StoryMoment[], context: StoryGenerationContext, conversationHistory: CharacterChatTurn[], previousSummary: string, targetStatus: CharacterChatTargetStatus, ) { const fallbackSummary = buildOfflineCharacterPanelChatSummaryFromFallback( targetCharacter, conversationHistory, previousSummary, ); const userPrompt = buildCharacterPanelChatSummaryPrompt({ world, playerCharacter, targetCharacter, storyHistory, context: buildCharacterChatPromptContext(context), conversationHistory, previousSummary, targetStatus, }); try { const text = await requestPlainTextCompletionFromClient( CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT, userPrompt, ); return text.trim() || fallbackSummary; } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { return fallbackSummary; } throw error; } } export async function generateInitialStory( world: WorldType, character: Character, monsters: SceneHostileNpc[], context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { try { return await requestCompletion( buildUserPrompt( world, character, monsters, [], context, undefined, requestOptions.availableOptions, requestOptions.optionCatalog, ), world, character, monsters, context, requestOptions, ); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { return buildOfflineResponse( world, character, monsters, context, undefined, requestOptions, ); } throw error; } } export async function generateNextStep( world: WorldType, character: Character, monsters: SceneHostileNpc[], history: StoryMoment[], choice: string, context: StoryGenerationContext, requestOptions: StoryRequestOptions = {}, ): Promise { try { return await requestCompletion( buildUserPrompt( world, character, monsters, history, context, choice, requestOptions.availableOptions, requestOptions.optionCatalog, ), world, character, monsters, context, requestOptions, ); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { return buildOfflineResponse( world, character, monsters, context, choice, requestOptions, ); } throw error; } } export async function streamNpcChatDialogue( world: WorldType, character: Character, encounter: Encounter, monsters: SceneHostileNpc[], history: StoryMoment[], context: StoryGenerationContext, topic: string, resultSummary: string, options: TextStreamOptions = {}, ) { const userPrompt = buildStrictNpcChatDialoguePrompt( world, character, encounter, monsters, history, context, topic, resultSummary, ); try { return await streamPlainTextCompletionFromClient( NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT, userPrompt, options, ); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { const fallbackText = buildOfflineNpcChatDialogueFromFallback( encounter, topic, ); options.onUpdate?.(fallbackText); return fallbackText; } throw error; } } export async function streamNpcRecruitDialogue( world: WorldType, character: Character, encounter: Encounter, monsters: SceneHostileNpc[], history: StoryMoment[], context: StoryGenerationContext, invitationText: string, recruitSummary: string, options: TextStreamOptions = {}, ) { const userPrompt = buildNpcRecruitDialoguePrompt( world, character, encounter, monsters, history, context, invitationText, recruitSummary, ); try { return await streamPlainTextCompletionFromClient( NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT, userPrompt, options, ); } catch (error) { if (isLlmConnectivityErrorFromClient(error)) { const fallbackText = buildOfflineNpcRecruitDialogueFromFallback(encounter); options.onUpdate?.(fallbackText); return fallbackText; } throw error; } }