refactor: 迁移视觉小说与木鱼 runtime 请求骨架
This commit is contained in:
@@ -10,10 +10,12 @@ vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildVisualNovelRuntimeCheckpoint,
|
||||
buildVisualNovelSaveArchiveState,
|
||||
type VisualNovelRuntimeStreamOptions,
|
||||
getVisualNovelHistory,
|
||||
getVisualNovelRun,
|
||||
listVisualNovelGallery,
|
||||
listVisualNovelSaveArchives,
|
||||
putVisualNovelRuntimeSnapshot,
|
||||
@@ -21,8 +23,8 @@ import {
|
||||
resumeVisualNovelSaveArchive,
|
||||
startVisualNovelRun,
|
||||
streamVisualNovelRuntimeAction,
|
||||
type VisualNovelRuntimeStreamOptions,
|
||||
} from './visualNovelRuntimeClient';
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
function createMockRun(
|
||||
overrides: Partial<VisualNovelRunSnapshot> = {},
|
||||
@@ -108,6 +110,32 @@ test('startVisualNovelRun uses the visual novel runtime work route', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('getVisualNovelRun and getVisualNovelHistory use encoded runtime run routes', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({ run: createMockRun() })
|
||||
.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
await getVisualNovelRun('vn/run-1');
|
||||
await getVisualNovelHistory('vn/run-1');
|
||||
|
||||
expect(requestJsonMock.mock.calls[0]).toEqual([
|
||||
'/api/runtime/visual-novel/runs/vn%2Frun-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取视觉小说运行快照失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
]);
|
||||
expect(requestJsonMock.mock.calls[1]).toEqual([
|
||||
'/api/runtime/visual-novel/runs/vn%2Frun-1/history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取视觉小说历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('streamVisualNovelRuntimeAction posts to the SSE action stream route', async () => {
|
||||
const response = createSseResponse(
|
||||
[
|
||||
@@ -146,6 +174,10 @@ test('streamVisualNovelRuntimeAction posts to the SSE action stream route', asyn
|
||||
}),
|
||||
signal: undefined,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
skipAuth: undefined,
|
||||
skipRefresh: undefined,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ runId: 'vn-run-route-1' });
|
||||
});
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
|
||||
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 = {
|
||||
@@ -57,17 +58,13 @@ export type VisualNovelSaveArchiveResumeResponse =
|
||||
>;
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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 {
|
||||
@@ -117,7 +114,12 @@ export async function startVisualNovelRun(
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<VisualNovelRunResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
|
||||
buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'works',
|
||||
profileId,
|
||||
'runs',
|
||||
),
|
||||
{
|
||||
...buildJsonInit('POST', payload),
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
@@ -134,25 +136,24 @@ export async function startVisualNovelRun(
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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 requestJson<VisualNovelHistoryResponse>(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/history`,
|
||||
{ method: 'GET' },
|
||||
'读取视觉小说历史失败',
|
||||
{
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<VisualNovelHistoryResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'history',
|
||||
),
|
||||
fallbackMessage: '读取视觉小说历史失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamVisualNovelRuntimeAction(
|
||||
@@ -161,7 +162,13 @@ export async function streamVisualNovelRuntimeAction(
|
||||
options: VisualNovelRuntimeStreamOptions = {},
|
||||
) {
|
||||
const response = await openVisualNovelRuntimeSsePost(
|
||||
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream`,
|
||||
buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'actions',
|
||||
'stream',
|
||||
),
|
||||
payload,
|
||||
'推进视觉小说失败',
|
||||
options.signal,
|
||||
@@ -179,14 +186,18 @@ 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,
|
||||
},
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -27,6 +27,10 @@ beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
||||
await import('./woodenFishClient');
|
||||
|
||||
@@ -50,3 +54,86 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish start run uses runtime guest json skeleton', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.startRun('profile/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/wooden-fish/runs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile/1' }),
|
||||
}),
|
||||
'启动敲木鱼运行态失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish checkpoint run keeps client event id local to the client', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000000);
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.checkpointRun(
|
||||
'run/1',
|
||||
{
|
||||
totalTapCount: 12,
|
||||
wordCounters: [{ text: '功德', count: 3 }],
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [, init] = requestJsonMock.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(requestJsonMock.mock.calls[0][0]).toBe(
|
||||
'/api/runtime/wooden-fish/runs/run%2F1/checkpoint',
|
||||
);
|
||||
expect(body).toEqual({
|
||||
totalTapCount: 12,
|
||||
wordCounters: [{ text: '功德', count: 3 }],
|
||||
clientEventId: 'checkpoint-run/1-1780000000000',
|
||||
});
|
||||
expect(body).not.toHaveProperty('runId');
|
||||
expect(body).not.toHaveProperty('checkpointAtMs');
|
||||
});
|
||||
|
||||
test('wooden fish finish run keeps finish event id local to the client', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000001);
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.finishRun(
|
||||
'run/1',
|
||||
{
|
||||
totalTapCount: 18,
|
||||
wordCounters: [{ text: '清净', count: 2 }],
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [, init] = requestJsonMock.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(requestJsonMock.mock.calls[0][0]).toBe(
|
||||
'/api/runtime/wooden-fish/runs/run%2F1/finish',
|
||||
);
|
||||
expect(body).toEqual({
|
||||
totalTapCount: 18,
|
||||
wordCounters: [{ text: '清净', count: 2 }],
|
||||
clientEventId: 'finish-run/1-1780000000001',
|
||||
});
|
||||
expect(body).not.toHaveProperty('runId');
|
||||
expect(body).not.toHaveProperty('finishedAtMs');
|
||||
});
|
||||
|
||||
@@ -13,17 +13,14 @@ import type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
|
||||
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
|
||||
@@ -58,8 +55,8 @@ export type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
};
|
||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||
export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse;
|
||||
@@ -237,23 +234,14 @@ export async function startWoodenFishRuntimeRun(
|
||||
profileId: string,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动敲木鱼运行态失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(WOODEN_FISH_RUNTIME_API_BASE, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动敲木鱼运行态失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkpointWoodenFishRun(
|
||||
@@ -261,28 +249,24 @@ export async function checkpointWoodenFishRun(
|
||||
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload: WoodenFishCheckpointRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `checkpoint-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'保存敲木鱼进度失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
WOODEN_FISH_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'checkpoint',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '保存敲木鱼进度失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function finishWoodenFishRun(
|
||||
@@ -290,28 +274,24 @@ export async function finishWoodenFishRun(
|
||||
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload: WoodenFishFinishRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `finish-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'结束敲木鱼运行失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<WoodenFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
WOODEN_FISH_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'finish',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '结束敲木鱼运行失败',
|
||||
retry: WOODEN_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export const woodenFishClient = {
|
||||
|
||||
Reference in New Issue
Block a user