Files
Genarrative/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts

301 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<VisualNovelWorksResponse>({
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<VisualNovelRunResponse>(
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<VisualNovelRunResponse>({
url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId),
fallbackMessage: '读取视觉小说运行快照失败',
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
});
}
export async function getVisualNovelHistory(runId: string) {
return requestRuntimeJson<VisualNovelHistoryResponse>({
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<VisualNovelRunResponse>({
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<ProfileSaveArchiveListResponse>(
'/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<VisualNovelSaveArchiveResumeResponse>(
`/api/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export function putVisualNovelRuntimeSnapshot(
checkpoint: RuntimeSaveCheckpointInput,
) {
// 中文注释:这里仍然只提交平台 checkpoint真实 run 状态由后端运行时维护,前端不上传整份业务快照。
return requestJson<unknown>(
'/api/runtime/save/snapshot',
buildJsonInit('PUT', checkpoint),
'保存视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export function deleteVisualNovelRuntimeSnapshot() {
return requestJson<BasicOkResult>(
'/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 };