301 lines
8.5 KiB
TypeScript
301 lines
8.5 KiB
TypeScript
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 };
|