Files
Genarrative/src/services/ai.ts
T

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;