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 | null = null; async function loadLegacyAiModule() { if (!legacyAiModulePromise) { legacyAiModulePromise = import('./ai'); } return legacyAiModulePromise; } 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 { if (typeof window === 'undefined') { const aiClient = await loadLegacyAiModule(); return aiClient.generateInitialStory( world, character, monsters, context, requestOptions, ); } return requestJson( `${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 { if (typeof window === 'undefined') { const aiClient = await loadLegacyAiModule(); return aiClient.generateNextStep( world, character, monsters, history, choice, context, requestOptions, ); } return requestJson( `${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, 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, };