import type { BasicOkResult, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileSaveArchiveSummary, RuntimeSaveCheckpointInput, } from '../../../packages/shared/src/contracts/runtime'; import type { VisualNovelHistoryResponse, VisualNovelRegenerateRequest, VisualNovelRunResponse, VisualNovelRunSnapshot, VisualNovelRuntimeActionRequest, VisualNovelRuntimeStreamEvent, VisualNovelSaveArchiveState, VisualNovelStartRunRequest, VisualNovelWorksResponse, } from '../../../packages/shared/src/contracts/visualNovel'; import { appendApiErrorRequestId, parseApiErrorMessage } from '../../../packages/shared/src/http'; import type { TextStreamOptions } from '../aiTypes'; import { type ApiRetryOptions, fetchWithApiAuth, requestJson, } from '../apiClient'; import { buildRuntimeGuestAuthOptions, buildRuntimeGuestHeaders, type RuntimeGuestRequestOptions, } from '../runtimeGuestAuth'; import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse'; const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel'; const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 180, maxDelayMs: 480, }; const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 240, maxDelayMs: 640, retryUnsafeMethods: true, }; export type VisualNovelRuntimeStreamOptions = TextStreamOptions & RuntimeGuestRequestOptions & { onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; }; type VisualNovelRuntimeRequestOptions = RuntimeGuestRequestOptions; export type VisualNovelSaveArchiveResumeResponse = ProfileSaveArchiveResumeResponse< VisualNovelSaveArchiveState, string, { run?: VisualNovelRunSnapshot; archiveState?: VisualNovelSaveArchiveState } | null >; export async function listVisualNovelGallery() { return requestRuntimeJson({ url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'gallery'), fallbackMessage: '读取视觉小说公开作品列表失败', retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, // 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。 requestOptions: { skipAuth: true, skipRefresh: true }, }); } function buildJsonInit(method: 'POST' | 'PUT', payload: unknown): RequestInit { return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }; } async function openVisualNovelRuntimeSsePost( url: string, payload: unknown, fallbackMessage: string, signal?: AbortSignal, options: RuntimeGuestRequestOptions = {}, ) { const requestOptions = buildRuntimeGuestAuthOptions(options); const response = await fetchWithApiAuth( url, { ...buildJsonInit('POST', payload), headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json', }), signal, }, requestOptions, ); if (!response.ok) { const responseText = await response.text(); throw new Error(appendApiErrorRequestId(parseApiErrorMessage(responseText, fallbackMessage), response.headers.get('x-request-id'))); } if (!response.body) { throw new Error('streaming response body is unavailable'); } return response; } export async function startVisualNovelRun( profileId: string, payload: VisualNovelStartRunRequest, options: VisualNovelRuntimeRequestOptions = {}, ) { const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( buildRuntimeApiPath( VISUAL_NOVEL_RUNTIME_API_BASE, 'works', profileId, 'runs', ), { ...buildJsonInit('POST', payload), headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json', }), }, '启动视觉小说运行失败', { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, timeoutMs: 15000, ...requestOptions, }, ); } export async function getVisualNovelRun(runId: string) { return requestRuntimeJson({ url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId), fallbackMessage: '读取视觉小说运行快照失败', retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, }); } export async function getVisualNovelHistory(runId: string) { return requestRuntimeJson({ url: buildRuntimeApiPath( VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId, 'history', ), fallbackMessage: '读取视觉小说历史失败', retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, }); } export async function streamVisualNovelRuntimeAction( runId: string, payload: VisualNovelRuntimeActionRequest, options: VisualNovelRuntimeStreamOptions = {}, ) { const response = await openVisualNovelRuntimeSsePost( buildRuntimeApiPath( VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId, 'actions', 'stream', ), payload, '推进视觉小说失败', options.signal, options, ); return readVisualNovelRuntimeRunFromSse(response, { ...options, fallbackMessage: '推进视觉小说失败', incompleteMessage: '视觉小说流式推进结果不完整', }); } export async function regenerateVisualNovelRun( runId: string, payload: VisualNovelRegenerateRequest, ) { return requestRuntimeJson({ url: buildRuntimeApiPath( VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId, 'regenerate', ), method: 'POST', jsonBody: payload, fallbackMessage: '重生成视觉小说历史失败', retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, }); } export async function listVisualNovelSaveArchives(profileId?: string | null) { const response = await requestJson( '/api/profile/save-archives', { method: 'GET' }, '读取视觉小说存档失败', { retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, }, ); const entries = Array.isArray(response?.entries) ? response.entries : []; const targetProfileId = profileId?.trim(); return entries.filter((entry) => { if (entry.worldType !== 'visual-novel') { return false; } return !targetProfileId || entry.profileId === targetProfileId; }); } export function resumeVisualNovelSaveArchive(worldKey: string) { return requestJson( `/api/profile/save-archives/${encodeURIComponent(worldKey)}`, { method: 'POST' }, '恢复视觉小说存档失败', { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, }, ); } export function putVisualNovelRuntimeSnapshot( checkpoint: RuntimeSaveCheckpointInput, ) { // 中文注释:这里仍然只提交平台 checkpoint;真实 run 状态由后端运行时维护,前端不上传整份业务快照。 return requestJson( '/api/runtime/save/snapshot', buildJsonInit('PUT', checkpoint), '保存视觉小说存档失败', { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, }, ); } export function deleteVisualNovelRuntimeSnapshot() { return requestJson( '/api/runtime/save/snapshot', { method: 'DELETE' }, '删除视觉小说存档失败', { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, }, ); } export function buildVisualNovelRuntimeCheckpoint(params: { run: VisualNovelRunSnapshot; savedAt?: string; }): RuntimeSaveCheckpointInput { return { sessionId: params.run.runId, bottomTab: 'adventure', savedAt: params.savedAt ?? new Date().toISOString(), }; } export function buildVisualNovelSaveArchiveState( run: VisualNovelRunSnapshot, ): VisualNovelSaveArchiveState { const latestEntry = run.history[run.history.length - 1] ?? null; return { runtimeKind: 'visual-novel', profileId: run.profileId, runId: run.runId, currentSceneId: run.currentSceneId, currentPhaseId: run.currentPhaseId, historyCursor: latestEntry?.turnIndex ?? 0, snapshotHash: latestEntry?.snapshotAfterHash ?? null, }; } export const visualNovelRuntimeClient = { listGallery: listVisualNovelGallery, startRun: startVisualNovelRun, getRun: getVisualNovelRun, getHistory: getVisualNovelHistory, streamAction: streamVisualNovelRuntimeAction, regenerateRun: regenerateVisualNovelRun, listSaveArchives: listVisualNovelSaveArchives, resumeSaveArchive: resumeVisualNovelSaveArchive, putSnapshot: putVisualNovelRuntimeSnapshot, deleteSnapshot: deleteVisualNovelRuntimeSnapshot, }; export type { ProfileSaveArchiveSummary };