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