609 lines
14 KiB
TypeScript
609 lines
14 KiB
TypeScript
import type {
|
|
CustomWorldGenerationProgress,
|
|
GenerateCustomWorldProfileInput,
|
|
GenerateCustomWorldProfileOptions,
|
|
} from '../../packages/shared/src/contracts/runtime';
|
|
import type {
|
|
CharacterChatReplyRequest,
|
|
CharacterChatSuggestionsRequest,
|
|
CharacterChatSummaryRequest,
|
|
NpcChatTurnDirective,
|
|
NpcChatDialogueRequest,
|
|
NpcChatTurnRequest,
|
|
NpcChatTurnResult,
|
|
NpcRecruitDialogueRequest,
|
|
PlainTextResponse,
|
|
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
|
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
|
import type {
|
|
AIResponse,
|
|
Character,
|
|
CharacterChatTurn,
|
|
Encounter,
|
|
GameState,
|
|
SceneHostileNpc,
|
|
StoryMoment,
|
|
WorldType,
|
|
} from '../types';
|
|
import type {
|
|
StoryGenerationContext,
|
|
StoryRequestOptions,
|
|
TextStreamOptions,
|
|
CustomWorldSceneImageResult,
|
|
} from './aiTypes';
|
|
import { fetchWithApiAuth, requestJson } from './apiClient';
|
|
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
|
import { parseLineListContent } from './llmParsers';
|
|
|
|
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 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(parseApiErrorMessage(responseText, '流式请求失败'));
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('streaming response body is unavailable');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder('utf-8');
|
|
let buffer = '';
|
|
let accumulatedText = '';
|
|
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
while (buffer.includes('\n\n')) {
|
|
const boundary = buffer.indexOf('\n\n');
|
|
const eventBlock = buffer.slice(0, boundary);
|
|
buffer = buffer.slice(boundary + 2);
|
|
|
|
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (!line.startsWith('data:')) {
|
|
continue;
|
|
}
|
|
|
|
const data = line.slice(5).trim();
|
|
if (!data || data === '[DONE]') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const delta = parsed?.choices?.[0]?.delta?.content;
|
|
if (typeof delta === 'string' && delta.length > 0) {
|
|
accumulatedText += delta;
|
|
options.onUpdate?.(accumulatedText);
|
|
}
|
|
} catch {
|
|
// Ignore malformed SSE frames.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return accumulatedText.trim();
|
|
}
|
|
|
|
type ParsedSseEvent = {
|
|
event: string | null;
|
|
data: string;
|
|
};
|
|
|
|
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
|
let eventName: string | null = null;
|
|
const dataLines: string[] = [];
|
|
|
|
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
if (line.startsWith('event:')) {
|
|
eventName = line.slice(6).trim() || null;
|
|
continue;
|
|
}
|
|
if (line.startsWith('data:')) {
|
|
dataLines.push(line.slice(5).trim());
|
|
}
|
|
}
|
|
|
|
if (dataLines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
event: eventName,
|
|
data: dataLines.join('\n'),
|
|
};
|
|
}
|
|
|
|
export async function generateInitialStory(
|
|
world: WorldType,
|
|
character: Character,
|
|
monsters: SceneHostileNpc[],
|
|
context: StoryGenerationContext,
|
|
requestOptions: StoryRequestOptions = {},
|
|
): Promise<AIResponse> {
|
|
if (typeof window === 'undefined') {
|
|
const aiClient = await loadLegacyAiModule();
|
|
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(
|
|
world: WorldType,
|
|
character: Character,
|
|
monsters: SceneHostileNpc[],
|
|
history: StoryMoment[],
|
|
choice: string,
|
|
context: StoryGenerationContext,
|
|
requestOptions: StoryRequestOptions = {},
|
|
): Promise<AIResponse> {
|
|
if (typeof window === 'undefined') {
|
|
const aiClient = await loadLegacyAiModule();
|
|
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(
|
|
world: WorldType,
|
|
playerCharacter: Character,
|
|
targetCharacter: Character,
|
|
storyHistory: StoryMoment[],
|
|
context: StoryGenerationContext,
|
|
conversationHistory: CharacterChatTurn[],
|
|
conversationSummary: string,
|
|
targetStatus: CharacterChatTargetStatus,
|
|
) {
|
|
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,
|
|
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,
|
|
) {
|
|
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,
|
|
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 = {},
|
|
) {
|
|
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,
|
|
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 = {},
|
|
) {
|
|
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,
|
|
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' | 'spar_complete';
|
|
} | null;
|
|
chatDirective?: NpcChatTurnDirective | null;
|
|
npcInitiatesConversation?: boolean;
|
|
} = {},
|
|
) {
|
|
const payload = {
|
|
worldType: world,
|
|
character,
|
|
player: character,
|
|
encounter,
|
|
monsters,
|
|
history,
|
|
context,
|
|
conversationHistory: conversationHistory ?? [],
|
|
dialogue: conversationHistory ?? [],
|
|
playerMessage,
|
|
npcState,
|
|
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
|
|
questOfferContext: options.questOfferContext
|
|
? {
|
|
state: options.questOfferContext.state,
|
|
encounter,
|
|
turnCount: options.questOfferContext.turnCount,
|
|
}
|
|
: null,
|
|
combatContext: options.combatContext ?? null,
|
|
chatDirective: options.chatDirective ?? null,
|
|
} 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(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('streaming response body is unavailable');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder('utf-8');
|
|
let buffer = '';
|
|
let accumulatedReply = '';
|
|
let completedResult: NpcChatTurnResult | null = null;
|
|
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
while (buffer.includes('\n\n')) {
|
|
const boundary = buffer.indexOf('\n\n');
|
|
const eventBlock = buffer.slice(0, boundary);
|
|
buffer = buffer.slice(boundary + 2);
|
|
|
|
const parsedEvent = parseSseEventBlock(eventBlock);
|
|
if (!parsedEvent) {
|
|
continue;
|
|
}
|
|
|
|
if (parsedEvent.data === '[DONE]') {
|
|
continue;
|
|
}
|
|
|
|
if (parsedEvent.event === 'reply_delta') {
|
|
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const nextText =
|
|
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
|
accumulatedReply = nextText;
|
|
options.onReplyUpdate?.(accumulatedReply);
|
|
continue;
|
|
}
|
|
|
|
if (parsedEvent.event === 'complete') {
|
|
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
|
accumulatedReply = completedResult.npcReply;
|
|
options.onReplyUpdate?.(accumulatedReply);
|
|
continue;
|
|
}
|
|
|
|
if (parsedEvent.event === 'error') {
|
|
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
throw new Error(
|
|
typeof payloadRecord.message === 'string'
|
|
? payloadRecord.message
|
|
: 'NPC 聊天续写失败',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!completedResult) {
|
|
throw new Error('NPC 聊天续写结果为空');
|
|
}
|
|
|
|
return completedResult;
|
|
}
|
|
|
|
export async function streamNpcRecruitDialogue(
|
|
world: WorldType,
|
|
character: Character,
|
|
encounter: Encounter,
|
|
monsters: SceneHostileNpc[],
|
|
history: StoryMoment[],
|
|
context: StoryGenerationContext,
|
|
invitationText: string,
|
|
recruitSummary: string,
|
|
options: TextStreamOptions = {},
|
|
) {
|
|
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,
|
|
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,
|
|
};
|