581 lines
15 KiB
TypeScript
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,
|
|
};
|