2488 lines
73 KiB
TypeScript
2488 lines
73 KiB
TypeScript
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 {
|
||
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<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 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'
|
||
>;
|
||
prompt?: string;
|
||
negativePrompt?: string;
|
||
size?: 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<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 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<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 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, 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<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 = item.actionText?.trim();
|
||
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 = item.actionText?.trim();
|
||
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 normalizeResponse(
|
||
raw: unknown,
|
||
worldType: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
requestOptions: StoryRequestOptions = {},
|
||
): AIResponse {
|
||
const parsedEncounter = normalizeEncounterResult(
|
||
(raw as Record<string, unknown> | 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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<AIResponse> {
|
||
const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, {
|
||
debugLabel: 'story-completion',
|
||
});
|
||
|
||
return normalizeResponse(
|
||
parseJsonResponseTextFromParser(content),
|
||
worldType,
|
||
character,
|
||
monsters,
|
||
context,
|
||
requestOptions,
|
||
);
|
||
}
|
||
|
||
export async function generateInitialStoryStrict(
|
||
world: WorldType,
|
||
character: Character,
|
||
monsters: SceneHostileNpc[],
|
||
context: StoryGenerationContext,
|
||
requestOptions: StoryRequestOptions = {},
|
||
): Promise<AIResponse> {
|
||
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<AIResponse> {
|
||
return requestCompletion(
|
||
buildUserPrompt(
|
||
world,
|
||
character,
|
||
monsters,
|
||
history,
|
||
context,
|
||
choice,
|
||
requestOptions.availableOptions,
|
||
requestOptions.optionCatalog,
|
||
),
|
||
world,
|
||
character,
|
||
monsters,
|
||
context,
|
||
requestOptions,
|
||
);
|
||
}
|
||
|
||
export async function generateCustomWorldSceneImage({
|
||
profile,
|
||
landmark,
|
||
prompt,
|
||
negativePrompt,
|
||
size = '1280*720',
|
||
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
|
||
const resolvedPrompt =
|
||
prompt?.trim() || buildCustomWorldSceneImagePrompt(profile, landmark);
|
||
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,
|
||
}),
|
||
signal: controller.signal,
|
||
});
|
||
const responseText = await response.text();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
normalizeApiErrorMessage(responseText, '场景图片生成失败。'),
|
||
);
|
||
}
|
||
|
||
const data = JSON.parse(
|
||
responseText,
|
||
) 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: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}。`,
|
||
});
|
||
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<AIResponse> {
|
||
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<AIResponse> {
|
||
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;
|
||
}
|
||
}
|