Merge codex/sse-stream-architecture into architecture adjustment
This commit is contained in:
@@ -35,13 +35,14 @@ import type {
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeProjection,
|
||||
getStoryRuntimeProjection,
|
||||
resolveRuntimeStoryAction,
|
||||
} from './rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import { parseSseJsonObject, readSseJsonStream, readSseStream } from './sseStream';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
@@ -108,81 +109,96 @@ async function requestPlainTextStream(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseStream(response, ({ data }) => {
|
||||
if (data === '[DONE]') {
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames.
|
||||
}
|
||||
}
|
||||
const parsed = parseSseJsonObject(data);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const delta = readPlainTextStreamDelta(parsed);
|
||||
if (delta) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
});
|
||||
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
type ParsedSseEvent = {
|
||||
event: string | null;
|
||||
data: string;
|
||||
};
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||||
let eventName: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
function readPlainTextStreamDelta(parsed: Record<string, unknown>) {
|
||||
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
||||
const firstChoice = asRecord(choices[0]);
|
||||
const delta = asRecord(firstChoice?.delta);
|
||||
const content = delta?.content;
|
||||
return typeof content === 'string' ? content : '';
|
||||
}
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
function readSseEventMessage(
|
||||
parsed: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return typeof parsed.message === 'string' ? parsed.message : fallbackMessage;
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
function coerceNpcChatTurnResult(
|
||||
parsed: Record<string, unknown>,
|
||||
): NpcChatTurnResult {
|
||||
return parsed as unknown as NpcChatTurnResult;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: dataLines.join('\n'),
|
||||
function readNpcReplyDelta(parsed: Record<string, unknown>) {
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
}
|
||||
|
||||
function readNpcCompletedReply(result: NpcChatTurnResult) {
|
||||
return typeof result.npcReply === 'string' ? result.npcReply : '';
|
||||
}
|
||||
|
||||
async function readNpcChatTurnFromSse(
|
||||
response: Response,
|
||||
options: { onReplyUpdate?: (text: string) => void } = {},
|
||||
): Promise<NpcChatTurnResult> {
|
||||
let accumulatedReply = '';
|
||||
const completedResultRef: { current: NpcChatTurnResult | null } = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
if (eventName === 'reply_delta') {
|
||||
accumulatedReply = readNpcReplyDelta(parsed);
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'complete') {
|
||||
completedResultRef.current = coerceNpcChatTurnResult(parsed);
|
||||
accumulatedReply = readNpcCompletedReply(completedResultRef.current);
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
throw new Error(readSseEventMessage(parsed, 'NPC 聊天续写失败'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!completedResultRef.current) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResultRef.current;
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
@@ -508,72 +524,9 @@ export async function streamNpcChatTurn(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedReply = '';
|
||||
let completedResult: NpcChatTurnResult | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
const parsedEvent = parseSseEventBlock(eventBlock);
|
||||
if (!parsedEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'complete') {
|
||||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||||
accumulatedReply = completedResult.npcReply;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
: 'NPC 聊天续写失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResult) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResult;
|
||||
return readNpcChatTurnFromSse(response, {
|
||||
onReplyUpdate: options.onReplyUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
|
||||
@@ -5,15 +5,11 @@ import type {
|
||||
BarkBattleRunStartResponse,
|
||||
BarkBattleRuntimeConfig,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type ApiRetryOptions } from '../apiClient';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle';
|
||||
|
||||
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -34,16 +30,17 @@ export function getBarkBattleRuntimeConfig(
|
||||
workId: string,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleRuntimeConfig>(
|
||||
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
|
||||
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
|
||||
'读取汪汪声浪大作战配置失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BarkBattleRuntimeConfig>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
workId,
|
||||
'config',
|
||||
),
|
||||
fallbackMessage: '读取汪汪声浪大作战配置失败',
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function startBarkBattleRun(
|
||||
@@ -51,39 +48,34 @@ export function startBarkBattleRun(
|
||||
payload: Partial<BarkBattleRunStartRequest> = {},
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleRunStartResponse>(
|
||||
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
workId: payload.workId ?? workId,
|
||||
}),
|
||||
return requestRuntimeJson<BarkBattleRunStartResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
workId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
workId: payload.workId ?? workId,
|
||||
},
|
||||
'启动汪汪声浪大作战正式局失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
fallbackMessage: '启动汪汪声浪大作战正式局失败',
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBarkBattleRun(
|
||||
runId: string,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<unknown>(
|
||||
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET', headers: buildRuntimeGuestHeaders(options) },
|
||||
'读取汪汪声浪大作战单局失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<unknown>({
|
||||
url: buildRuntimeApiPath(BARK_BATTLE_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取汪汪声浪大作战单局失败',
|
||||
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function finishBarkBattleRun(
|
||||
@@ -91,21 +83,20 @@ export function finishBarkBattleRun(
|
||||
payload: BarkBattleRunFinishRequest,
|
||||
options: BarkBattleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BarkBattleFinishResponse>(
|
||||
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
return requestRuntimeJson<BarkBattleFinishResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BARK_BATTLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'finish',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'提交汪汪声浪大作战成绩失败',
|
||||
{
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
fallbackMessage: '提交汪汪声浪大作战成绩失败',
|
||||
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,16 +4,11 @@ import type {
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type ApiRetryOptions } from '../apiClient';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -30,51 +25,44 @@ export function recordBigFishPlay(
|
||||
payload: RecordBigFishPlayRequest,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishWorksResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BIG_FISH_RUNTIME_API_BASE,
|
||||
'sessions',
|
||||
sessionId,
|
||||
'play',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '记录大鱼吃小鱼游玩失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function startBigFishRun(
|
||||
sessionId: string,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
'启动大鱼吃小鱼玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
BIG_FISH_RUNTIME_API_BASE,
|
||||
'sessions',
|
||||
sessionId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '启动大鱼吃小鱼玩法失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBigFishRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼玩法失败',
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取大鱼吃小鱼玩法失败',
|
||||
});
|
||||
}
|
||||
|
||||
export function submitBigFishInput(
|
||||
@@ -82,20 +70,12 @@ export function submitBigFishInput(
|
||||
payload: SubmitBigFishInputRequest,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'同步大鱼吃小鱼输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId, 'input'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '同步大鱼吃小鱼输入失败',
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
@@ -24,65 +25,6 @@ type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
| null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||
@@ -147,71 +89,30 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const resolveSession =
|
||||
options.resolveSession ??
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
const normalizeEvent =
|
||||
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const normalized = normalizeEvent(eventName, parsed);
|
||||
|
||||
if (normalized?.kind === 'reply_delta') {
|
||||
options.onUpdate?.(normalized.text);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized?.kind === 'session') {
|
||||
finalSession = resolveSession(normalized.session);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized?.kind === 'error') {
|
||||
throw new Error(normalized.message || options.fallbackMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
});
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreativeDraftEditResult,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type CreativeAgentSseOptions = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
@@ -16,65 +17,6 @@ type CreativeAgentSseResult = {
|
||||
draftEditResult: CreativeDraftEditResult | null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCreativeAgentSseEvent(
|
||||
eventName: string,
|
||||
data: Record<string, unknown>,
|
||||
@@ -105,13 +47,9 @@ function normalizeCreativeAgentSseEvent(
|
||||
|
||||
function handleParsedCreativeAgentEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown> | null,
|
||||
parsed: Record<string, unknown>,
|
||||
options: CreativeAgentSseOptions,
|
||||
): Partial<CreativeAgentSseResult> | null {
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
|
||||
if (normalizedEvent) {
|
||||
options.onEvent?.(normalizedEvent);
|
||||
@@ -168,59 +106,24 @@ export async function readCreativeAgentResultFromSse(
|
||||
response: Response,
|
||||
options: CreativeAgentSseOptions,
|
||||
): Promise<CreativeAgentSseResult> {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
const result: CreativeAgentSseResult = {
|
||||
session: null,
|
||||
draftEditResult: null,
|
||||
};
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextResult = handleParsedCreativeAgentEvent(
|
||||
eventName,
|
||||
parseJsonObject(data),
|
||||
options,
|
||||
);
|
||||
if (nextResult?.session) {
|
||||
result.session = nextResult.session;
|
||||
}
|
||||
if (nextResult?.draftEditResult) {
|
||||
result.draftEditResult = nextResult.draftEditResult;
|
||||
}
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const nextResult = handleParsedCreativeAgentEvent(
|
||||
eventName,
|
||||
parsed,
|
||||
options,
|
||||
);
|
||||
if (nextResult?.session) {
|
||||
result.session = nextResult.session;
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
if (nextResult?.draftEditResult) {
|
||||
result.draftEditResult = nextResult.draftEditResult;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
});
|
||||
|
||||
if (!result.session) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
108
src/services/jump-hop/jumpHopClient.runtime.test.ts
Normal file
108
src/services/jump-hop/jumpHopClient.runtime.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
restartJumpHopRuntimeRun,
|
||||
startJumpHopRuntimeRun,
|
||||
submitJumpHopJump,
|
||||
} from './jumpHopClient';
|
||||
|
||||
describe('jumpHopClient runtime requests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1780000000000);
|
||||
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts runs through the shared runtime request skeleton', async () => {
|
||||
await startJumpHopRuntimeRun('profile/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile/1' }),
|
||||
}),
|
||||
'启动跳一跳运行态失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits jump input with a generated client event id', async () => {
|
||||
await submitJumpHopJump(
|
||||
'run/1',
|
||||
{ chargeMs: 320 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs/run%2F1/jump',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chargeMs: 320,
|
||||
clientEventId: 'jump-run/1-1780000000000',
|
||||
}),
|
||||
}),
|
||||
'提交跳一跳起跳失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts runs with the same guest auth request skeleton', async () => {
|
||||
await restartJumpHopRuntimeRun('run/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/runs/run%2F1/restart',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: 'restart-run/1-1780000000000',
|
||||
}),
|
||||
}),
|
||||
'重新开始跳一跳失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,8 @@ import {
|
||||
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 JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
@@ -255,23 +252,13 @@ export async function startJumpHopRuntimeRun(
|
||||
profileId: string,
|
||||
options: JumpHopStartRunOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const runtimeMode = options.runtimeMode ?? 'published';
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId, runtimeMode }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
{
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动跳一跳运行态失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
@@ -279,7 +266,6 @@ export async function submitJumpHopJump(
|
||||
payload: JumpHopJumpPayload,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload = {
|
||||
dragDistance: payload.dragDistance,
|
||||
dragVectorX: payload.dragVectorX,
|
||||
@@ -287,19 +273,13 @@ export async function submitJumpHopJump(
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'提交跳一跳起跳失败',
|
||||
requestOptions,
|
||||
);
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs', runId, 'jump'),
|
||||
method: 'POST',
|
||||
jsonBody: requestPayload,
|
||||
fallbackMessage: '提交跳一跳起跳失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJumpHopLeaderboard(
|
||||
@@ -322,22 +302,20 @@ export async function restartJumpHopRuntimeRun(
|
||||
runId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
}),
|
||||
return requestRuntimeJson<JumpHopRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
JUMP_HOP_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'restart',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
},
|
||||
'重新开始跳一跳失败',
|
||||
requestOptions,
|
||||
);
|
||||
fallbackMessage: '重新开始跳一跳失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export const jumpHopClient = {
|
||||
|
||||
51
src/services/llmClient.test.ts
Normal file
51
src/services/llmClient.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamPlainTextCompletion } from './llmClient';
|
||||
|
||||
function createSseResponse(body: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('llmClient streamPlainTextCompletion', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads OpenAI compatible SSE through the shared stream reader', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createSseResponse(
|
||||
[
|
||||
'data: {"choices":[{"delta":{"content":"溪上"}}]}\r\n\r\n',
|
||||
'data: not-json\r\n\r\n',
|
||||
'data: {"choices":[{"delta":{"content":"春风"}}]}\r\n\r\n',
|
||||
'data: [DONE]\r\n\r\n',
|
||||
'data: {"choices":[{"delta":{"content":"不应读取"}}]}\r\n\r\n',
|
||||
].join(''),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await streamPlainTextCompletion('system', 'user', {
|
||||
onUpdate,
|
||||
});
|
||||
|
||||
expect(result).toBe('溪上春风');
|
||||
expect(onUpdate).toHaveBeenNthCalledWith(1, '溪上');
|
||||
expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风');
|
||||
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import { parseSseJsonObject, readSseStream } from './sseStream';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
@@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) {
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function readLlmStreamDeltaContent(parsed: Record<string, unknown>) {
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [firstChoice] = choices;
|
||||
if (typeof firstChoice !== 'object' || firstChoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delta = (firstChoice as {delta?: unknown}).delta;
|
||||
if (typeof delta !== 'object' || delta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = (delta as {content?: unknown}).content;
|
||||
return typeof content === 'string' && content.length > 0 ? content : null;
|
||||
}
|
||||
|
||||
const NODE_ENV = getNodeEnv();
|
||||
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
||||
const SERVER_API_KEY =
|
||||
@@ -291,48 +312,20 @@ export async function streamPlainTextCompletion(
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseStream(response, ({ data }) => {
|
||||
if (data === '[DONE]') {
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames and continue consuming the stream.
|
||||
}
|
||||
}
|
||||
const parsed = parseSseJsonObject(data);
|
||||
const delta = parsed ? readLlmStreamDeltaContent(parsed) : null;
|
||||
if (delta) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accumulatedText.trim();
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,14 +10,11 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const MATCH3D_RUNTIME_API_BASE = '/api/runtime/match3d';
|
||||
const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -74,39 +71,30 @@ export function startMatch3DRun(
|
||||
profileId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const payload: StartMatch3DRunRequest = {
|
||||
profileId,
|
||||
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
|
||||
};
|
||||
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'works', profileId, 'runs'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '启动抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取抓大鹅运行态快照。
|
||||
*/
|
||||
export function getMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取抓大鹅运行快照失败',
|
||||
{ retry: MATCH3D_RUNTIME_READ_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取抓大鹅运行快照失败',
|
||||
retry: MATCH3D_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,19 +104,16 @@ export async function clickMatch3DItem(
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
) {
|
||||
const response = await requestJson<Match3DClickResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
const response = await requestRuntimeJson<Match3DClickResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'确认抓大鹅点击失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
fallbackMessage: '确认抓大鹅点击失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
|
||||
return mapClickConfirmation(payload, response.confirmation);
|
||||
}
|
||||
@@ -142,40 +127,37 @@ export function stopMatch3DRun(
|
||||
clientActionId: `match3d-stop-${Date.now()}`,
|
||||
},
|
||||
) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'停止抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartMatch3DRun(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{ method: 'POST' },
|
||||
'重新开始抓大鹅玩法失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishMatch3DTimeUp(runId: string) {
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`,
|
||||
{ method: 'POST' },
|
||||
'同步抓大鹅倒计时失败',
|
||||
{ retry: MATCH3D_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步抓大鹅倒计时失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export const match3dRuntimeClient = {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCustomWorldPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildPuzzleClearPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
isSameCustomWorldPublicWorkCode,
|
||||
isSameJumpHopPublicWorkCode,
|
||||
isSamePuzzleClearPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from './publicWorkCode';
|
||||
|
||||
@@ -54,6 +54,24 @@ describe('publicWorkCode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches current and legacy match3d public work prefixes', () => {
|
||||
expect(buildMatch3DPublicWorkCode('match3d-profile-12345678')).toBe(
|
||||
'M3-12345678',
|
||||
);
|
||||
expect(
|
||||
isSameMatch3DPublicWorkCode(
|
||||
'M3-12345678',
|
||||
'match3d-profile-12345678',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameMatch3DPublicWorkCode(
|
||||
'M3D-12345678',
|
||||
'match3d-profile-12345678',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('builds and matches custom world public work codes from profile ids', () => {
|
||||
expect(buildCustomWorldPublicWorkCode('world-public-1')).toBe('CW-00000001');
|
||||
expect(isSameCustomWorldPublicWorkCode('cw-00000001', 'world-public-1')).toBe(
|
||||
|
||||
@@ -37,6 +37,14 @@ export function buildMatch3DPublicWorkCode(profileId: string) {
|
||||
return `M3-${suffix}`;
|
||||
}
|
||||
|
||||
function buildLegacyMatch3DPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `M3D-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildSquareHolePublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
@@ -155,6 +163,8 @@ export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string)
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildLegacyMatch3DPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
92
src/services/puzzle-runtime/puzzleRuntimeClient.test.ts
Normal file
92
src/services/puzzle-runtime/puzzleRuntimeClient.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
describe('puzzleRuntimeClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('reads runs through the shared encoded runtime path', async () => {
|
||||
await getPuzzleRun('run/1');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/runs/run%2F1',
|
||||
{ method: 'GET' },
|
||||
'读取拼图运行快照失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits puzzle swaps through the shared json request skeleton', async () => {
|
||||
await swapPuzzlePieces('run/1', {
|
||||
firstPieceId: 'piece-a',
|
||||
secondPieceId: 'piece-b',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/puzzle/runs/run%2F1/swap',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstPieceId: 'piece-a',
|
||||
secondPieceId: 'piece-b',
|
||||
}),
|
||||
}),
|
||||
'交换拼图块失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pause requests on account auth options instead of guest auth', async () => {
|
||||
await updatePuzzleRunPause(
|
||||
'run/1',
|
||||
{ paused: true },
|
||||
{
|
||||
authImpact: 'local',
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
|
||||
const [, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paused: true }),
|
||||
}),
|
||||
);
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
expect(options).not.toMatchObject({ skipAuth: true });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
AdvancePuzzleNextLevelRequest,
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -42,38 +39,25 @@ export async function startPuzzleRun(
|
||||
payload: StartPuzzleRunRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
PUZZLE_RUNTIME_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: PUZZLE_RUNTIME_API_BASE,
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '启动拼图玩法失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图运行态快照。
|
||||
*/
|
||||
export async function getPuzzleRun(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图运行快照失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId),
|
||||
fallbackMessage: '读取拼图运行快照失败',
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,18 +67,13 @@ export async function swapPuzzlePieces(
|
||||
runId: string,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'交换拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '交换拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,18 +83,13 @@ export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '拖动拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +100,6 @@ export async function advancePuzzleNextLevel(
|
||||
payload: AdvancePuzzleNextLevelRequest = {},
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||
const preferSimilarWork = payload.preferSimilarWork === true;
|
||||
const requestPayload = {
|
||||
@@ -134,27 +107,14 @@ export async function advancePuzzleNextLevel(
|
||||
...(preferSimilarWork ? { preferSimilarWork: true } : {}),
|
||||
};
|
||||
const hasRequestPayload = Object.keys(requestPayload).length > 0;
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
...(hasRequestPayload
|
||||
? {
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(requestPayload),
|
||||
}
|
||||
: {
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
}),
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'next-level'),
|
||||
method: 'POST',
|
||||
...(hasRequestPayload ? { jsonBody: requestPayload } : {}),
|
||||
fallbackMessage: '进入下一关失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,22 +125,14 @@ export async function submitPuzzleLeaderboard(
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'leaderboard'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '提交拼图排行榜失败',
|
||||
retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,7 +144,7 @@ export async function updatePuzzleRunPause(
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -218,7 +170,7 @@ export async function usePuzzleRuntimeProp(
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -13,8 +13,8 @@ vi.mock('./apiClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
|
||||
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
|
||||
import {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type {
|
||||
ClaimProfileTaskRewardResponse,
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ClaimProfileTaskRewardResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileTaskCenterResponse,
|
||||
@@ -24,10 +24,11 @@ import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { fetchWithApiAuth } from '../apiClient';
|
||||
import {
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
requestRpgRuntimeJson,
|
||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
@@ -132,65 +133,6 @@ type RechargeOrderSseEvent =
|
||||
payload: { message: string };
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRechargeOrderSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
@@ -264,81 +206,33 @@ export async function watchWechatRpgProfileRechargeOrder(
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||
let streamDone = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.type === 'done') {
|
||||
streamDone = true;
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (finalResponse) {
|
||||
break;
|
||||
if (normalized.type === 'order') {
|
||||
lastResponse = normalized.payload;
|
||||
if (normalized.payload.order.status !== 'pending') {
|
||||
finalResponse = normalized.payload;
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (streamDone) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalResponse) {
|
||||
if (lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
if (normalized.type === 'done') {
|
||||
if (!finalResponse && lastResponse) {
|
||||
finalResponse = lastResponse;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||
});
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('充值订单状态流返回不完整');
|
||||
|
||||
88
src/services/runtimeRequest.test.ts
Normal file
88
src/services/runtimeRequest.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildRuntimeApiPath,
|
||||
requestRuntimeJson,
|
||||
} from './runtimeRequest';
|
||||
|
||||
describe('runtimeRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('builds encoded runtime api paths', () => {
|
||||
expect(buildRuntimeApiPath('/api/runtime/demo/', 'work/a b', 'run/1')).toBe(
|
||||
'/api/runtime/demo/work%2Fa%20b/run%2F1',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends json runtime requests with guest auth and retry options', async () => {
|
||||
const retry = { maxRetries: 1, retryUnsafeMethods: true };
|
||||
|
||||
await requestRuntimeJson({
|
||||
url: '/api/runtime/demo/runs',
|
||||
method: 'POST',
|
||||
jsonBody: { profileId: 'profile-1' },
|
||||
fallbackMessage: '启动失败',
|
||||
retry,
|
||||
requestOptions: {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/demo/runs',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ profileId: 'profile-1' }),
|
||||
},
|
||||
'启动失败',
|
||||
{
|
||||
retry,
|
||||
authImpact: undefined,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: undefined,
|
||||
clearAuthOnUnauthorized: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits empty headers and body for plain runtime reads', async () => {
|
||||
await requestRuntimeJson({
|
||||
url: '/api/runtime/demo/runs/run-1',
|
||||
fallbackMessage: '读取失败',
|
||||
});
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/runtime/demo/runs/run-1',
|
||||
{ method: 'GET' },
|
||||
'读取失败',
|
||||
{
|
||||
authImpact: undefined,
|
||||
skipAuth: undefined,
|
||||
skipRefresh: undefined,
|
||||
notifyAuthStateChange: undefined,
|
||||
clearAuthOnUnauthorized: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
62
src/services/runtimeRequest.ts
Normal file
62
src/services/runtimeRequest.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from './runtimeGuestAuth';
|
||||
|
||||
export type RuntimeJsonRequestParams = {
|
||||
url: string;
|
||||
method?: string;
|
||||
jsonBody?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
fallbackMessage: string;
|
||||
retry?: ApiRetryOptions;
|
||||
requestOptions?: RuntimeGuestRequestOptions;
|
||||
};
|
||||
|
||||
export function buildRuntimeApiPath(
|
||||
basePath: string,
|
||||
...segments: string[]
|
||||
) {
|
||||
const normalizedBasePath = basePath.endsWith('/')
|
||||
? basePath.slice(0, -1)
|
||||
: basePath;
|
||||
return [
|
||||
normalizedBasePath,
|
||||
...segments.map((segment) => encodeURIComponent(segment)),
|
||||
].join('/');
|
||||
}
|
||||
|
||||
export function requestRuntimeJson<T>(params: RuntimeJsonRequestParams) {
|
||||
const {
|
||||
fallbackMessage,
|
||||
headers = {},
|
||||
jsonBody,
|
||||
method = 'GET',
|
||||
requestOptions = {},
|
||||
retry,
|
||||
url,
|
||||
} = params;
|
||||
const hasJsonBody = jsonBody !== undefined;
|
||||
const requestHeaders = buildRuntimeGuestHeaders(requestOptions, {
|
||||
...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
});
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
...(Object.keys(requestHeaders).length > 0
|
||||
? { headers: requestHeaders }
|
||||
: {}),
|
||||
...(hasJsonBody ? { body: JSON.stringify(jsonBody) } : {}),
|
||||
};
|
||||
const authOptions = buildRuntimeGuestAuthOptions(requestOptions);
|
||||
|
||||
return requestJson<T>(url, init, fallbackMessage, {
|
||||
...(retry ? { retry } : {}),
|
||||
...authOptions,
|
||||
});
|
||||
}
|
||||
@@ -6,14 +6,11 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import {
|
||||
buildRuntimeGuestAuthOptions,
|
||||
buildRuntimeGuestHeaders,
|
||||
type RuntimeGuestRequestOptions,
|
||||
} from '../runtimeGuestAuth';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
const SQUARE_HOLE_RUNTIME_API_BASE = '/api/runtime/square-hole';
|
||||
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -34,34 +31,30 @@ export function startSquareHoleRun(
|
||||
profileId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildRuntimeGuestHeaders(options, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动方洞挑战失败',
|
||||
{
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
...requestOptions,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'works',
|
||||
profileId,
|
||||
'runs',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: { profileId },
|
||||
fallbackMessage: '启动方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取方洞挑战运行态快照。
|
||||
*/
|
||||
export function getSquareHoleRun(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取方洞挑战运行快照失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_READ_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取方洞挑战运行快照失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_READ_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,19 +64,21 @@ export function dropSquareHoleShape(
|
||||
runId: string,
|
||||
payload: DropSquareHoleShapeRequest,
|
||||
) {
|
||||
return requestJson<SquareHoleDropResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/drop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
}),
|
||||
return requestRuntimeJson<SquareHoleDropResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'drop',
|
||||
),
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
...payload,
|
||||
runId: payload.runId ?? runId,
|
||||
},
|
||||
'确认方洞挑战投入失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
fallbackMessage: '确认方洞挑战投入失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,40 +90,47 @@ export function stopSquareHoleRun(
|
||||
clientActionId: `square-hole-stop-${Date.now()}`,
|
||||
},
|
||||
) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'停止方洞挑战失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartSquareHoleRun(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{ method: 'POST' },
|
||||
'重新开始方洞挑战失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'restart',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishSquareHoleTimeUp(runId: string) {
|
||||
return requestJson<SquareHoleRunResponse>(
|
||||
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/time-up`,
|
||||
{ method: 'POST' },
|
||||
'同步方洞挑战倒计时失败',
|
||||
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
|
||||
);
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
'runs',
|
||||
runId,
|
||||
'time-up',
|
||||
),
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步方洞挑战倒计时失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
});
|
||||
}
|
||||
|
||||
export const squareHoleRuntimeClient = {
|
||||
|
||||
98
src/services/sseStream.test.ts
Normal file
98
src/services/sseStream.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { readSseJsonStream, readSseStream } from './sseStream';
|
||||
|
||||
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('readSseJsonStream flushes decoder tail and handles CRLF boundaries', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
|
||||
const replyBytes = encoder.encode('溪上春风');
|
||||
const suffix = encoder.encode('"}\r\n\r\n');
|
||||
const splitIndex = replyBytes.length - 1;
|
||||
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||
[];
|
||||
|
||||
await readSseJsonStream(
|
||||
createChunkedStreamResponse([
|
||||
new Uint8Array([...prefix, ...replyBytes.slice(0, splitIndex)]),
|
||||
new Uint8Array([...replyBytes.slice(splitIndex), ...suffix]),
|
||||
]),
|
||||
({ eventName, parsed }) => {
|
||||
events.push({ eventName, parsed });
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
eventName: 'reply_delta',
|
||||
parsed: { text: '溪上春风' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('readSseJsonStream skips malformed json and keeps valid LF events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||
[];
|
||||
|
||||
await readSseJsonStream(
|
||||
createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'event: malformed\ndata: not-json\n\n' +
|
||||
'event: ready\ndata: {"value":7}\n\n',
|
||||
),
|
||||
]),
|
||||
({ eventName, parsed }) => {
|
||||
events.push({ eventName, parsed });
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
eventName: 'ready',
|
||||
parsed: { value: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('readSseStream can stop early and cancel the reader', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: first\ndata: one\n\n' + 'event: second\ndata: two\n\n',
|
||||
),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const events: string[] = [];
|
||||
|
||||
await readSseStream(new Response(stream), ({ eventName }) => {
|
||||
events.push(eventName);
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(events).toEqual(['first']);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
168
src/services/sseStream.ts
Normal file
168
src/services/sseStream.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type SseStreamEvent = {
|
||||
eventName: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type SseJsonStreamEvent = SseStreamEvent & {
|
||||
parsed: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SseEventBoundary = {
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
|
||||
type SseStreamEventHandler<TEvent extends SseStreamEvent> = (
|
||||
event: TEvent,
|
||||
) => void | boolean;
|
||||
|
||||
function findSseEventBoundary(buffer: string): SseEventBoundary | null {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): SseStreamEvent | null {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSseJsonObject(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSseStream(
|
||||
response: Response,
|
||||
onEvent: SseStreamEventHandler<SseStreamEvent>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let shouldContinue = true;
|
||||
let completed = false;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const event = parseSseEventBlock(eventBlock);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onEvent(event) === false) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldContinue) {
|
||||
// 流结束后 flush 解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
completed = true;
|
||||
}
|
||||
} finally {
|
||||
if (!completed && typeof reader.cancel === 'function') {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function readSseJsonStream(
|
||||
response: Response,
|
||||
onEvent: SseStreamEventHandler<SseJsonStreamEvent>,
|
||||
) {
|
||||
return readSseStream(response, (event) => {
|
||||
const parsed = parseSseJsonObject(event.data);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return onEvent({
|
||||
...event,
|
||||
parsed,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeStreamEvent,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
type VisualNovelRuntimeSseOptions = {
|
||||
fallbackMessage: string;
|
||||
@@ -9,65 +10,6 @@ type VisualNovelRuntimeSseOptions = {
|
||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVisualNovelRuntimeEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
@@ -115,59 +57,19 @@ export async function readVisualNovelRuntimeRunFromSse(
|
||||
response: Response,
|
||||
options: VisualNovelRuntimeSseOptions,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalRun: VisualNovelRunSnapshot | null = null;
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
||||
if (nextRun) {
|
||||
finalRun = nextRun;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
consumeBuffer();
|
||||
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
||||
if (nextRun) {
|
||||
finalRun = nextRun;
|
||||
}
|
||||
});
|
||||
|
||||
if (!finalRun) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -51,15 +55,85 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish delete work uses creation works endpoint', async () => {
|
||||
test('wooden fish start run uses runtime guest json skeleton', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
await woodenFishClient.deleteWork('wooden-fish-profile-1');
|
||||
await woodenFishClient.startRun('profile/1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works/wooden-fish-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
'/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;
|
||||
@@ -245,23 +242,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(
|
||||
@@ -269,28 +257,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(
|
||||
@@ -298,28 +282,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