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 { 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 { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; import { parseLineListContent } from './llmParsers'; import { buildStoryMomentFromRuntimeProjection, getStoryRuntimeProjection, resolveRuntimeStoryAction, } from './rpg-runtime/rpgRuntimeStoryClient'; 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( 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 { 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 { 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, 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(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 = {}, ) { 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, };