import type { CreateCustomWorldAgentSessionRequest, CreateCustomWorldAgentSessionResponse, CustomWorldAgentActionRequest, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, CustomWorldDraftCardDetail, GetCustomWorldAgentCardDetailResponse, ListCustomWorldWorksResponse, SendCustomWorldAgentMessageRequest, } from '../../packages/shared/src/contracts/customWorldAgent'; import type { AnswerCustomWorldSessionQuestionRequest, CreateCustomWorldSessionRequest, CustomWorldGenerationProgress, CustomWorldSessionRecord, CustomWorldSessionSummary, GenerateCustomWorldProfileInput, GenerateCustomWorldProfileOptions, } from '../../packages/shared/src/contracts/runtime'; import type { CharacterChatReplyRequest, CharacterChatSuggestionsRequest, CharacterChatSummaryRequest, NpcChatDialogueRequest, NpcRecruitDialogueRequest, PlainTextResponse, } from '../../packages/shared/src/contracts/story'; import { parseApiErrorMessage } from '../../packages/shared/src/http'; import type { AIResponse, Character, CharacterChatTurn, CustomWorldProfile, Encounter, SceneHostileNpc, StoryMoment, WorldType, } from '../types'; import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult, StoryGenerationContext, StoryRequestOptions, TextStreamOptions, } from './ai'; 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 requestPostJson( url: string, payload: unknown, fallbackMessage: string, ) { return requestJson( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, fallbackMessage, ); } async function requestPlainText( url: string, payload: unknown, fallbackMessage: string, ) { return requestPostJson(url, 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(); } 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 generateCustomWorldProfile( input: GenerateCustomWorldProfileInput | string, options: GenerateCustomWorldProfileOptions = {}, ): Promise { const normalizedInput = typeof input === 'string' ? { settingText: input, creatorIntent: null, generationMode: 'full' as const, } : { settingText: input.settingText, creatorIntent: input.creatorIntent ?? null, generationMode: input.generationMode === 'fast' ? 'fast' as const : 'full' as const, }; const session = await createCustomWorldSession({ settingText: normalizedInput.settingText, creatorIntent: normalizedInput.creatorIntent as Record | null, generationMode: normalizedInput.generationMode, }); const fallbackAnswerMap: Record = { world_hook: typeof normalizedInput.creatorIntent?.worldHook === 'string' && normalizedInput.creatorIntent.worldHook.trim() ? normalizedInput.creatorIntent.worldHook.trim() : normalizedInput.settingText.trim().slice(0, 120) || '这是一个围绕失衡秩序展开的世界。', player_premise: typeof normalizedInput.creatorIntent?.playerPremise === 'string' && normalizedInput.creatorIntent.playerPremise.trim() ? normalizedInput.creatorIntent.playerPremise.trim() : '玩家是一名被卷入局势中心的行动者。', opening_situation: typeof normalizedInput.creatorIntent?.openingSituation === 'string' && normalizedInput.creatorIntent.openingSituation.trim() ? normalizedInput.creatorIntent.openingSituation.trim() : '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。', core_conflict: Array.isArray(normalizedInput.creatorIntent?.coreConflicts) && normalizedInput.creatorIntent.coreConflicts.length > 0 ? normalizedInput.creatorIntent.coreConflicts .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter(Boolean) .join(';') : '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。', }; for (const question of session.questions ?? []) { if (question.answer?.trim()) { continue; } const answer = fallbackAnswerMap[question.id] || normalizedInput.settingText.trim(); await answerCustomWorldSessionQuestion(session.sessionId, { questionId: question.id, answer, }); } return streamCustomWorldSessionGeneration(session.sessionId, options); } export async function streamCustomWorldSessionGeneration( sessionId: string, options: GenerateCustomWorldProfileOptions = {}, ): Promise { const response = await fetchWithApiAuth( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`, { method: 'GET', signal: options.signal, }, ); if (!response.ok) { const responseText = await response.text(); throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败')); } if (!response.body) { throw new Error('自定义世界生成流不可用'); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let latestProfile: Record | 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); let eventName = ''; 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(); continue; } if (!line.startsWith('data:')) { continue; } const payloadText = line.slice(5).trim(); if (!payloadText) { continue; } const payload = JSON.parse(payloadText) as Record; if (eventName === 'progress') { if ( typeof payload.phaseId === 'string' && typeof payload.phaseLabel === 'string' && typeof payload.phaseDetail === 'string' && typeof payload.overallProgress === 'number' && Array.isArray(payload.steps) ) { options.onProgress?.(payload as unknown as CustomWorldGenerationProgress); } else { options.onProgress?.({ phaseId: 'finalize', phaseLabel: typeof payload.phase === 'string' ? payload.phase : 'generating', phaseDetail: typeof payload.phase === 'string' ? payload.phase : 'generating', overallProgress: typeof payload.progress === 'number' ? payload.progress / 100 : 0, completedWeight: typeof payload.progress === 'number' ? payload.progress : 0, totalWeight: 100, elapsedMs: 0, estimatedRemainingMs: null, activeStepIndex: 0, steps: [], }); } } if (eventName === 'result' && payload.profile && typeof payload.profile === 'object') { latestProfile = payload.profile as Record; } if (eventName === 'error') { throw new Error( typeof payload.message === 'string' ? payload.message : '生成自定义世界失败', ); } } } } if (!latestProfile) { throw new Error('自定义世界生成未返回结果'); } return latestProfile as unknown as CustomWorldProfile; } export async function generateCustomWorldSceneImage( ...args: [CustomWorldSceneImageRequest] ) { const aiClient = await loadLegacyAiModule(); return aiClient.generateCustomWorldSceneImage(...args); } export async function createCustomWorldSession(payload: { settingText: string; creatorIntent?: Record | null; generationMode: 'fast' | 'full'; }) { return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest), }, '创建自定义世界会话失败', ); } export async function listCustomWorldWorks() { const response = await requestJson( `${RUNTIME_API_BASE}/custom-world/works`, { method: 'GET', }, '读取创作作品列表失败', ); return Array.isArray(response?.items) ? response.items : []; } export async function createCustomWorldAgentSession( payload: CreateCustomWorldAgentSessionRequest, ) { return requestJson( `${RUNTIME_API_BASE}/custom-world/agent/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '创建世界共创会话失败', ); } export async function getCustomWorldAgentSession(sessionId: string) { return requestJson( `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', }, '读取世界共创会话失败', ); } export async function sendCustomWorldAgentMessage( sessionId: string, payload: SendCustomWorldAgentMessageRequest, ) { return requestJson<{ operation: CustomWorldAgentOperationRecord }>( `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '发送共创消息失败', ); } export async function executeCustomWorldAgentAction( sessionId: string, payload: CustomWorldAgentActionRequest, ) { return requestJson<{ operation: CustomWorldAgentOperationRecord }>( `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, '执行共创操作失败', ); } export async function getCustomWorldAgentOperation( sessionId: string, operationId: string, ): Promise { const response = await requestJson<{ operation?: CustomWorldAgentOperationRecord; data?: CustomWorldAgentOperationRecord; } & Partial>( `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`, { method: 'GET', }, '读取共创操作状态失败', ); return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord; } export async function getCustomWorldAgentCardDetail( sessionId: string, cardId: string, ) { const response = await requestJson( `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`, { method: 'GET', }, '读取草稿卡详情失败', ); return response.card as CustomWorldDraftCardDetail; } export async function getCustomWorldSession(sessionId: string) { return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', }, '读取自定义世界会话失败', ); } export async function answerCustomWorldSessionQuestion( sessionId: string, payload: { questionId: string; answer: string }, ) { return requestJson( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest), }, '提交自定义世界补充设定失败', ); } 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 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, };