Files
Genarrative/src/services/ai.ts
高物 323aa94c87
Some checks failed
CI / verify (push) Has been cancelled
Merge remote-tracking branch 'origin/server_node'
2026-04-08 19:16:55 +08:00

2653 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
import {
buildEncounterFromSceneNpc,
getScenePresetById,
isHostileSceneNpc,
} from '../data/scenePresets';
import {
FunctionAvailabilityContext,
getDefaultFunctionIdsForContext,
resolveFunctionOption,
} from '../data/stateFunctions';
import {
AIResponse,
Character,
CharacterChatTurn,
CustomWorldCreatorIntent,
CustomWorldGenerationMode,
CustomWorldProfile,
Encounter,
SceneEncounterResult,
SceneHostileNpc,
SceneNpc,
StoryMoment,
StoryOption,
ThemePack,
WorldStoryGraph,
WorldType,
} from '../types';
import {
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
buildOfflineCharacterPanelChatSummary as buildOfflineCharacterPanelChatSummaryFromFallback,
buildOfflineNpcChatDialogue as buildOfflineNpcChatDialogueFromFallback,
buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback,
} from './aiFallbacks';
import type {
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './aiTypes';
import { fetchWithApiAuth } from './apiClient';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
buildCharacterPanelChatSummaryPrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
CharacterChatPromptContext,
CharacterChatTargetStatus,
} from './characterChatPrompt';
import {
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
buildCustomWorldActorNarrativeProfileBatchPrompt,
buildCustomWorldFrameworkJsonRepairPrompt,
buildCustomWorldFrameworkPrompt,
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
buildCustomWorldLandmarkNetworkBatchPrompt,
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
buildCustomWorldLandmarkSeedBatchPrompt,
buildCustomWorldRawProfileFromFramework,
buildCustomWorldRoleBatchJsonRepairPrompt,
buildCustomWorldRoleBatchPrompt,
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
buildCustomWorldRoleOutlineBatchPrompt,
buildCustomWorldSceneImagePrompt,
buildCustomWorldStoryGraphJsonRepairPrompt,
buildCustomWorldStoryGraphPrompt,
buildCustomWorldThemePackJsonRepairPrompt,
buildCustomWorldThemePackPrompt,
type CustomWorldGenerationFramework,
type CustomWorldGenerationLandmarkOutline,
type CustomWorldGenerationRoleBatchStage,
type CustomWorldGenerationRoleBatchType,
type CustomWorldGenerationRoleOutline,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
normalizeCustomWorldGenerationFramework,
normalizeCustomWorldGenerationLandmarkOutlineBatch,
normalizeCustomWorldGenerationRoleOutlineBatch,
validateCustomWorldGenerationFramework,
validateGeneratedCustomWorldProfile,
} from './customWorld';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
import {
CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
isLlmConnectivityError as isLlmConnectivityErrorFromClient,
isLlmTimeoutError as isLlmTimeoutErrorFromClient,
requestChatMessageContent,
requestPlainTextCompletion as requestPlainTextCompletionFromClient,
streamPlainTextCompletion as streamPlainTextCompletionFromClient,
} from './llmClient';
import {
parseJsonResponseText as parseJsonResponseTextFromParser,
parseLineListContent as parseLineListContentFromParser,
} from './llmParsers';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
buildUserPrompt,
describeWorld,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
SYSTEM_PROMPT,
} from './prompt';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
} from './storyEngine/themePack';
import {
buildFallbackWorldStoryGraph,
normalizeWorldStoryGraph,
} from './storyEngine/worldStoryGraph';
export type {
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './aiTypes';
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
type RawOptionItem = {
functionId: string;
actionText?: string;
};
type MergeableCustomWorldRoleEntry = {
name: string;
};
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
'/api/custom-world/scene-image';
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE = 5;
const CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE = 5;
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE = 5;
const CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE = 3;
const CUSTOM_WORLD_PLAYABLE_BATCH_SIZE = 3;
const CUSTOM_WORLD_STORY_BATCH_SIZE = 5;
const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
const rawValue = Number(ENV.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS);
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
})();
export interface CustomWorldSceneImageRequest {
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'subtitle'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
>;
landmark: Pick<
CustomWorldProfile['landmarks'][number],
'id' | 'name' | 'description' | 'dangerLevel'
>;
userPrompt?: string;
prompt?: string;
negativePrompt?: string;
size?: string;
referenceImageSrc?: string;
}
export interface CustomWorldSceneImageResult {
imageSrc: string;
assetId: string;
model: string;
size: string;
taskId: string;
prompt: string;
actualPrompt?: string;
}
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
{
id: 'framework',
label: '世界框架',
detail: '解析设定文本,确定世界主题、主目标与基础模板。',
total: 1,
weight: 1,
},
{
id: 'theme-pack',
label: '题材适配层',
detail: '提炼制度词汇、禁忌词与命名范式。',
total: 1,
weight: 1,
},
{
id: 'story-graph',
label: '世界线程图谱',
detail: '补出明线、暗线、旧伤与意象母题。',
total: 1,
weight: 1,
},
{
id: 'playable-outline',
label: '可扮演角色骨架',
detail: '先生成可扮演角色名单与核心定位。',
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT /
CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
),
),
},
{
id: 'story-outline',
label: '场景角色骨架',
detail: '补齐世界里的关键角色与势力关系。',
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT /
CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
),
),
},
{
id: 'landmark-seed',
label: '场景骨架',
detail: '生成地标、区域描述与危险等级。',
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
),
),
},
{
id: 'landmark-network',
label: '场景连接',
detail: '建立场景连接关系与场景内角色分布。',
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
),
),
},
{
id: 'playable-narrative',
label: '可扮演角色叙事',
detail: '为可扮演角色补充公开背景、动机与风格。',
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
),
),
},
{
id: 'playable-dossier',
label: '可扮演角色档案',
detail: '补齐技能、好感章节与初始携带信息。',
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
),
),
},
{
id: 'playable-profile',
label: '可扮演角色叙事档案',
detail: '为可扮演角色生成首遇面具、当前压力和暗线钩子。',
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
),
),
},
{
id: 'story-narrative',
label: '场景角色叙事',
detail: '扩写场景角色的关系钩子与叙事位置。',
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
id: 'story-profile',
label: '场景角色叙事档案',
detail: '为场景角色生成首遇面具、当前压力和暗线钩子。',
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
id: 'story-dossier',
label: '场景角色档案',
detail: '补齐场景角色档案与互动素材。',
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
id: 'finalize',
label: '归档世界',
detail: '整理最终世界档案并做完整性校验。',
total: 1,
weight: 1,
},
] as const;
export type CustomWorldGenerationStageId =
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
export interface CustomWorldGenerationStep {
id: CustomWorldGenerationStageId;
label: string;
detail: string;
completed: number;
total: number;
status: 'pending' | 'active' | 'completed';
}
export interface CustomWorldGenerationProgress {
phaseId: CustomWorldGenerationStageId;
phaseLabel: string;
phaseDetail: string;
batchLabel?: string;
overallProgress: number;
completedWeight: number;
totalWeight: number;
elapsedMs: number;
estimatedRemainingMs: number | null;
activeStepIndex: number;
steps: CustomWorldGenerationStep[];
}
export interface GenerateCustomWorldProfileOptions {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
}
export interface GenerateCustomWorldProfileInput {
settingText: string;
creatorIntent?: CustomWorldCreatorIntent | null;
generationMode?: CustomWorldGenerationMode;
}
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
class CustomWorldGenerationAbortedError extends Error {
constructor(message = '世界生成已中断。') {
super(message);
this.name = 'CustomWorldGenerationAbortedError';
}
}
function normalizeApiErrorMessage(
responseText: string,
fallbackMessage: string,
) {
if (!responseText.trim()) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(responseText) as {
error?: { message?: string };
message?: string;
};
if (
typeof parsed.error?.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message;
}
if (typeof parsed.message === 'string' && parsed.message.trim()) {
return parsed.message;
}
} catch {
// Fall through to the raw response text below.
}
return responseText;
}
function resolveCustomWorldGenerationInput(
input: string | GenerateCustomWorldProfileInput,
): {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
} {
if (typeof input === 'string') {
return {
settingText: input.trim(),
generationSeedText: input.trim(),
creatorIntent: null as CustomWorldCreatorIntent | null,
generationMode: 'full' as CustomWorldGenerationMode,
};
}
const normalizedSettingText = input.settingText.trim();
const creatorIntent = input.creatorIntent ?? null;
const generationSeedText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: normalizedSettingText;
return {
settingText: normalizedSettingText,
generationSeedText: generationSeedText.trim(),
creatorIntent,
generationMode:
input.generationMode === 'fast' ? ('fast' as const) : ('full' as const),
};
}
function getCustomWorldGenerationTargets(
generationMode: CustomWorldGenerationMode,
) {
if (generationMode === 'fast') {
return {
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'key_only' as const,
};
}
return {
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'complete' as const,
};
}
function sanitizeJsonLikeText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return '';
}
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
const firstBrace = unfenced.indexOf('{');
const lastBrace = unfenced.lastIndexOf('}');
const extracted =
firstBrace >= 0 && lastBrace > firstBrace
? unfenced.slice(firstBrace, lastBrace + 1)
: unfenced;
return extracted
.replace(/^\uFEFF/u, '')
.replace(/[\u201C\u201D]/gu, '"')
.replace(/[\u2018\u2019]/gu, "'")
.replace(/\u00A0/gu, ' ')
.replace(/,\s*([}\]])/gu, '$1')
.trim();
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
Record<string, unknown>
>)
: [];
}
function getNamedRecordKey(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function chunkArray<T>(items: T[], size: number) {
if (size <= 0) {
return [items];
}
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
function mergeRoleBatchDetails<T extends MergeableCustomWorldRoleEntry>(
baseEntries: T[],
detailEntries: Array<Record<string, unknown>>,
) {
const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
const availableIndexes = new Set(nextEntries.map((_, index) => index));
const indexByName = new Map<string, number>();
nextEntries.forEach((entry, index) => {
const name = getNamedRecordKey(entry.name);
if (name) {
indexByName.set(name, index);
}
});
detailEntries.forEach((detail) => {
const detailName = getNamedRecordKey(detail.name);
let targetIndex =
detailName && indexByName.has(detailName)
? indexByName.get(detailName)
: undefined;
if (targetIndex === undefined) {
for (const index of availableIndexes) {
targetIndex = index;
break;
}
}
if (targetIndex === undefined) {
return;
}
const baseEntry = nextEntries[targetIndex];
if (!baseEntry) {
return;
}
nextEntries[targetIndex] = {
...baseEntry,
...detail,
name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name,
} as T;
availableIndexes.delete(targetIndex);
});
return nextEntries;
}
function appendUniqueNamedEntries<T extends MergeableCustomWorldRoleEntry>(
baseEntries: T[],
nextEntries: T[],
maxCount: number,
) {
const merged = baseEntries.map((entry) => ({ ...entry })) as T[];
const existingNames = new Set(
merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean),
);
nextEntries.forEach((entry) => {
if (merged.length >= maxCount) {
return;
}
const name = getNamedRecordKey(entry.name);
if (!name || existingNames.has(name)) {
return;
}
merged.push({ ...entry, name } as T);
existingNames.add(name);
});
return merged;
}
const CUSTOM_WORLD_GENERATION_STAGE_MAP = new Map(
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, stage]),
);
const CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT =
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, stage) => sum + stage.weight,
0,
);
function getCustomWorldGenerationStageIdForRoleOutline(
roleType: CustomWorldGenerationRoleBatchType,
): CustomWorldGenerationStageId {
return roleType === 'playable' ? 'playable-outline' : 'story-outline';
}
function getCustomWorldGenerationStageIdForRoleExpansion(
roleType: CustomWorldGenerationRoleBatchType,
stage: CustomWorldGenerationRoleBatchStage,
): CustomWorldGenerationStageId {
if (roleType === 'playable') {
return stage === 'narrative' ? 'playable-narrative' : 'playable-dossier';
}
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
}
function getCustomWorldGenerationStageIdForActorProfile(
roleType: CustomWorldGenerationRoleBatchType,
): CustomWorldGenerationStageId {
return roleType === 'playable' ? 'playable-profile' : 'story-profile';
}
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
if (!signal?.aborted) {
return;
}
throw signal.reason instanceof Error
? signal.reason
: new CustomWorldGenerationAbortedError();
}
function isCustomWorldGenerationAbortLikeError(error: unknown) {
return (
error instanceof CustomWorldGenerationAbortedError ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError')
);
}
function createCustomWorldGenerationReporter(
onProgress?: GenerateCustomWorldProfileOptions['onProgress'],
) {
const startedAt = performance.now();
const completedByStage = Object.fromEntries(
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
) as Record<CustomWorldGenerationStageId, number>;
const emit = (
stageId: CustomWorldGenerationStageId,
options: Partial<{
completed: number;
phaseDetail: string;
batchLabel: string;
}> = {},
) => {
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
if (!stage) {
return;
}
if (typeof options.completed === 'number') {
completedByStage[stageId] = Math.max(
0,
Math.min(stage.total, options.completed),
);
}
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
const completed = Math.max(
0,
Math.min(item.total, completedByStage[item.id]),
);
return {
id: item.id,
label: item.label,
detail: item.detail,
completed,
total: item.total,
status:
completed >= item.total
? 'completed'
: item.id === stageId
? 'active'
: 'pending',
} satisfies CustomWorldGenerationStep;
});
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, item) =>
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
0,
);
const progressFraction =
CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT > 0
? completedWeight / CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT
: 0;
const elapsedMs = Math.max(0, performance.now() - startedAt);
const estimatedRemainingMs =
progressFraction > 0 && progressFraction < 1
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
: progressFraction >= 1
? 0
: null;
onProgress?.({
phaseId: stage.id,
phaseLabel: stage.label,
phaseDetail: options.phaseDetail ?? stage.detail,
batchLabel: options.batchLabel,
overallProgress: Math.max(
0,
Math.min(100, Math.round(progressFraction * 100)),
),
completedWeight,
totalWeight: CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT,
elapsedMs: Math.round(elapsedMs),
estimatedRemainingMs,
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
(item) => item.id === stage.id,
),
steps,
});
};
return {
begin(
stageId: CustomWorldGenerationStageId,
options: Partial<{
phaseDetail: string;
batchLabel: string;
}> = {},
) {
emit(stageId, {
completed: completedByStage[stageId],
...options,
});
},
update(
stageId: CustomWorldGenerationStageId,
completed: number,
options: Partial<{
phaseDetail: string;
batchLabel: string;
}> = {},
) {
emit(stageId, {
completed,
...options,
});
},
complete(
stageId: CustomWorldGenerationStageId,
options: Partial<{
phaseDetail: string;
batchLabel: string;
}> = {},
) {
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
if (!stage) {
return;
}
emit(stageId, {
completed: stage.total,
...options,
});
},
};
}
type CustomWorldGenerationReporter = ReturnType<
typeof createCustomWorldGenerationReporter
>;
async function generateCustomWorldRoleOutlineEntries(params: {
framework: CustomWorldGenerationFramework;
roleType: CustomWorldGenerationRoleBatchType;
totalCount: number;
batchSize: number;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
roleType,
totalCount,
batchSize,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const stageId = getCustomWorldGenerationStageIdForRoleOutline(roleType);
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
let mergedEntries: CustomWorldGenerationRoleOutline[] = [];
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
for (
let batchIndex = 0;
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
batchIndex += 1
) {
throwIfCustomWorldGenerationAborted(signal);
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
reporter.update(stageId, mergedEntries.length, {
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}`,
batchLabel: `${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount}`,
});
const batchRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
framework,
roleType,
batchCount,
forbiddenNames: mergedEntries.map((entry) => entry.name),
}),
debugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldRoleOutlineBatchJsonRepairPrompt({
responseText,
roleType,
expectedCount: batchCount,
forbiddenNames: mergedEntries.map((entry) => entry.name),
}),
repairDebugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}名单批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = appendUniqueNamedEntries(
mergedEntries,
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
totalCount,
);
reporter.update(stageId, mergedEntries.length, {
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}`,
batchLabel: `${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount}`,
});
if (batchCount <= 0) {
break;
}
}
return mergedEntries;
}
async function generateCustomWorldLandmarkSeedEntries(params: {
framework: CustomWorldGenerationFramework;
totalCount: number;
batchSize: number;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
totalCount,
batchSize,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
let mergedEntries: CustomWorldGenerationLandmarkOutline[] = [];
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
for (
let batchIndex = 0;
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
batchIndex += 1
) {
throwIfCustomWorldGenerationAborted(signal);
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
reporter.update('landmark-seed', mergedEntries.length, {
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}`,
batchLabel: `${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount}`,
});
const batchRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
framework,
batchCount,
forbiddenNames: mergedEntries.map((entry) => entry.name),
}),
debugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({
responseText,
expectedCount: batchCount,
forbiddenNames: mergedEntries.map((entry) => entry.name),
}),
repairDebugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界场景骨架批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = appendUniqueNamedEntries(
mergedEntries,
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
totalCount,
);
reporter.update('landmark-seed', mergedEntries.length, {
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}`,
batchLabel: `${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount}`,
});
if (batchCount <= 0) {
break;
}
}
return mergedEntries;
}
async function expandCustomWorldLandmarkNetworkEntries(params: {
framework: CustomWorldGenerationFramework;
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
baseEntries: CustomWorldGenerationLandmarkOutline[];
batchSize: number;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
storyNpcs,
baseEntries,
batchSize,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const plannedBatchCount = Math.max(
1,
Math.ceil(framework.landmarks.length / batchSize),
);
let mergedEntries = baseEntries.map((entry) => ({ ...entry }));
let processedCount = 0;
for (const [batchIndex, landmarkBatch] of chunkArray(
framework.landmarks,
batchSize,
).entries()) {
throwIfCustomWorldGenerationAborted(signal);
reporter.update('landmark-network', processedCount, {
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
const batchRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
framework,
landmarkBatch,
storyNpcs,
}),
debugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({
responseText,
expectedNames: landmarkBatch.map((landmark) => landmark.name),
}),
repairDebugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界场景连接批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = mergeRoleBatchDetails(
mergedEntries,
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw).map(
(entry) => ({ ...entry }),
),
);
processedCount = Math.min(
framework.landmarks.length,
processedCount + landmarkBatch.length,
);
reporter.update('landmark-network', processedCount, {
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
}
return mergedEntries;
}
async function expandCustomWorldRoleEntries<
T extends MergeableCustomWorldRoleEntry,
>(params: {
framework: CustomWorldGenerationFramework;
roleType: CustomWorldGenerationRoleBatchType;
baseEntries: T[];
batchSize: number;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
roleType,
baseEntries,
batchSize,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const roleBatchSource =
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
const plannedBatchCount = Math.max(
1,
Math.ceil(roleBatchSource.length / batchSize),
);
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> =
{
narrative: 0,
dossier: 0,
};
const requestBatchStage = async (
roleBatch: typeof roleBatchSource,
batchIndex: number,
stage: CustomWorldGenerationRoleBatchStage,
) => {
throwIfCustomWorldGenerationAborted(signal);
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
const stageId = getCustomWorldGenerationStageIdForRoleExpansion(
roleType,
stage,
);
reporter.update(stageId, processedByStage[stage], {
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
const stageRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldRoleBatchPrompt({
framework,
roleType,
roleBatch,
stage,
}),
debugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldRoleBatchJsonRepairPrompt({
responseText,
roleType,
expectedNames: roleBatch.map((role) => role.name),
stage,
}),
repairDebugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界${roleLabel}批次 ${batchIndex + 1}${stageLabel}生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = mergeRoleBatchDetails(
mergedEntries,
toRecordArray(
stageRaw && typeof stageRaw === 'object'
? (stageRaw as Record<string, unknown>)[
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
]
: [],
),
);
processedByStage[stage] = Math.min(
roleBatchSource.length,
processedByStage[stage] + roleBatch.length,
);
reporter.update(stageId, processedByStage[stage], {
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
};
for (const [batchIndex, roleBatch] of chunkArray(
roleBatchSource,
batchSize,
).entries()) {
await requestBatchStage(roleBatch, batchIndex, 'narrative');
await requestBatchStage(roleBatch, batchIndex, 'dossier');
}
return mergedEntries;
}
async function generateCustomWorldThemePackWithAi(params: {
framework: CustomWorldGenerationFramework;
signal?: AbortSignal;
}) {
const { framework, signal } = params;
const fallback = buildThemePackFromWorldProfile({
...framework,
templateWorldType:
framework.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA,
});
const raw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldThemePackPrompt({ framework }),
debugLabel: 'custom-world-theme-pack',
repairPromptBuilder: (responseText) =>
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-theme-pack-json-repair',
emptyResponseMessage:
'自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
signal,
});
return normalizeThemePack(raw, fallback);
}
async function generateCustomWorldStoryGraphWithAi(params: {
framework: CustomWorldGenerationFramework;
themePack: ThemePack;
signal?: AbortSignal;
}) {
const { framework, themePack, signal } = params;
const profileSeed = buildExpandedCustomWorldProfile(
buildCustomWorldRawProfileFromFramework(framework),
framework.settingText,
);
const fallback = buildFallbackWorldStoryGraph(
{
...profileSeed,
themePack,
},
themePack,
);
const raw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldStoryGraphPrompt({
framework,
themePack,
}),
debugLabel: 'custom-world-story-graph',
repairPromptBuilder: (responseText) =>
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-story-graph-json-repair',
emptyResponseMessage:
'自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
signal,
});
return normalizeWorldStoryGraph(raw, fallback);
}
async function expandCustomWorldActorNarrativeProfiles<
T extends MergeableCustomWorldRoleEntry,
>(params: {
framework: CustomWorldGenerationFramework;
roleType: CustomWorldGenerationRoleBatchType;
baseEntries: T[];
batchSize: number;
themePack: ThemePack;
storyGraph: WorldStoryGraph;
reporter?: CustomWorldGenerationReporter;
signal?: AbortSignal;
}) {
const {
framework,
roleType,
baseEntries,
batchSize,
themePack,
storyGraph,
reporter = createCustomWorldGenerationReporter(),
signal,
} = params;
const roleBatchSource = baseEntries;
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
const plannedBatchCount = Math.max(
1,
Math.ceil(roleBatchSource.length / batchSize),
);
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
let processedCount = 0;
for (const [batchIndex, roleBatch] of chunkArray(
roleBatchSource,
batchSize,
).entries()) {
throwIfCustomWorldGenerationAborted(signal);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
const stageRaw = await requestCustomWorldJsonStage({
userPrompt: buildCustomWorldActorNarrativeProfileBatchPrompt({
framework,
roleType,
roleBatch: roleBatch as Array<Record<string, unknown>>,
themePack,
storyGraph,
}),
debugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}`,
repairPromptBuilder: (responseText) =>
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt({
responseText,
roleType,
expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)),
}),
repairDebugLabel: `custom-world-${roleType}-actor-profile-batch-${batchIndex + 1}-json-repair`,
emptyResponseMessage: `自定义世界${roleLabel}叙事档案批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
signal,
});
mergedEntries = mergeRoleBatchDetails(
mergedEntries,
toRecordArray(
stageRaw && typeof stageRaw === 'object'
? (stageRaw as Record<string, unknown>)[
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
]
: [],
),
);
processedCount = Math.min(
roleBatchSource.length,
processedCount + roleBatch.length,
);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
});
}
return mergedEntries.map((entry) => {
const item = entry as Record<string, unknown>;
const fallbackProfile = buildFallbackActorNarrativeProfile(
entry as unknown as CustomWorldProfile['storyNpcs'][number],
storyGraph,
themePack,
);
return {
...entry,
narrativeProfile: normalizeActorNarrativeProfile(
item.narrativeProfile,
fallbackProfile,
),
} as T;
});
}
async function parseCustomWorldStageResponseJson(params: {
responseText: string;
repairPrompt: string;
repairDebugLabel: string;
signal?: AbortSignal;
}) {
const { responseText, repairPrompt, repairDebugLabel, signal } = params;
throwIfCustomWorldGenerationAborted(signal);
try {
return parseJsonResponseTextFromParser(responseText);
} catch {
const sanitized = sanitizeJsonLikeText(responseText);
if (sanitized && sanitized !== responseText.trim()) {
try {
return parseJsonResponseTextFromParser(sanitized);
} catch {
// Fall through to model-assisted repair.
}
}
const repairedText = await requestPlainTextCompletionFromClient(
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
repairPrompt,
{
timeoutMs: Math.max(
30000,
Math.min(
90000,
Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2),
),
),
debugLabel: repairDebugLabel,
signal,
},
);
throwIfCustomWorldGenerationAborted(signal);
return parseJsonResponseTextFromParser(
sanitizeJsonLikeText(repairedText) || repairedText,
);
}
}
async function requestCustomWorldJsonStage(params: {
userPrompt: string;
debugLabel: string;
repairPromptBuilder: (responseText: string) => string;
repairDebugLabel: string;
emptyResponseMessage: string;
signal?: AbortSignal;
}) {
const {
userPrompt,
debugLabel,
repairPromptBuilder,
repairDebugLabel,
emptyResponseMessage,
signal,
} = params;
const timeoutPlan = [
CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
Math.max(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS, 180000),
].filter((timeoutMs, index, array) => array.indexOf(timeoutMs) === index);
let text = '';
let lastTimeoutError: unknown = null;
for (const [attemptIndex, timeoutMs] of timeoutPlan.entries()) {
try {
throwIfCustomWorldGenerationAborted(signal);
const responseText = await requestPlainTextCompletionFromClient(
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
userPrompt,
{
timeoutMs,
debugLabel:
attemptIndex === 0
? debugLabel
: `${debugLabel}-retry-${attemptIndex + 1}`,
signal,
},
);
text = typeof responseText === 'string' ? responseText : '';
break;
} catch (error) {
if (
isLlmTimeoutErrorFromClient(error) &&
attemptIndex < timeoutPlan.length - 1
) {
lastTimeoutError = error;
continue;
}
throw error;
}
}
if (!text.trim()) {
throw lastTimeoutError ?? new Error(emptyResponseMessage);
}
return parseCustomWorldStageResponseJson({
responseText: text,
repairPrompt: repairPromptBuilder(text),
repairDebugLabel,
signal,
});
}
function buildFunctionContext(
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
): FunctionAvailabilityContext {
return {
worldType,
playerCharacter: character,
inBattle: context.inBattle,
currentSceneId: context.sceneId,
currentSceneName: context.sceneName,
monsters,
playerHp: context.playerHp,
playerMaxHp: context.playerMaxHp,
playerMana: context.playerMana,
playerMaxMana: context.playerMaxMana,
};
}
function normalizeEncounterResult(
raw: unknown,
worldType: WorldType,
context: StoryGenerationContext,
): SceneEncounterResult | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const scene = getScenePresetById(worldType, context.sceneId);
const item = raw as Record<string, unknown>;
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
if (kind === 'monster') {
const fallbackHostileNpc = scene?.npcs.find((npc: SceneNpc) =>
isHostileSceneNpc(npc),
);
return fallbackHostileNpc
? { kind: 'npc', npcId: fallbackHostileNpc.id }
: { kind: 'none' };
}
if (kind === 'npc') {
const npcId = typeof item.npcId === 'string' ? item.npcId.trim() : '';
const isValidNpc =
scene?.npcs?.some((npc: SceneNpc) => npc.id === npcId) ?? false;
return isValidNpc ? { kind: 'npc', npcId } : { kind: 'none' };
}
if (kind === 'treasure') {
return {
kind: 'treasure',
treasureText:
typeof item.treasureText === 'string'
? item.treasureText.trim()
: undefined,
};
}
return { kind: 'none' };
}
function buildEncounterDrivenResolution(
worldType: WorldType,
inputMonsters: SceneHostileNpc[],
context: StoryGenerationContext,
encounter: SceneEncounterResult | undefined,
) {
const scene = getScenePresetById(worldType, context.sceneId);
if (!context.pendingSceneEncounter) {
return {
monsters: inputMonsters,
inBattle: context.inBattle,
encounter: undefined,
};
}
if (encounter?.kind === 'npc') {
const sceneNpc = scene?.npcs.find(
(npc: SceneNpc) => npc.id === encounter.npcId,
);
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
return {
monsters: createSceneHostileNpcsFromEncounters(
worldType,
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
context.playerX,
),
inBattle: true,
encounter,
};
}
return {
monsters: [],
inBattle: false,
encounter,
};
}
return {
monsters: [],
inBattle: false,
encounter: encounter ?? { kind: 'none' as const },
};
}
function resolveSafeGeneratedActionText(actionText: string | undefined) {
const trimmed = actionText?.trim();
if (!trimmed || hasMixedNarrativeLanguage(trimmed)) {
return undefined;
}
return trimmed;
}
function resolveOptionsFromFunctionIds(
items: RawOptionItem[],
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
): StoryOption[] {
const functionContext = buildFunctionContext(
worldType,
character,
monsters,
context,
);
return items
.map((item) =>
resolveFunctionOption(
item.functionId,
functionContext,
resolveSafeGeneratedActionText(item.actionText),
),
)
.filter(Boolean) as StoryOption[];
}
function cloneStoryOption(option: StoryOption): StoryOption {
return {
...option,
visuals: {
...option.visuals,
monsterChanges: option.visuals.monsterChanges.map((change) => ({
...change,
})),
},
interaction: option.interaction ? { ...option.interaction } : undefined,
skillProbabilities: option.skillProbabilities
? { ...option.skillProbabilities }
: undefined,
};
}
function buildCharacterChatPromptContext(
context: StoryGenerationContext,
): CharacterChatPromptContext {
return {
playerHp: context.playerHp,
playerMaxHp: context.playerMaxHp,
playerMana: context.playerMana,
playerMaxMana: context.playerMaxMana,
inBattle: context.inBattle,
playerFacing: context.playerFacing,
playerAnimation: context.playerAnimation,
sceneName: context.sceneName ?? null,
sceneDescription: context.sceneDescription ?? null,
customWorldProfile: context.customWorldProfile ?? null,
};
}
function resolveOptionsFromProvidedOptions(
items: RawOptionItem[],
availableOptions: StoryOption[],
): StoryOption[] {
if (items.length === 0) {
return availableOptions.map(cloneStoryOption);
}
const optionBuckets = new Map<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,
);
}
function buildOfflineResponse(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
choice?: string,
requestOptions: StoryRequestOptions = {},
): AIResponse {
const scene = getScenePresetById(world, context.sceneId);
const fallbackEncounter = context.pendingSceneEncounter
? normalizeEncounterResult(
scene?.npcs[0]
? { kind: 'npc', npcId: scene.npcs[0].id }
: { kind: 'none' },
world,
context,
)
: undefined;
const resolution = buildEncounterDrivenResolution(
world,
monsters,
context,
fallbackEncounter,
);
const constrainedOptions =
requestOptions.availableOptions?.map(cloneStoryOption) ??
requestOptions.optionCatalog?.map(cloneStoryOption);
const options =
constrainedOptions ??
getFallbackOptions(world, character, resolution.monsters, {
...context,
inBattle: resolution.inBattle,
});
const primaryMonster =
resolution.monsters.find((monster) => monster.hp > 0) ??
resolution.monsters[0];
const encounterName = context.encounterName || '前方的人影';
if (!resolution.inBattle || !primaryMonster) {
return {
storyText: constrainedOptions
? choice
? `${encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`
: `${context.sceneName || describeWorld(world)}的气氛仍在缓慢推进,眼前的${encounterName}正等待你的下一步反应。`
: choice
? `主角暂时脱离了正面厮杀,四周重新安静下来,${context.sceneName || describeWorld(world)}的前路正等着继续探索。`
: `主角踏入${describeWorld(world)}世界的${context.sceneName || '前方区域'},眼前暂时没有新的敌对角色逼近。`,
options,
encounter: resolution.encounter,
};
}
return {
storyText: choice
? `主角刚做出新的动作,前方的${primaryMonster.name}${primaryMonster.action},局势仍在持续绷紧。`
: `主角刚踏入战场,前方的${primaryMonster.name}${primaryMonster.action},战斗压力已经逼到眼前。`,
options,
encounter: resolution.encounter,
};
}
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
'如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText);
}
function buildStoryLanguageFallbackText(
context: StoryGenerationContext,
inBattle: boolean,
) {
if (inBattle) {
return '敌意仍压在眼前,战斗局势还没有真正松开。';
}
if (context.encounterName) {
return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
}
return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
}
function finalizeStoryNarrativeLanguage(
response: AIResponse,
context: StoryGenerationContext,
inBattle: boolean,
): AIResponse {
if (!needsStoryLanguageRepair(response)) {
return response;
}
return {
...response,
storyText: buildStoryLanguageFallbackText(context, inBattle),
};
}
async function repairStoryNarrativeLanguage(
response: AIResponse,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions,
) {
const responseBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
response.encounter,
).inBattle;
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
try {
const repairedContent = await requestChatMessageContent(
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
buildStoryLanguageRepairPrompt(response),
{
debugLabel: 'story-language-repair',
},
);
const repairedResponse = normalizeResponse(
parseJsonResponseTextFromParser(repairedContent),
worldType,
character,
monsters,
context,
requestOptions,
);
const repairedBattleState = buildEncounterDrivenResolution(
worldType,
monsters,
context,
repairedResponse.encounter,
).inBattle;
return finalizeStoryNarrativeLanguage(
repairedResponse,
context,
repairedBattleState,
);
} catch (error) {
console.warn('Failed to repair mixed-language story response:', error);
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
}
function normalizeResponse(
raw: unknown,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): AIResponse {
const parsedEncounter = normalizeEncounterResult(
(raw as Record<string, unknown> | null)?.encounter,
worldType,
context,
);
const resolution = buildEncounterDrivenResolution(
worldType,
monsters,
context,
parsedEncounter,
);
const responseContext = {
...context,
inBattle: resolution.inBattle,
};
const fallbackOptions =
requestOptions.availableOptions?.map(cloneStoryOption) ??
requestOptions.optionCatalog?.map(cloneStoryOption) ??
getFallbackOptions(
worldType,
character,
resolution.monsters,
responseContext,
);
if (!raw || typeof raw !== 'object') {
return {
storyText: responseContext.inBattle
? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。'
: '周围暂时平静下来,你可以继续探索或前往别处。',
options: fallbackOptions,
encounter: resolution.encounter,
};
}
const data = raw as Record<string, unknown>;
const rawOptions = Array.isArray(data.options) ? data.options : [];
const optionItems = rawOptions
.map((option) => {
if (!option || typeof option !== 'object') return null;
const item = option as Record<string, unknown>;
const functionId =
typeof item.functionId === 'string' ? item.functionId.trim() : '';
if (!functionId) return null;
return {
functionId,
actionText:
typeof item.actionText === 'string'
? item.actionText.trim()
: undefined,
} satisfies RawOptionItem;
})
.filter(Boolean) as RawOptionItem[];
const options = requestOptions.availableOptions
? resolveOptionsFromProvidedOptions(
optionItems,
requestOptions.availableOptions,
)
: requestOptions.optionCatalog
? resolveOptionsFromOptionCatalog(
optionItems,
requestOptions.optionCatalog,
)
: resolveOptionsFromFunctionIds(
optionItems,
worldType,
character,
resolution.monsters,
responseContext,
);
return {
storyText:
typeof data.storyText === 'string' && data.storyText.trim()
? data.storyText.trim()
: responseContext.inBattle
? '敌人仍在前方压迫而来,战斗还没有结束。'
: '前路重新安静下来,可以继续决定接下来的探索方向。',
options: options.length > 0 ? options : fallbackOptions,
encounter: resolution.encounter,
};
}
async function requestCompletion(
userPrompt: string,
worldType: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, {
debugLabel: 'story-completion',
});
const response = normalizeResponse(
parseJsonResponseTextFromParser(content),
worldType,
character,
monsters,
context,
requestOptions,
);
return repairStoryNarrativeLanguage(
response,
worldType,
character,
monsters,
context,
requestOptions,
);
}
export async function generateInitialStoryStrict(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
return requestCompletion(
buildUserPrompt(
world,
character,
monsters,
[],
context,
undefined,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
}
export async function generateNextStepStrict(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
return requestCompletion(
buildUserPrompt(
world,
character,
monsters,
history,
context,
choice,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
}
export async function generateCustomWorldSceneImage({
profile,
landmark,
userPrompt,
prompt,
negativePrompt,
size = '1280*720',
referenceImageSrc,
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
const resolvedPrompt =
prompt?.trim() ||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
});
const resolvedNegativePrompt =
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS,
);
try {
const response = await fetchWithApiAuth(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profileId: profile.id,
worldName: profile.name,
landmarkId: landmark.id,
landmarkName: landmark.name,
prompt: resolvedPrompt,
negativePrompt: resolvedNegativePrompt,
size,
...(referenceImageSrc?.trim()
? { referenceImageSrc: referenceImageSrc.trim() }
: {}),
}),
signal: controller.signal,
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
normalizeApiErrorMessage(responseText, '场景图片生成失败。'),
);
}
const data = JSON.parse(
responseText,
) as Partial<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 frameworkWithLandmarkSeeds = {
...frameworkWithStory,
landmarks: landmarkSeeds,
} satisfies CustomWorldGenerationFramework;
reporter.begin('landmark-network', {
phaseDetail: '正在建立场景连接与场景角色分布。',
});
const landmarks = (await expandCustomWorldLandmarkNetworkEntries({
framework: frameworkWithLandmarkSeeds,
storyNpcs,
baseEntries: landmarkSeeds,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['landmarks'];
reporter.complete('landmark-network', {
phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`,
});
const framework = {
...frameworkWithStory,
landmarks,
} satisfies CustomWorldGenerationFramework;
if (generationMode === 'full') {
validateCustomWorldGenerationFramework(framework);
}
reporter.begin('story-graph', {
phaseDetail: '正在生成世界线程、旧伤与意象母题。',
});
const storyGraph = await generateCustomWorldStoryGraphWithAi({
framework,
themePack,
signal,
});
reporter.complete('story-graph', {
phaseDetail: `世界线程图谱已完成,当前可见线程 ${storyGraph.visibleThreads.length} 条,暗线 ${storyGraph.hiddenThreads.length} 条。`,
});
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
reporter.begin('playable-narrative', {
phaseDetail: '正在补充可扮演角色的叙事设定。',
});
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
framework,
roleType: 'playable',
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
reporter,
signal,
});
reporter.begin('story-narrative', {
phaseDetail: '正在补充场景角色的叙事设定。',
});
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
framework,
roleType: 'story',
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
reporter,
signal,
});
const profileSeed = buildExpandedCustomWorldProfile(
{
...baseRawProfile,
playableNpcs: mergedPlayableNpcs,
storyNpcs: mergedStoryNpcs,
themePack,
storyGraph,
creatorIntent,
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
},
generationSeedText,
);
reporter.begin('playable-profile', {
phaseDetail: '正在补充可扮演角色的叙事档案。',
});
const playableNpcsWithNarrativeProfile =
await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'playable',
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('playable-profile', {
phaseDetail: `可扮演角色叙事档案已完成,共 ${playableNpcsWithNarrativeProfile.length} 名。`,
});
reporter.begin('story-profile', {
phaseDetail: '正在补充场景角色的叙事档案。',
});
const storyNpcsWithNarrativeProfile =
await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'story',
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('story-profile', {
phaseDetail: `场景角色叙事档案已完成,共 ${storyNpcsWithNarrativeProfile.length} 名。`,
});
reporter.begin('finalize', {
phaseDetail: '正在归档世界并做完整性校验。',
});
throwIfCustomWorldGenerationAborted(signal);
const profile = buildExpandedCustomWorldProfile(
{
...baseRawProfile,
playableNpcs: playableNpcsWithNarrativeProfile,
storyNpcs: storyNpcsWithNarrativeProfile,
themePack,
storyGraph,
creatorIntent,
anchorPack: buildCustomWorldAnchorPackFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
},
generationSeedText,
);
if (generationMode === 'full') {
validateGeneratedCustomWorldProfile(profile);
}
reporter.complete('finalize', {
phaseDetail: `世界“${profile.name}”已完成归档。`,
});
return {
...profile,
settingText: normalizedSettingText || profile.settingText,
creatorIntent,
anchorPack:
profile.anchorPack ??
buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
profile.lockState ??
deriveCustomWorldLockStateFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
items: [],
};
} catch (error) {
if (isCustomWorldGenerationAbortLikeError(error) || signal?.aborted) {
throw error instanceof Error
? error
: new CustomWorldGenerationAbortedError();
}
if (error instanceof SyntaxError) {
throw new Error(
'自定义世界生成失败:模型返回了非严格 JSON且自动修复仍未成功请稍后重试。',
);
}
if (isLlmTimeoutErrorFromClient(error)) {
throw new Error(
'自定义世界生成超时:分阶段生成过程中仍有批次未在限定时间内完成返回。已自动延长重试一次;如果仍失败,请稍后重试或提高 VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS。',
);
}
if (isLlmConnectivityErrorFromClient(error)) {
throw new Error(
'自定义世界生成无法连接模型服务,请确认本地开发服务器、模型代理和网络连接可用后再试。',
);
}
throw error;
}
}
export async function streamCharacterPanelChatReply(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
playerMessage: string,
targetStatus: CharacterChatTargetStatus,
options: TextStreamOptions = {},
) {
const userPrompt = buildCharacterPanelChatPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
});
try {
const reply = await streamPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
userPrompt,
options,
);
return (
reply.trim() ||
buildOfflineCharacterPanelChatReplyFromFallback(
targetCharacter,
playerMessage,
conversationSummary,
)
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText = buildOfflineCharacterPanelChatReplyFromFallback(
targetCharacter,
playerMessage,
conversationSummary,
);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}
export async function generateCharacterPanelChatSuggestions(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSuggestions =
buildOfflineCharacterPanelChatSuggestionsFromFallback(targetCharacter);
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
conversationSummary,
targetStatus,
});
try {
const text = await requestPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
userPrompt,
);
const parsedSuggestions = parseLineListContentFromParser(text, 3);
if (parsedSuggestions.length === 0) {
return fallbackSuggestions;
}
return [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return fallbackSuggestions;
}
throw error;
}
}
export async function generateCharacterPanelChatSummary(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
previousSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSummary = buildOfflineCharacterPanelChatSummaryFromFallback(
targetCharacter,
conversationHistory,
previousSummary,
);
const userPrompt = buildCharacterPanelChatSummaryPrompt({
world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
conversationHistory,
previousSummary,
targetStatus,
});
try {
const text = await requestPlainTextCompletionFromClient(
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
userPrompt,
);
return text.trim() || fallbackSummary;
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return fallbackSummary;
}
throw error;
}
}
export async function generateInitialStory(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
try {
return await requestCompletion(
buildUserPrompt(
world,
character,
monsters,
[],
context,
undefined,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return buildOfflineResponse(
world,
character,
monsters,
context,
undefined,
requestOptions,
);
}
throw error;
}
}
export async function generateNextStep(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
try {
return await requestCompletion(
buildUserPrompt(
world,
character,
monsters,
history,
context,
choice,
requestOptions.availableOptions,
requestOptions.optionCatalog,
),
world,
character,
monsters,
context,
requestOptions,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
return buildOfflineResponse(
world,
character,
monsters,
context,
choice,
requestOptions,
);
}
throw error;
}
}
export async function streamNpcChatDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildStrictNpcChatDialoguePrompt(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
);
try {
return await streamPlainTextCompletionFromClient(
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
userPrompt,
options,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText = buildOfflineNpcChatDialogueFromFallback(
encounter,
topic,
);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}
export async function streamNpcRecruitDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildNpcRecruitDialoguePrompt(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
);
try {
return await streamPlainTextCompletionFromClient(
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
userPrompt,
options,
);
} catch (error) {
if (isLlmConnectivityErrorFromClient(error)) {
const fallbackText =
buildOfflineNpcRecruitDialogueFromFallback(encounter);
options.onUpdate?.(fallbackText);
return fallbackText;
}
throw error;
}
}