This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,50 +1,56 @@
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGenerationProgress,
CustomWorldSessionRecord,
CustomWorldSessionSummary,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from '../../packages/shared/src/contracts/runtime';
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/story';
import { parseApiErrorMessage } from '../../packages/shared/src/http';
import type {
AIResponse,
Character,
CharacterChatTurn,
CustomWorldProfile,
Encounter,
SceneHostileNpc,
StoryMoment,
WorldType,
} from '../types';
import type {
CustomWorldGenerationProgress,
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './ai';
import * as aiClient from './ai';
import {
buildOfflineCharacterPanelChatReply,
buildOfflineCharacterPanelChatSuggestions,
buildOfflineCharacterPanelChatSummary,
buildOfflineNpcChatDialogue,
buildOfflineNpcRecruitDialogue,
} from './aiFallbacks';
import { fetchWithApiAuth, requestJson } from './apiClient';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
buildCharacterPanelChatSummaryPrompt,
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
type CharacterChatTargetStatus,
} from './characterChatPrompt';
import { type CharacterChatTargetStatus } from './characterChatPrompt';
import { parseLineListContent } from './llmParsers';
import {
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
} from './prompt';
const RUNTIME_API_BASE = '/api/runtime';
type LegacyAiModule = typeof import('./ai');
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
async function loadLegacyAiModule() {
if (!legacyAiModulePromise) {
legacyAiModulePromise = import('./ai');
}
return legacyAiModulePromise;
}
async function requestPostJson<T>(
url: string,
payload: unknown,
@@ -63,15 +69,15 @@ async function requestPostJson<T>(
async function requestPlainText(
url: string,
payload: { systemPrompt: string; userPrompt: string },
payload: unknown,
fallbackMessage: string,
) {
return requestPostJson<{ text: string }>(url, payload, fallbackMessage);
return requestPostJson<PlainTextResponse>(url, payload, fallbackMessage);
}
async function requestPlainTextStream(
url: string,
payload: { systemPrompt: string; userPrompt: string },
payload: unknown,
options: TextStreamOptions = {},
) {
const response = await fetchWithApiAuth(url, {
@@ -135,21 +141,6 @@ async function requestPlainTextStream(
return accumulatedText.trim();
}
function buildCharacterChatPromptContext(context: StoryGenerationContext) {
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,
};
}
export async function generateInitialStory(
world: WorldType,
character: Character,
@@ -158,6 +149,7 @@ export async function generateInitialStory(
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateInitialStory(
world,
character,
@@ -167,32 +159,21 @@ export async function generateInitialStory(
);
}
try {
return await requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/initial`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldType: world,
character,
monsters,
context,
requestOptions,
}),
},
'剧情开局生成失败',
);
} catch (error) {
console.warn('[aiService] story/initial fell back to frontend implementation', error);
return aiClient.generateInitialStory(
world,
character,
monsters,
context,
requestOptions,
);
}
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/initial`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldType: world,
character,
monsters,
context,
requestOptions,
}),
},
'剧情开局生成失败',
);
}
export async function generateNextStep(
@@ -205,6 +186,7 @@ export async function generateNextStep(
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateNextStep(
world,
character,
@@ -216,36 +198,23 @@ export async function generateNextStep(
);
}
try {
return await requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldType: world,
character,
monsters,
history,
choice,
context,
requestOptions,
}),
},
'剧情续写失败',
);
} catch (error) {
console.warn('[aiService] story/continue fell back to frontend implementation', error);
return aiClient.generateNextStep(
world,
character,
monsters,
history,
choice,
context,
requestOptions,
);
}
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worldType: world,
character,
monsters,
history,
choice,
context,
requestOptions,
}),
},
'剧情续写失败',
);
}
export async function generateCharacterPanelChatSuggestions(
@@ -258,58 +227,37 @@ export async function generateCharacterPanelChatSuggestions(
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSuggestions =
buildOfflineCharacterPanelChatSuggestions(targetCharacter);
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
world,
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCharacterPanelChatSuggestions(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
context,
conversationHistory,
conversationSummary,
targetStatus,
});
} satisfies CharacterChatSuggestionsRequest;
if (typeof window === 'undefined') {
return aiClient.generateCharacterPanelChatSuggestions(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
);
}
try {
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/suggestions`,
{
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
userPrompt,
},
'角色聊天建议生成失败',
);
const parsedSuggestions = parseLineListContent(text, 3);
return parsedSuggestions.length > 0
? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3)
: fallbackSuggestions;
} catch (error) {
console.warn('[aiService] character suggestions fell back to frontend implementation', error);
return aiClient.generateCharacterPanelChatSuggestions(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
);
}
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/suggestions`,
payload,
'角色聊天建议生成失败',
);
return parseLineListContent(text, 3);
}
export async function generateCharacterPanelChatSummary(
@@ -322,64 +270,43 @@ export async function generateCharacterPanelChatSummary(
previousSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const fallbackSummary = buildOfflineCharacterPanelChatSummary(
targetCharacter,
conversationHistory,
previousSummary,
);
const userPrompt = buildCharacterPanelChatSummaryPrompt({
world,
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCharacterPanelChatSummary(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
context,
conversationHistory,
previousSummary,
targetStatus,
});
} satisfies CharacterChatSummaryRequest;
if (typeof window === 'undefined') {
return aiClient.generateCharacterPanelChatSummary(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
);
}
try {
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/summary`,
{
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
userPrompt,
},
'角色聊天摘要生成失败',
);
return text.trim() || fallbackSummary;
} catch (error) {
console.warn('[aiService] character summary fell back to frontend implementation', error);
return aiClient.generateCharacterPanelChatSummary(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
);
}
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/summary`,
payload,
'角色聊天摘要生成失败',
);
return text.trim();
}
export async function generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
) {
): Promise<CustomWorldProfile> {
const normalizedInput =
typeof input === 'string'
? {
@@ -534,14 +461,13 @@ export async function generateCustomWorldProfile(
throw new Error('自定义世界生成未返回结果');
}
return latestProfile as unknown as Awaited<
ReturnType<typeof aiClient.generateCustomWorldProfile>
>;
return latestProfile as unknown as CustomWorldProfile;
}
export async function generateCustomWorldSceneImage(
...args: Parameters<typeof aiClient.generateCustomWorldSceneImage>
...args: [CustomWorldSceneImageRequest]
) {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldSceneImage(...args);
}
@@ -550,41 +476,19 @@ export async function createCustomWorldSession(payload: {
creatorIntent?: Record<string, unknown> | null;
generationMode: 'fast' | 'full';
}) {
return requestJson<{
sessionId: string;
status: string;
questions: Array<{
id: string;
label: string;
question: string;
answer?: string;
}>;
}>(
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
},
'创建自定义世界会话失败',
);
}
export async function getCustomWorldSession(sessionId: string) {
return requestJson<{
sessionId: string;
status: string;
settingText: string;
generationMode: string;
questions: Array<{
id: string;
label: string;
question: string;
answer?: string;
}>;
result?: Record<string, unknown>;
lastError?: string;
}>(
return requestJson<CustomWorldSessionRecord>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
@@ -597,21 +501,12 @@ export async function answerCustomWorldSessionQuestion(
sessionId: string,
payload: { questionId: string; answer: string },
) {
return requestJson<{
sessionId: string;
status: string;
questions: Array<{
id: string;
label: string;
question: string;
answer?: string;
}>;
}>(
return requestJson<CustomWorldSessionSummary>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
},
'提交自定义世界补充设定失败',
);
@@ -629,65 +524,40 @@ export async function streamCharacterPanelChatReply(
targetStatus: CharacterChatTargetStatus,
options: TextStreamOptions = {},
) {
const userPrompt = buildCharacterPanelChatPrompt({
world,
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamCharacterPanelChatReply(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
options,
);
}
const payload = {
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context: buildCharacterChatPromptContext(context),
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
});
} satisfies CharacterChatReplyRequest;
if (typeof window === 'undefined') {
return aiClient.streamCharacterPanelChatReply(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
options,
);
}
try {
const reply = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
{
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
userPrompt,
},
options,
);
return (
reply.trim() ||
buildOfflineCharacterPanelChatReply(
targetCharacter,
playerMessage,
conversationSummary,
)
);
} catch (error) {
console.warn('[aiService] character reply stream fell back to frontend implementation', error);
return aiClient.streamCharacterPanelChatReply(
world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
options,
);
}
const reply = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
payload,
options,
);
return reply.trim();
}
export async function streamNpcChatDialogue(
@@ -701,8 +571,23 @@ export async function streamNpcChatDialogue(
resultSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildStrictNpcChatDialoguePrompt(
world,
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamNpcChatDialogue(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
options,
);
}
const payload = {
worldType: world,
character,
encounter,
monsters,
@@ -710,46 +595,14 @@ export async function streamNpcChatDialogue(
context,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest;
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
payload,
options,
);
if (typeof window === 'undefined') {
return aiClient.streamNpcChatDialogue(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
options,
);
}
try {
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
{
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
userPrompt,
},
options,
);
return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic);
} catch (error) {
console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error);
return aiClient.streamNpcChatDialogue(
world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
options,
);
}
return dialogue.trim();
}
export async function streamNpcRecruitDialogue(
@@ -763,8 +616,23 @@ export async function streamNpcRecruitDialogue(
recruitSummary: string,
options: TextStreamOptions = {},
) {
const userPrompt = buildNpcRecruitDialoguePrompt(
world,
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.streamNpcRecruitDialogue(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
options,
);
}
const payload = {
worldType: world,
character,
encounter,
monsters,
@@ -772,46 +640,14 @@ export async function streamNpcRecruitDialogue(
context,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest;
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
payload,
options,
);
if (typeof window === 'undefined') {
return aiClient.streamNpcRecruitDialogue(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
options,
);
}
try {
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
{
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
userPrompt,
},
options,
);
return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter);
} catch (error) {
console.warn('[aiService] npc recruit stream fell back to frontend implementation', error);
return aiClient.streamNpcRecruitDialogue(
world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
options,
);
}
return dialogue.trim();
}
export type {