Files
Genarrative/src/services/aiService.ts
T

581 lines
15 KiB
TypeScript

import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnDirective,
NpcChatTurnRequest,
NpcChatTurnResult,
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from '../../packages/shared/src/contracts/runtime';
import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../packages/shared/src/http';
import type {
AIResponse,
Character,
CharacterChatTurn,
Encounter,
GameState,
SceneHostileNpc,
StoryMoment,
WorldType,
} from '../types';
import type {
CustomWorldSceneImageResult,
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
} from './aiTypes';
import { fetchWithApiAuth, requestJson } from './apiClient';
import { parseLineListContent } from './llmParsers';
import {
buildStoryMomentFromRuntimeProjection,
getStoryRuntimeProjection,
resolveRuntimeStoryAction,
} from './rpg-runtime/rpgRuntimeStoryClient';
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import { parseSseJsonObject, readSseJsonStream, readSseStream } from './sseStream';
const RUNTIME_API_BASE = '/api/runtime';
function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
function getStorySessionIdFromContext(context: StoryGenerationContext) {
return context.storySessionId?.trim() || undefined;
}
function runtimeStoryMomentToAiResponse(
story: StoryMoment | null | undefined,
fallbackText: string,
): AIResponse {
return {
storyText: story?.text?.trim() || fallbackText,
options: story?.options ?? [],
};
}
function getRuntimeSnapshotFromContext(context: StoryGenerationContext) {
return context.runtimeSnapshot;
}
async function requestPlainText(
url: string,
payload: unknown,
fallbackMessage: string,
) {
return requestJson<PlainTextResponse>(
url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
fallbackMessage,
);
}
async function requestPlainTextStream(
url: string,
payload: unknown,
options: TextStreamOptions = {},
) {
const response = await fetchWithApiAuth(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(
appendApiErrorRequestId(
parseApiErrorMessage(responseText, '流式请求失败'),
response.headers.get('x-request-id'),
),
);
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
let accumulatedText = '';
await readSseStream(response, ({ data }) => {
if (data === '[DONE]') {
return false;
}
const parsed = parseSseJsonObject(data);
if (!parsed) {
return;
}
const delta = readPlainTextStreamDelta(parsed);
if (delta) {
accumulatedText += delta;
options.onUpdate?.(accumulatedText);
}
});
return accumulatedText.trim();
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: null;
}
function readPlainTextStreamDelta(parsed: Record<string, unknown>) {
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
const firstChoice = asRecord(choices[0]);
const delta = asRecord(firstChoice?.delta);
const content = delta?.content;
return typeof content === 'string' ? content : '';
}
function readSseEventMessage(
parsed: Record<string, unknown>,
fallbackMessage: string,
) {
return typeof parsed.message === 'string' ? parsed.message : fallbackMessage;
}
function coerceNpcChatTurnResult(
parsed: Record<string, unknown>,
): NpcChatTurnResult {
return parsed as unknown as NpcChatTurnResult;
}
function readNpcReplyDelta(parsed: Record<string, unknown>) {
return typeof parsed.text === 'string' ? parsed.text : '';
}
function readNpcCompletedReply(result: NpcChatTurnResult) {
return typeof result.npcReply === 'string' ? result.npcReply : '';
}
async function readNpcChatTurnFromSse(
response: Response,
options: { onReplyUpdate?: (text: string) => void } = {},
): Promise<NpcChatTurnResult> {
let accumulatedReply = '';
const completedResultRef: { current: NpcChatTurnResult | null } = {
current: null,
};
await readSseJsonStream(response, ({ eventName, parsed }) => {
if (eventName === 'reply_delta') {
accumulatedReply = readNpcReplyDelta(parsed);
options.onReplyUpdate?.(accumulatedReply);
return;
}
if (eventName === 'complete') {
completedResultRef.current = coerceNpcChatTurnResult(parsed);
accumulatedReply = readNpcCompletedReply(completedResultRef.current);
options.onReplyUpdate?.(accumulatedReply);
return false;
}
if (eventName === 'error') {
throw new Error(readSseEventMessage(parsed, 'NPC 聊天续写失败'));
}
});
if (!completedResultRef.current) {
throw new Error('NPC 聊天续写结果为空');
}
return completedResultRef.current;
}
export async function generateInitialStory(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
void world;
void character;
void monsters;
void requestOptions;
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法生成开局剧情');
}
const projection = await getStoryRuntimeProjection({
storySessionId,
clientVersion: context.runtimeActionVersion,
});
const story = buildStoryMomentFromRuntimeProjection({ projection });
return runtimeStoryMomentToAiResponse(story, '开局剧情已同步。');
}
export async function generateNextStep(
world: WorldType,
character: Character,
monsters: SceneHostileNpc[],
history: StoryMoment[],
choice: string,
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
void world;
void character;
void monsters;
void history;
void requestOptions;
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法续写剧情');
}
const functionId = context.lastFunctionId?.trim();
if (!functionId) {
throw new Error('运行时动作缺少 functionId,无法续写剧情');
}
const response = await resolveRuntimeStoryAction({
storySessionId,
clientVersion: context.runtimeActionVersion,
option: {
functionId,
actionText: choice,
},
payload: {
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
},
});
return runtimeStoryMomentToAiResponse(
response.snapshot.currentStory,
choice,
);
}
export async function generateCharacterPanelChatSuggestions(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
conversationSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
targetStatus,
} satisfies CharacterChatSuggestionsRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
targetStatus,
} satisfies CharacterChatSuggestionsRequest);
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/suggestions`,
payload,
'角色聊天建议生成失败',
);
return parseLineListContent(text, 3);
}
export async function generateCharacterPanelChatSummary(
world: WorldType,
playerCharacter: Character,
targetCharacter: Character,
storyHistory: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: CharacterChatTurn[],
previousSummary: string,
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
previousSummary,
targetStatus,
} satisfies CharacterChatSummaryRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
previousSummary,
targetStatus,
} satisfies CharacterChatSummaryRequest);
const { text } = await requestPlainText(
`${RUNTIME_API_BASE}/chat/character/summary`,
payload,
'角色聊天摘要生成失败',
);
return text.trim();
}
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 sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
} satisfies CharacterChatReplyRequest)
: ({
worldType: world,
playerCharacter,
targetCharacter,
storyHistory,
context,
conversationHistory,
conversationSummary,
playerMessage,
targetStatus,
} satisfies CharacterChatReplyRequest);
const reply = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
payload,
options,
);
return reply.trim();
}
export async function streamNpcChatDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
topic: string,
resultSummary: string,
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest)
: ({
worldType: world,
character,
encounter,
monsters,
history,
context,
topic,
resultSummary,
} satisfies NpcChatDialogueRequest);
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
payload,
options,
);
return dialogue.trim();
}
export async function streamNpcChatTurn(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: StoryMoment['dialogue'],
playerMessage: string,
npcState: Record<string, unknown>,
options: {
onReplyUpdate?: (text: string) => void;
questOfferContext?: {
state: GameState;
turnCount: number;
} | null;
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'defeat' | 'spar_complete';
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;
} = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const commonChatPayload = {
encounter,
conversationHistory: conversationHistory ?? [],
dialogue: conversationHistory ?? [],
playerMessage,
npcState,
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
questOfferContext: options.questOfferContext
? {
state: sessionId ? {} : options.questOfferContext.state,
encounter,
turnCount: options.questOfferContext.turnCount,
}
: null,
combatContext: options.combatContext ?? null,
chatDirective: options.chatDirective
? {
...options.chatDirective,
functionOptions: options.chatDirective.functionOptions?.map(
(item) => ({
...item,
}),
),
}
: null,
};
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
...commonChatPayload,
} satisfies NpcChatTurnRequest)
: ({
worldType: world,
character,
player: character,
monsters,
history,
context,
...commonChatPayload,
} satisfies NpcChatTurnRequest);
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(
appendApiErrorRequestId(
parseApiErrorMessage(responseText, 'NPC 聊天续写失败'),
response.headers.get('x-request-id'),
),
);
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return readNpcChatTurnFromSse(response, {
onReplyUpdate: options.onReplyUpdate,
});
}
export async function streamNpcRecruitDialogue(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
invitationText: string,
recruitSummary: string,
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest)
: ({
worldType: world,
character,
encounter,
monsters,
history,
context,
invitationText,
recruitSummary,
} satisfies NpcRecruitDialogueRequest);
const dialogue = await requestPlainTextStream(
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
payload,
options,
);
return dialogue.trim();
}
export type {
CustomWorldGenerationProgress,
CustomWorldSceneImageResult,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
StoryGenerationContext,
StoryRequestOptions,
TextStreamOptions,
};