This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -35,45 +35,34 @@ import {
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 {
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
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';
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,
@@ -105,20 +94,8 @@ import {
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 { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -1388,23 +1365,6 @@ function cloneStoryOption(option: StoryOption): StoryOption {
};
}
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[],
@@ -1505,357 +1465,9 @@ function getFallbackOptions(
);
}
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 || '前方的人影';
export const generateInitialStoryStrict = generateInitialStoryFromServer;
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 const generateNextStepStrict = generateNextStepFromServer;
export async function generateCustomWorldSceneImage({
profile,
@@ -2218,297 +1830,19 @@ export async function generateCustomWorldProfile(
}
}
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,
});
export const streamCharacterPanelChatReply =
streamCharacterPanelChatReplyFromServer;
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 const generateCharacterPanelChatSuggestions =
generateCharacterPanelChatSuggestionsFromServer;
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,
});
export const generateCharacterPanelChatSummary =
generateCharacterPanelChatSummaryFromServer;
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 const generateInitialStory = generateInitialStoryFromServer;
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,
});
export const generateNextStep = generateNextStepFromServer;
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 const streamNpcChatDialogue = streamNpcChatDialogueFromServer;
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;
}
}
export const streamNpcRecruitDialogue = streamNpcRecruitDialogueFromServer;