1
This commit is contained in:
264
src/services/visual-novel-runtime/visualNovelRuntimeClient.ts
Normal file
264
src/services/visual-novel-runtime/visualNovelRuntimeClient.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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 { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
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 & {
|
||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||
};
|
||||
|
||||
export type VisualNovelSaveArchiveResumeResponse =
|
||||
ProfileSaveArchiveResumeResponse<
|
||||
VisualNovelSaveArchiveState,
|
||||
string,
|
||||
{ run?: VisualNovelRunSnapshot; archiveState?: VisualNovelSaveArchiveState } | null
|
||||
>;
|
||||
|
||||
export async function listVisualNovelGallery() {
|
||||
return requestJson<VisualNovelWorksResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说公开作品列表失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
// 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。
|
||||
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,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
...buildJsonInit('POST', payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function startVisualNovelRun(
|
||||
profileId: string,
|
||||
payload: VisualNovelStartRunRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
||||
buildJsonInit('POST', payload),
|
||||
'启动视觉小说运行失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVisualNovelRun(runId: string) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说运行快照失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVisualNovelHistory(runId: string) {
|
||||
return requestJson<VisualNovelHistoryResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/history`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说历史失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamVisualNovelRuntimeAction(
|
||||
runId: string,
|
||||
payload: VisualNovelRuntimeActionRequest,
|
||||
options: VisualNovelRuntimeStreamOptions = {},
|
||||
) {
|
||||
const response = await openVisualNovelRuntimeSsePost(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream`,
|
||||
payload,
|
||||
'推进视觉小说失败',
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readVisualNovelRuntimeRunFromSse(response, {
|
||||
...options,
|
||||
fallbackMessage: '推进视觉小说失败',
|
||||
incompleteMessage: '视觉小说流式推进结果不完整',
|
||||
});
|
||||
}
|
||||
|
||||
export async function regenerateVisualNovelRun(
|
||||
runId: string,
|
||||
payload: VisualNovelRegenerateRequest,
|
||||
) {
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/regenerate`,
|
||||
buildJsonInit('POST', payload),
|
||||
'重生成视觉小说历史失败',
|
||||
{
|
||||
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 };
|
||||
Reference in New Issue
Block a user