This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -19,14 +19,24 @@ test('creation agent document input validation accepts supported text documents'
}).not.toThrow();
});
test('creation agent document input validation rejects unsupported documents', () => {
test('creation agent document input validation accepts docx documents', () => {
expect(() => {
validateCreationAgentDocumentInputFile(
new File(['binary'], '世界设定.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}),
);
}).toThrow('暂时只支持 txt、md、csv、json 文本文档。');
}).not.toThrow();
});
test('creation agent document input validation rejects unsupported documents', () => {
expect(() => {
validateCreationAgentDocumentInputFile(
new File(['binary'], '世界设定.pdf', {
type: 'application/pdf',
}),
);
}).toThrow('暂时只支持 txt、md、docx、csv、json 文档。');
});
test('creation agent document input validation rejects oversized documents', () => {
@@ -44,8 +54,8 @@ test('creation agent document input parse skips network for unsupported files',
vi.stubGlobal('fetch', fetchSpy);
await expect(
parseCreationAgentDocumentInput(new File(['binary'], '世界设定.docx')),
).rejects.toThrow('暂时只支持 txt、md、csv、json 文本文档。');
parseCreationAgentDocumentInput(new File(['binary'], '世界设定.pdf')),
).rejects.toThrow('暂时只支持 txt、md、docx、csv、json 文档。');
expect(fetchSpy).not.toHaveBeenCalled();
});

View File

@@ -11,6 +11,7 @@ const SUPPORTED_DOCUMENT_INPUT_EXTENSIONS = new Set([
'txt',
'md',
'markdown',
'docx',
'csv',
'json',
]);
@@ -52,7 +53,7 @@ export function validateCreationAgentDocumentInputFile(file: File) {
: '';
if (!extension || !SUPPORTED_DOCUMENT_INPUT_EXTENSIONS.has(extension)) {
throw new Error('暂时只支持 txt、md、csv、json 文本文档。');
throw new Error('暂时只支持 txt、md、docx、csv、json 文档。');
}
if (file.size <= 0) {

View File

@@ -0,0 +1,168 @@
import type {
ConfirmCreativePuzzleTemplateRequest,
CreateCreativeAgentSessionRequest,
CreativeAgentSessionResponse,
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeDraftEditStreamRequest,
StreamCreativeAgentMessageRequest,
} from '../../../packages/shared/src/contracts/creativeAgent';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import { fetchWithApiAuth, requestJson } from '../apiClient';
import {
readCreativeAgentResultFromSse,
readCreativeAgentSessionFromSse,
} from './creativeAgentSse';
const CREATIVE_AGENT_API_BASE = '/api/runtime/creative-agent/sessions';
export type CreativeAgentStreamOptions = TextStreamOptions & {
onEvent?: (event: CreativeAgentSseEvent) => void;
};
function buildJsonPostInit(payload: unknown): RequestInit {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
};
}
async function openCreativeAgentSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
signal?: AbortSignal,
) {
const response = await fetchWithApiAuth(url, {
...buildJsonPostInit(payload),
signal,
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return response;
}
export async function createCreativeAgentSession(
payload: CreateCreativeAgentSessionRequest = {},
) {
return requestJson<CreativeAgentSessionResponse>(
CREATIVE_AGENT_API_BASE,
buildJsonPostInit(payload),
'创建智能创作会话失败',
{
retry: {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
},
timeoutMs: 15000,
},
);
}
export async function getCreativeAgentSession(sessionId: string) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
{ method: 'GET' },
'读取智能创作会话失败',
);
}
export async function streamCreativeAgentMessage(
sessionId: string,
payload: StreamCreativeAgentMessageRequest,
options: CreativeAgentStreamOptions = {},
) {
const response = await openCreativeAgentSsePost(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,
'发送智能创作消息失败',
options.signal,
);
return readCreativeAgentSessionFromSse(response, {
...options,
fallbackMessage: '发送智能创作消息失败',
incompleteMessage: '智能创作消息流式结果不完整',
});
}
export async function confirmCreativePuzzleTemplate(
sessionId: string,
payload: ConfirmCreativePuzzleTemplateRequest,
) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/confirm-template`,
buildJsonPostInit(payload),
'确认拼图模板失败',
{
retry: {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
},
},
);
}
export async function streamCreativeDraftEdit(
sessionId: string,
payload: CreativeDraftEditStreamRequest,
options: CreativeAgentStreamOptions = {},
) {
const response = await openCreativeAgentSsePost(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/draft-edits/stream`,
payload,
'修改拼图草稿失败',
options.signal,
);
return requestCreativeDraftEditResultFromSse(response, options);
}
export async function cancelCreativeAgentSession(sessionId: string) {
return requestJson<CreativeAgentSessionResponse>(
`${CREATIVE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/cancel`,
buildJsonPostInit({}),
'取消智能创作会话失败',
);
}
async function requestCreativeDraftEditResultFromSse(
response: Response,
options: CreativeAgentStreamOptions,
) {
const result = await readCreativeAgentResultFromSse(response, {
...options,
fallbackMessage: '修改拼图草稿失败',
incompleteMessage: '智能创作修改结果不完整',
});
if (result.draftEditResult) {
return result.draftEditResult;
}
// 中文注释:后端如果暂时只返回 session调用方仍能用 session 做保底收尾。
return result.session as CreativeAgentSessionSnapshot;
}
export const creativeAgentClient = {
createSession: createCreativeAgentSession,
getSession: getCreativeAgentSession,
streamMessage: streamCreativeAgentMessage,
confirmTemplate: confirmCreativePuzzleTemplate,
streamDraftEdit: streamCreativeDraftEdit,
cancelSession: cancelCreativeAgentSession,
};

View File

@@ -0,0 +1,141 @@
import { expect, test, vi } from 'vitest';
import {
readCreativeAgentResultFromSse,
readCreativeAgentSessionFromSse,
} from './creativeAgentSse';
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('readCreativeAgentSessionFromSse parses typed creative agent events', async () => {
const encoder = new TextEncoder();
const onEvent = vi.fn();
const session = {
sessionId: 'creative-session-1',
stage: 'waiting_template_confirmation',
inputSummary: {
text: '做一套生日拼图',
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const response = createChunkedStreamResponse([
encoder.encode(
'event: stage\r\ndata: {"sessionId":"creative-session-1","stage":"perceiving"}\r\n\r\n',
),
encoder.encode(
'event: thought_summary_delta\r\ndata: {"sessionId":"creative-session-1","thoughtId":"thought-1","textDelta":"正在理解素材"}\r\n\r\n',
),
encoder.encode(
'event: puzzle_template_catalog\r\ndata: {"sessionId":"creative-session-1","templates":[]}\r\n\r\n',
),
encoder.encode(
`event: session\r\ndata: ${JSON.stringify({ session })}\r\n\r\n`,
),
]);
await expect(
readCreativeAgentSessionFromSse(response, {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
onEvent,
}),
).resolves.toEqual(session);
expect(onEvent).toHaveBeenCalledWith({
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'perceiving',
},
});
expect(onEvent).toHaveBeenCalledWith({
event: 'thought_summary_delta',
data: {
sessionId: 'creative-session-1',
thoughtId: 'thought-1',
textDelta: '正在理解素材',
},
});
expect(onEvent).toHaveBeenCalledWith({
event: 'puzzle_template_catalog',
data: {
sessionId: 'creative-session-1',
templates: [],
},
});
});
test('readCreativeAgentResultFromSse keeps draft edit payload when present', async () => {
const encoder = new TextEncoder();
const session = {
sessionId: 'creative-session-1',
stage: 'target_ready',
inputSummary: {
text: null,
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const puzzleSession = {
sessionId: 'puzzle-session-1',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: {},
draft: null,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-05T10:00:00.000Z',
};
const response = createChunkedStreamResponse([
encoder.encode(
`event: draft_edit_result\ndata: ${JSON.stringify({
editInstructions: [],
session,
puzzleSession,
})}\n\n`,
),
]);
const result = await readCreativeAgentResultFromSse(response, {
fallbackMessage: '修改失败',
incompleteMessage: '结果不完整',
});
expect(result.session).toEqual(session);
expect(result.draftEditResult?.puzzleSession).toEqual(puzzleSession);
});

View File

@@ -0,0 +1,230 @@
import type {
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeDraftEditResult,
} from '../../../packages/shared/src/contracts/creativeAgent';
import type { TextStreamOptions } from '../aiTypes';
type CreativeAgentSseOptions = TextStreamOptions & {
fallbackMessage: string;
incompleteMessage: string;
onEvent?: (event: CreativeAgentSseEvent) => void;
};
type CreativeAgentSseResult = {
session: CreativeAgentSessionSnapshot | null;
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>,
): CreativeAgentSseEvent | null {
switch (eventName) {
case 'stage':
case 'agent_message_delta':
case 'thought_summary_delta':
case 'puzzle_template_catalog':
case 'puzzle_template_selection':
case 'puzzle_cost_range':
case 'puzzle_level_plan':
case 'tool_started':
case 'tool_completed':
case 'reflection':
case 'target_session':
case 'session':
case 'error':
case 'done':
return {
event: eventName,
data: data as never,
} as CreativeAgentSseEvent;
default:
return null;
}
}
function handleParsedCreativeAgentEvent(
eventName: string,
parsed: Record<string, unknown> | null,
options: CreativeAgentSseOptions,
): Partial<CreativeAgentSseResult> | null {
if (!parsed) {
return null;
}
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
if (normalizedEvent) {
options.onEvent?.(normalizedEvent);
}
if (eventName === 'agent_message_delta') {
const textDelta = parsed.textDelta;
if (typeof textDelta === 'string') {
options.onUpdate?.(textDelta);
}
return null;
}
if (eventName === 'session' && parsed.session) {
return {
session: parsed.session as CreativeAgentSessionSnapshot,
draftEditResult:
'puzzleSession' in parsed
? (parsed as unknown as CreativeDraftEditResult)
: null,
};
}
if (eventName === 'draft_edit_result' && parsed.session) {
return {
session: parsed.session as CreativeAgentSessionSnapshot,
draftEditResult: parsed as unknown as CreativeDraftEditResult,
};
}
if (eventName === 'error') {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
return null;
}
export async function readCreativeAgentSessionFromSse(
response: Response,
options: CreativeAgentSseOptions,
) {
const result = await readCreativeAgentResultFromSse(response, options);
if (!result.session) {
throw new Error(options.incompleteMessage);
}
return result.session;
}
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;
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
}
buffer += decoder.decode();
consumeBuffer();
if (!result.session) {
throw new Error(options.incompleteMessage);
}
return result;
}

View File

@@ -0,0 +1,10 @@
export {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
creativeAgentClient,
type CreativeAgentStreamOptions,
getCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from './creativeAgentClient';

View File

@@ -37,6 +37,14 @@ export function buildSquareHolePublicWorkCode(profileId: string) {
return `SH-${suffix}`;
}
export function buildVisualNovelPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `VN-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -82,3 +90,16 @@ export function isSameSquareHolePublicWorkCode(
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameVisualNovelPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildVisualNovelPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}

View File

@@ -4,6 +4,9 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import {
PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH,
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
readPuzzleReferenceImageAsDataUrl,
} from './puzzleReferenceImage';
@@ -115,3 +118,53 @@ describe('readPuzzleReferenceImageAsDataUrl', () => {
);
});
});
describe('puzzle reference image square crop helpers', () => {
test('reports square upload dimensions without opening crop flow', async () => {
const sourceDataUrl = 'data:image/png;base64,square';
stubFileReader(sourceDataUrl);
stubImage(512, 512);
const file = new File(['x'], 'square.png', { type: 'image/png' });
const result = await readPuzzleReferenceImageForUpload(file);
expect(result).toEqual({
dataUrl: sourceDataUrl,
width: 512,
height: 512,
});
expect(isPuzzleReferenceImageSquare(result)).toBe(true);
});
test('crops non-square uploads to a centered square data URL', async () => {
const sourceDataUrl = 'data:image/png;base64,wide';
const croppedDataUrl = 'data:image/jpeg;base64,cropped';
stubImage(800, 600);
const { drawImage, toDataURL } = stubCanvas([
`data:image/jpeg;base64,${'A'.repeat(30)}`,
croppedDataUrl,
`data:image/jpeg;base64,${'B'.repeat(40)}`,
]);
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: sourceDataUrl,
cropX: 100,
cropY: 0,
cropSize: 600,
});
expect(dataUrl).toBe(croppedDataUrl);
expect(drawImage).toHaveBeenCalledWith(
expect.anything(),
100,
0,
600,
600,
0,
0,
600,
600,
);
expect(toDataURL).toHaveBeenCalledWith('image/jpeg', 0.88);
});
});

View File

@@ -1,12 +1,24 @@
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1536;
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
type PuzzleReferenceImageSize = {
width: number;
height: number;
};
export type PuzzleReferenceImageReadResult = PuzzleReferenceImageSize & {
dataUrl: string;
};
export type PuzzleReferenceImageCropParams = {
source: string;
cropX: number;
cropY: number;
cropSize: number;
};
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -57,6 +69,25 @@ function resolveCompressedImageSize(
};
}
function resolveReferenceImageNaturalSize(
image: HTMLImageElement,
): PuzzleReferenceImageSize {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (width <= 0 || height <= 0) {
throw new Error('拼图图片读取失败,请重试。');
}
return { width, height };
}
export function isPuzzleReferenceImageSquare(size: PuzzleReferenceImageSize) {
return (
Math.abs(Math.round(size.width) - Math.round(size.height)) <=
PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE
);
}
function shouldCompressReferenceImage(file: File, dataUrl: string) {
return (
file.size > PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES ||
@@ -115,3 +146,95 @@ export async function readPuzzleReferenceImageAsDataUrl(file: File) {
throw error;
}
}
export async function readPuzzleReferenceImageForUpload(
file: File,
): Promise<PuzzleReferenceImageReadResult> {
const dataUrl = await readFileAsDataUrl(file);
const image = await loadReferenceImage(dataUrl);
const size = resolveReferenceImageNaturalSize(image);
if (!isPuzzleReferenceImageSquare(size)) {
return {
dataUrl,
...size,
};
}
try {
const compressedDataUrl = await compressReferenceImageDataUrl(file, dataUrl);
return {
dataUrl: ensureReferenceImageWithinLimit(
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
),
...size,
};
} catch (error) {
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
return {
dataUrl,
...size,
};
}
throw error;
}
}
export async function cropPuzzleReferenceImageDataUrl({
source,
cropX,
cropY,
cropSize,
}: PuzzleReferenceImageCropParams) {
const image = await loadReferenceImage(source);
const sourceSize = resolveReferenceImageNaturalSize(image);
const normalizedCropSize = Math.max(
1,
Math.min(sourceSize.width, sourceSize.height, Math.round(cropSize)),
);
const normalizedCropX = Math.max(
0,
Math.min(sourceSize.width - normalizedCropSize, Math.round(cropX)),
);
const normalizedCropY = Math.max(
0,
Math.min(sourceSize.height - normalizedCropSize, Math.round(cropY)),
);
const outputSize = Math.max(
1,
Math.min(PUZZLE_REFERENCE_IMAGE_MAX_EDGE, normalizedCropSize),
);
const canvas = document.createElement('canvas');
canvas.width = outputSize;
canvas.height = outputSize;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('拼图图片裁剪失败,请重试。');
}
// 中文注释:拼图棋盘固定按 1:1 切块,非正方形上传图必须先裁成正方形再进入草稿链路。
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.fillStyle = '#ffffff';
context.fillRect(0, 0, outputSize, outputSize);
context.drawImage(
image,
normalizedCropX,
normalizedCropY,
normalizedCropSize,
normalizedCropSize,
0,
0,
outputSize,
outputSize,
);
const candidates = [0.88, 0.8, 0.72].map((quality) =>
canvas.toDataURL('image/jpeg', quality),
);
return ensureReferenceImageWithinLimit(
candidates.reduce((best, current) =>
current.length < best.length ? current : best,
),
);
}

View File

@@ -1,29 +0,0 @@
import type { SimulationRunResult } from '../../types';
export interface NarrativeReplaySeed {
id: string;
seed: string;
label: string;
}
export function recordReplaySeed(params: {
seed: string;
label: string;
}) {
return {
id: `replay-seed:${params.seed}`,
seed: params.seed,
label: params.label,
} satisfies NarrativeReplaySeed;
}
export function replayNarrativeRun(params: {
recordedSeed: NarrativeReplaySeed;
result: SimulationRunResult;
}) {
return {
replayId: `replay:${params.recordedSeed.id}`,
seed: params.recordedSeed.seed,
summary: `${params.recordedSeed.label} 回放结果:${params.result.summary}`,
};
}

View File

@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest';
import { recordReplaySeed, replayNarrativeRun } from './narrativeRegressionReplay';
import { recordRerunSeed, rerunNarrativeSimulation } from './narrativeRegressionRerun';
describe('narrativeRegressionReplay', () => {
it('records and replays a narrative seed summary', () => {
const seed = recordReplaySeed({
describe('narrativeRegressionRerun', () => {
it('records and reruns a narrative seed summary', () => {
const seed = recordRerunSeed({
seed: 'baseline',
label: 'Baseline',
});
const replay = replayNarrativeRun({
const rerun = rerunNarrativeSimulation({
recordedSeed: seed,
result: {
id: 'simulation-1',
@@ -23,6 +23,6 @@ describe('narrativeRegressionReplay', () => {
},
});
expect(replay.summary).toContain('Baseline');
expect(rerun.summary).toContain('Baseline');
});
});

View File

@@ -0,0 +1,29 @@
import type { SimulationRunResult } from '../../types';
export interface NarrativeRerunSeed {
id: string;
seed: string;
label: string;
}
export function recordRerunSeed(params: {
seed: string;
label: string;
}) {
return {
id: `rerun-seed:${params.seed}`,
seed: params.seed,
label: params.label,
} satisfies NarrativeRerunSeed;
}
export function rerunNarrativeSimulation(params: {
recordedSeed: NarrativeRerunSeed;
result: SimulationRunResult;
}) {
return {
rerunId: `rerun:${params.recordedSeed.id}`,
seed: params.recordedSeed.seed,
summary: `${params.recordedSeed.label} 复测结果:${params.result.summary}`,
};
}

View File

@@ -0,0 +1,2 @@
export * from './visualNovelCreationClient';
export * from './visualNovelAssetClient';

View File

@@ -0,0 +1,259 @@
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
export type VisualNovelUploadAssetKind =
| 'document'
| 'cover'
| 'scene_background'
| 'character_standee'
| 'music';
export type VisualNovelHistoryAssetKind =
| 'character_visual'
| 'scene_image'
| 'puzzle_cover_image';
export type VisualNovelAssetReference = {
assetObjectId: string;
assetKind: string;
objectKey: string;
imageSrc: string;
ownerUserId?: string | null;
profileId?: string | null;
entityId?: string | null;
createdAt?: string;
updatedAt?: string;
ownerLabel?: string;
};
export type VisualNovelUploadAssetRequest = {
kind: VisualNovelUploadAssetKind;
file: File;
ownerUserId?: string | null;
profileId?: string | null;
entityId?: string | null;
};
type DirectUploadTicketResponse = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
ownerUserId?: string | null;
profileId?: string | null;
entityId?: string | null;
createdAt?: string;
updatedAt?: string;
};
};
type AssetHistoryListResponse = {
assets: Array<{
assetObjectId: string;
assetKind: VisualNovelHistoryAssetKind;
imageSrc: string;
ownerUserId?: string | null;
ownerLabel: string;
profileId?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
}>;
};
const VISUAL_NOVEL_UPLOAD_CONFIG = {
document: {
legacyPrefix: 'generated-character-drafts',
assetKind: 'visual_novel_document',
maxSizeBytes: 256 * 1024,
},
cover: {
legacyPrefix: 'generated-custom-world-covers',
assetKind: 'visual_novel_cover_image',
maxSizeBytes: 10 * 1024 * 1024,
},
scene_background: {
legacyPrefix: 'generated-custom-world-scenes',
assetKind: 'scene_image',
maxSizeBytes: 10 * 1024 * 1024,
},
character_standee: {
legacyPrefix: 'generated-characters',
assetKind: 'character_visual',
maxSizeBytes: 10 * 1024 * 1024,
},
music: {
legacyPrefix: 'generated-custom-world-scenes',
assetKind: 'visual_novel_music',
maxSizeBytes: 20 * 1024 * 1024,
},
} satisfies Record<
VisualNovelUploadAssetKind,
{ legacyPrefix: string; assetKind: string; maxSizeBytes: number }
>;
const MIME_BY_EXTENSION: Record<string, string> = {
csv: 'text/csv',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
json: 'application/json',
md: 'text/markdown',
markdown: 'text/markdown',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
png: 'image/png',
txt: 'text/plain',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
};
function resolveContentType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function buildUploadPathSegments(payload: VisualNovelUploadAssetRequest) {
const profileSegment = payload.profileId?.trim() || 'draft';
const entitySegment = payload.entityId?.trim() || payload.kind;
return ['visual-novel', profileSegment, entitySegment, `${Date.now()}`];
}
function validateUploadFile(payload: VisualNovelUploadAssetRequest) {
const config = VISUAL_NOVEL_UPLOAD_CONFIG[payload.kind];
if (payload.file.size <= 0) {
throw new Error('素材文件为空,请重新选择。');
}
if (payload.file.size > config.maxSizeBytes) {
throw new Error('素材文件过大,请压缩后再上传。');
}
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传平台资产失败。');
}
}
export async function uploadVisualNovelAsset(
payload: VisualNovelUploadAssetRequest,
): Promise<VisualNovelAssetReference> {
validateUploadFile(payload);
const config = VISUAL_NOVEL_UPLOAD_CONFIG[payload.kind];
const contentType = resolveContentType(payload.file);
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: config.legacyPrefix,
pathSegments: buildUploadPathSegments(payload),
fileName: payload.file.name,
contentType,
access: 'private',
maxSizeBytes: config.maxSizeBytes,
metadata: {
asset_kind: config.assetKind,
visual_novel_slot: payload.kind,
},
}),
},
'创建平台资产上传凭证失败',
);
await postDirectUploadFile(ticket.upload, payload.file);
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: payload.file.size,
assetKind: config.assetKind,
accessPolicy: 'private',
ownerUserId: payload.ownerUserId?.trim() || null,
profileId: payload.profileId?.trim() || null,
entityId: payload.entityId?.trim() || null,
}),
},
'确认平台资产失败',
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
assetKind: confirmed.assetObject.assetKind,
objectKey: confirmed.assetObject.objectKey,
imageSrc: ticket.upload.legacyPublicPath,
ownerUserId: confirmed.assetObject.ownerUserId,
profileId: confirmed.assetObject.profileId,
entityId: confirmed.assetObject.entityId,
createdAt: confirmed.assetObject.createdAt,
updatedAt: confirmed.assetObject.updatedAt,
};
}
export async function listVisualNovelHistoryAssets(payload: {
kind: VisualNovelHistoryAssetKind;
limit?: number;
}) {
const params = new URLSearchParams({ kind: payload.kind });
if (payload.limit) {
params.set('limit', String(payload.limit));
}
const response = await requestJson<AssetHistoryListResponse>(
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
{ method: 'GET' },
'读取历史素材失败',
);
return response.assets.map((asset) => ({
assetObjectId: asset.assetObjectId,
assetKind: asset.assetKind,
objectKey: '',
imageSrc: asset.imageSrc,
ownerUserId: asset.ownerUserId,
ownerLabel: asset.ownerLabel,
profileId: asset.profileId,
entityId: asset.entityId,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
})) satisfies VisualNovelAssetReference[];
}

View File

@@ -0,0 +1,99 @@
import type {
CompileVisualNovelWorkProfileRequest,
CreateVisualNovelSessionRequest,
ExecuteVisualNovelAgentActionRequest,
SendVisualNovelMessageRequest,
VisualNovelAgentSessionSnapshot,
VisualNovelCompileResponse,
VisualNovelSessionResponse,
} from '../../../packages/shared/src/contracts/visualNovel';
import type { TextStreamOptions } from '../aiTypes';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions';
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
const visualNovelAgentHttpClient = createCreationAgentClient<
CreateVisualNovelSessionRequest,
VisualNovelSessionResponse,
VisualNovelSessionResponse,
VisualNovelAgentSessionSnapshot,
SendVisualNovelMessageRequest,
VisualNovelSessionResponse,
ExecuteVisualNovelAgentActionRequest,
VisualNovelSessionResponse
>({
apiBase: VISUAL_NOVEL_AGENT_API_BASE,
messages: {
createSession: '创建视觉小说共创会话失败',
getSession: '读取视觉小说共创会话失败',
sendMessage: '发送视觉小说共创消息失败',
streamIncomplete: '视觉小说共创消息流式结果不完整',
executeAction: '执行视觉小说创作操作失败',
},
});
export function createVisualNovelSession(
payload: CreateVisualNovelSessionRequest,
) {
return visualNovelAgentHttpClient.createSession(payload);
}
export function getVisualNovelSession(sessionId: string) {
return visualNovelAgentHttpClient.getSession(sessionId);
}
export function sendVisualNovelMessage(
sessionId: string,
payload: SendVisualNovelMessageRequest,
) {
return visualNovelAgentHttpClient.sendMessage(sessionId, payload);
}
export function streamVisualNovelMessage(
sessionId: string,
payload: SendVisualNovelMessageRequest,
options: TextStreamOptions = {},
) {
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
}
export function executeVisualNovelAction(
sessionId: string,
payload: ExecuteVisualNovelAgentActionRequest,
) {
return visualNovelAgentHttpClient.executeAction(sessionId, payload);
}
export function compileVisualNovelWorkProfile(
sessionId: string,
payload: CompileVisualNovelWorkProfileRequest,
) {
return requestJson<VisualNovelCompileResponse>(
`${VISUAL_NOVEL_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/compile`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'编译视觉小说作品草稿失败',
{
retry: VISUAL_NOVEL_CREATION_WRITE_RETRY,
},
);
}
export const visualNovelCreationClient = {
compileWorkProfile: compileVisualNovelWorkProfile,
createSession: createVisualNovelSession,
executeAction: executeVisualNovelAction,
getSession: getVisualNovelSession,
sendMessage: sendVisualNovelMessage,
streamMessage: streamVisualNovelMessage,
};

View File

@@ -0,0 +1,21 @@
export type {
ProfileSaveArchiveSummary,
VisualNovelRuntimeStreamOptions,
VisualNovelSaveArchiveResumeResponse,
} from './visualNovelRuntimeClient';
export {
buildVisualNovelRuntimeCheckpoint,
buildVisualNovelSaveArchiveState,
deleteVisualNovelRuntimeSnapshot,
getVisualNovelHistory,
getVisualNovelRun,
listVisualNovelGallery,
listVisualNovelSaveArchives,
putVisualNovelRuntimeSnapshot,
regenerateVisualNovelRun,
resumeVisualNovelSaveArchive,
startVisualNovelRun,
streamVisualNovelRuntimeAction,
visualNovelRuntimeClient,
} from './visualNovelRuntimeClient';
export { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';

View File

@@ -0,0 +1,305 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
fetchWithApiAuthMock: vi.fn(),
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', () => ({
fetchWithApiAuth: fetchWithApiAuthMock,
requestJson: requestJsonMock,
}));
import {
buildVisualNovelRuntimeCheckpoint,
buildVisualNovelSaveArchiveState,
type VisualNovelRuntimeStreamOptions,
listVisualNovelGallery,
listVisualNovelSaveArchives,
putVisualNovelRuntimeSnapshot,
regenerateVisualNovelRun,
resumeVisualNovelSaveArchive,
startVisualNovelRun,
streamVisualNovelRuntimeAction,
} from './visualNovelRuntimeClient';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
function createMockRun(
overrides: Partial<VisualNovelRunSnapshot> = {},
): VisualNovelRunSnapshot {
return {
runId: 'vn-run-route-1',
ownerUserId: 'user-1',
profileId: 'vn-profile-1',
mode: 'test',
status: 'active',
currentSceneId: 'scene-1',
currentPhaseId: 'phase-1',
visibleCharacterIds: [],
flags: {},
metrics: {},
history: [],
availableChoices: [],
textModeEnabled: false,
createdAt: '2026-05-07T09:00:00.000Z',
updatedAt: '2026-05-07T09:00:00.000Z',
...overrides,
};
}
function createSseResponse(bodyText: string) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(bodyText));
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
},
);
}
beforeEach(() => {
fetchWithApiAuthMock.mockReset();
requestJsonMock.mockReset();
});
test('listVisualNovelGallery reads public gallery without auth refresh coupling', async () => {
requestJsonMock.mockResolvedValueOnce({ works: [] });
await listVisualNovelGallery();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/visual-novel/gallery',
expect.objectContaining({ method: 'GET' }),
'读取视觉小说公开作品列表失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
skipAuth: true,
skipRefresh: true,
}),
);
});
test('startVisualNovelRun uses the visual novel runtime work route', async () => {
requestJsonMock.mockResolvedValueOnce({ run: createMockRun() });
await startVisualNovelRun('vn-profile-1', {
profileId: 'vn-profile-1',
mode: 'test',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/visual-novel/works/vn-profile-1/runs',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId: 'vn-profile-1', mode: 'test' }),
}),
'启动视觉小说运行失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true }),
timeoutMs: 15000,
}),
);
});
test('streamVisualNovelRuntimeAction posts to the SSE action stream route', async () => {
const response = createSseResponse(
[
'event: raw_text',
'data: {"text":"临时文本"}',
'',
'event: complete',
'data: {"run":' + JSON.stringify(createMockRun()) + '}',
'',
'',
].join('\n'),
);
fetchWithApiAuthMock.mockResolvedValueOnce(response);
const result = await streamVisualNovelRuntimeAction(
'vn-run-route-1',
{
actionKind: 'free_text',
text: '检查广播柜',
clientEventId: 'client-event-1',
},
{
onEvent: vi.fn(),
} satisfies VisualNovelRuntimeStreamOptions,
);
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
'/api/runtime/visual-novel/runs/vn-run-route-1/actions/stream',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actionKind: 'free_text',
text: '检查广播柜',
clientEventId: 'client-event-1',
}),
signal: undefined,
}),
);
expect(result).toMatchObject({ runId: 'vn-run-route-1' });
});
test('regenerateVisualNovelRun uses the history regenerate route', async () => {
requestJsonMock.mockResolvedValueOnce({ run: createMockRun() });
await regenerateVisualNovelRun('vn-run-route-1', {
historyEntryId: 'vn-history-1',
clientEventId: 'client-event-2',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/visual-novel/runs/vn-run-route-1/regenerate',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
historyEntryId: 'vn-history-1',
clientEventId: 'client-event-2',
}),
}),
'重生成视觉小说历史失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true }),
}),
);
});
test('listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes', async () => {
requestJsonMock.mockResolvedValueOnce({
entries: [
{
worldKey: 'visual-novel:one',
ownerUserId: 'user-1',
profileId: 'vn-profile-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
summaryText: '第 2 回合',
coverImageSrc: null,
lastPlayedAt: '2026-05-07T09:00:00.000Z',
},
],
});
await listVisualNovelSaveArchives('vn-profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/save-archives',
expect.objectContaining({ method: 'GET' }),
'读取视觉小说存档失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
requestJsonMock.mockResolvedValueOnce({
entry: {
worldKey: 'visual-novel:one',
},
snapshot: {
version: 2,
savedAt: '2026-05-07T09:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'VISUAL_NOVEL',
},
},
});
await resumeVisualNovelSaveArchive('visual-novel:one');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/save-archives/visual-novel%3Aone',
expect.objectContaining({ method: 'POST' }),
'恢复视觉小说存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
test('putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata', async () => {
requestJsonMock.mockResolvedValueOnce({ ok: true });
await putVisualNovelRuntimeSnapshot({
sessionId: 'vn-run-route-1',
bottomTab: 'adventure',
savedAt: '2026-05-07T09:00:00.000Z',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'vn-run-route-1',
bottomTab: 'adventure',
savedAt: '2026-05-07T09:00:00.000Z',
}),
}),
'保存视觉小说存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
test('buildVisualNovelRuntimeCheckpoint maps run id into session id', () => {
expect(
buildVisualNovelRuntimeCheckpoint({
run: createMockRun(),
savedAt: '2026-05-07T09:00:00.000Z',
}),
).toEqual({
sessionId: 'vn-run-route-1',
bottomTab: 'adventure',
savedAt: '2026-05-07T09:00:00.000Z',
});
});
test('buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes', () => {
expect(
buildVisualNovelSaveArchiveState(
createMockRun({
history: [
{
entryId: 'vn-history-1',
runId: 'vn-run-route-1',
turnIndex: 3,
source: 'assistant',
actionText: '继续',
steps: [],
snapshotBeforeHash: 'before-hash',
snapshotAfterHash: 'after-hash',
createdAt: '2026-05-07T09:00:00.000Z',
},
],
}),
),
).toEqual({
runtimeKind: 'visual-novel',
profileId: 'vn-profile-1',
runId: 'vn-run-route-1',
currentSceneId: 'scene-1',
currentPhaseId: 'phase-1',
historyCursor: 3,
snapshotHash: 'after-hash',
});
});

View File

@@ -0,0 +1,264 @@
import type {
BasicOkResult,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary,
RuntimeSaveCheckpointInput,
} from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelHistoryResponse,
VisualNovelRegenerateRequest,
VisualNovelRunResponse,
VisualNovelRunSnapshot,
VisualNovelRuntimeActionRequest,
VisualNovelRuntimeStreamEvent,
VisualNovelSaveArchiveState,
VisualNovelStartRunRequest,
VisualNovelWorksResponse,
} from '../../../packages/shared/src/contracts/visualNovel';
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
import type { TextStreamOptions } from '../aiTypes';
import {
type ApiRetryOptions,
fetchWithApiAuth,
requestJson,
} from '../apiClient';
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel';
const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const VISUAL_NOVEL_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
};
export type VisualNovelSaveArchiveResumeResponse =
ProfileSaveArchiveResumeResponse<
VisualNovelSaveArchiveState,
string,
{ run?: VisualNovelRunSnapshot; archiveState?: VisualNovelSaveArchiveState } | null
>;
export async function listVisualNovelGallery() {
return requestJson<VisualNovelWorksResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取视觉小说公开作品列表失败',
{
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
// 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。
skipAuth: true,
skipRefresh: true,
},
);
}
function buildJsonInit(method: 'POST' | 'PUT', payload: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
};
}
async function openVisualNovelRuntimeSsePost(
url: string,
payload: unknown,
fallbackMessage: string,
signal?: AbortSignal,
) {
const response = await fetchWithApiAuth(url, {
...buildJsonInit('POST', payload),
signal,
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
return response;
}
export async function startVisualNovelRun(
profileId: string,
payload: VisualNovelStartRunRequest,
) {
return requestJson<VisualNovelRunResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`,
buildJsonInit('POST', payload),
'启动视觉小说运行失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
timeoutMs: 15000,
},
);
}
export async function getVisualNovelRun(runId: string) {
return requestJson<VisualNovelRunResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取视觉小说运行快照失败',
{
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
},
);
}
export async function getVisualNovelHistory(runId: string) {
return requestJson<VisualNovelHistoryResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/history`,
{ method: 'GET' },
'读取视觉小说历史失败',
{
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
},
);
}
export async function streamVisualNovelRuntimeAction(
runId: string,
payload: VisualNovelRuntimeActionRequest,
options: VisualNovelRuntimeStreamOptions = {},
) {
const response = await openVisualNovelRuntimeSsePost(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream`,
payload,
'推进视觉小说失败',
options.signal,
);
return readVisualNovelRuntimeRunFromSse(response, {
...options,
fallbackMessage: '推进视觉小说失败',
incompleteMessage: '视觉小说流式推进结果不完整',
});
}
export async function regenerateVisualNovelRun(
runId: string,
payload: VisualNovelRegenerateRequest,
) {
return requestJson<VisualNovelRunResponse>(
`${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/regenerate`,
buildJsonInit('POST', payload),
'重生成视觉小说历史失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export async function listVisualNovelSaveArchives(profileId?: string | null) {
const response = await requestJson<ProfileSaveArchiveListResponse>(
'/api/profile/save-archives',
{ method: 'GET' },
'读取视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
},
);
const entries = Array.isArray(response?.entries) ? response.entries : [];
const targetProfileId = profileId?.trim();
return entries.filter((entry) => {
if (entry.worldType !== 'visual-novel') {
return false;
}
return !targetProfileId || entry.profileId === targetProfileId;
});
}
export function resumeVisualNovelSaveArchive(worldKey: string) {
return requestJson<VisualNovelSaveArchiveResumeResponse>(
`/api/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export function putVisualNovelRuntimeSnapshot(
checkpoint: RuntimeSaveCheckpointInput,
) {
// 中文注释:这里仍然只提交平台 checkpoint真实 run 状态由后端运行时维护,前端不上传整份业务快照。
return requestJson<unknown>(
'/api/runtime/save/snapshot',
buildJsonInit('PUT', checkpoint),
'保存视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export function deleteVisualNovelRuntimeSnapshot() {
return requestJson<BasicOkResult>(
'/api/runtime/save/snapshot',
{ method: 'DELETE' },
'删除视觉小说存档失败',
{
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
},
);
}
export function buildVisualNovelRuntimeCheckpoint(params: {
run: VisualNovelRunSnapshot;
savedAt?: string;
}): RuntimeSaveCheckpointInput {
return {
sessionId: params.run.runId,
bottomTab: 'adventure',
savedAt: params.savedAt ?? new Date().toISOString(),
};
}
export function buildVisualNovelSaveArchiveState(
run: VisualNovelRunSnapshot,
): VisualNovelSaveArchiveState {
const latestEntry = run.history[run.history.length - 1] ?? null;
return {
runtimeKind: 'visual-novel',
profileId: run.profileId,
runId: run.runId,
currentSceneId: run.currentSceneId,
currentPhaseId: run.currentPhaseId,
historyCursor: latestEntry?.turnIndex ?? 0,
snapshotHash: latestEntry?.snapshotAfterHash ?? null,
};
}
export const visualNovelRuntimeClient = {
listGallery: listVisualNovelGallery,
startRun: startVisualNovelRun,
getRun: getVisualNovelRun,
getHistory: getVisualNovelHistory,
streamAction: streamVisualNovelRuntimeAction,
regenerateRun: regenerateVisualNovelRun,
listSaveArchives: listVisualNovelSaveArchives,
resumeSaveArchive: resumeVisualNovelSaveArchive,
putSnapshot: putVisualNovelRuntimeSnapshot,
deleteSnapshot: deleteVisualNovelRuntimeSnapshot,
};
export type { ProfileSaveArchiveSummary };

View File

@@ -0,0 +1,90 @@
import { expect, test, vi } from 'vitest';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse';
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',
},
});
}
function createRun(): VisualNovelRunSnapshot {
return {
runId: 'vn-run-sse-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-sse-1',
mode: 'test',
status: 'active',
currentSceneId: 'vn-scene-1',
currentPhaseId: 'vn-phase-1',
visibleCharacterIds: [],
flags: {},
metrics: {},
history: [],
availableChoices: [],
textModeEnabled: false,
createdAt: '2026-05-05T12:00:00.000Z',
updatedAt: '2026-05-05T12:00:00.000Z',
};
}
test('readVisualNovelRuntimeRunFromSse parses raw text, typed steps and final run', async () => {
const encoder = new TextEncoder();
const run = createRun();
const onEvent = vi.fn();
const response = createChunkedStreamResponse([
encoder.encode(
'event: raw_text\r\ndata: {"text":"临时文本"}\r\n\r\n',
),
encoder.encode(
'event: step\r\ndata: {"step":{"type":"narration","text":"正式旁白"}}\r\n\r\n',
),
encoder.encode(`event: complete\r\ndata: ${JSON.stringify({ run })}\r\n\r\n`),
]);
await expect(
readVisualNovelRuntimeRunFromSse(response, {
fallbackMessage: '推进失败',
incompleteMessage: '结果不完整',
onEvent,
}),
).resolves.toEqual(run);
expect(onEvent).toHaveBeenCalledWith({
type: 'raw_text',
text: '临时文本',
});
expect(onEvent).toHaveBeenCalledWith({
type: 'step',
step: {
type: 'narration',
text: '正式旁白',
},
});
});
test('readVisualNovelRuntimeRunFromSse accepts payload type when event name is message', async () => {
const encoder = new TextEncoder();
const run = createRun();
const response = createChunkedStreamResponse([
encoder.encode(`data: ${JSON.stringify({ type: 'snapshot', run })}\n\n`),
]);
await expect(
readVisualNovelRuntimeRunFromSse(response, {
fallbackMessage: '推进失败',
incompleteMessage: '结果不完整',
}),
).resolves.toEqual(run);
});

View File

@@ -0,0 +1,177 @@
import type {
VisualNovelRunSnapshot,
VisualNovelRuntimeStreamEvent,
} from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelRuntimeSseOptions = {
fallbackMessage: string;
incompleteMessage: string;
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>,
): VisualNovelRuntimeStreamEvent | null {
const typedEventName =
eventName === 'message' && typeof parsed.type === 'string'
? parsed.type
: eventName;
switch (typedEventName) {
case 'start':
case 'raw_text':
case 'step':
case 'snapshot':
case 'complete':
case 'error':
case 'done':
return {
...parsed,
type: typedEventName,
} as VisualNovelRuntimeStreamEvent;
default:
return null;
}
}
function handleVisualNovelRuntimeEvent(
event: VisualNovelRuntimeStreamEvent,
options: VisualNovelRuntimeSseOptions,
) {
options.onEvent?.(event);
if (event.type === 'error') {
throw new Error(event.message.trim() || options.fallbackMessage);
}
if (event.type === 'snapshot' || event.type === 'complete') {
return event.run;
}
return null;
}
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;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
}
buffer += decoder.decode();
consumeBuffer();
if (!finalRun) {
throw new Error(options.incompleteMessage);
}
return finalRun;
}

View File

@@ -0,0 +1 @@
export * from './visualNovelWorksClient';

View File

@@ -0,0 +1,89 @@
import type {
UpdateVisualNovelWorkRequest,
VisualNovelWorkResponse,
VisualNovelWorksResponse,
} from '../../../packages/shared/src/contracts/visualNovel';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const VISUAL_NOVEL_WORKS_API_BASE = '/api/creation/visual-novel/works';
const VISUAL_NOVEL_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 160,
maxDelayMs: 420,
};
const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 220,
maxDelayMs: 620,
retryUnsafeMethods: true,
};
export function listVisualNovelWorks() {
return requestJson<VisualNovelWorksResponse>(
VISUAL_NOVEL_WORKS_API_BASE,
{ method: 'GET' },
'读取视觉小说作品列表失败',
{
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
},
);
}
export function getVisualNovelWorkDetail(profileId: string) {
return requestJson<VisualNovelWorkResponse>(
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取视觉小说作品详情失败',
{
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
},
);
}
export function updateVisualNovelWork(
profileId: string,
payload: UpdateVisualNovelWorkRequest,
) {
return requestJson<VisualNovelWorkResponse>(
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新视觉小说作品失败',
{
retry: VISUAL_NOVEL_WORKS_WRITE_RETRY,
},
);
}
export function deleteVisualNovelWork(profileId: string) {
return requestJson<VisualNovelWorksResponse>(
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除视觉小说作品失败',
{
retry: VISUAL_NOVEL_WORKS_WRITE_RETRY,
},
);
}
export function publishVisualNovelWork(profileId: string) {
return requestJson<VisualNovelWorkResponse>(
`${VISUAL_NOVEL_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布视觉小说作品失败',
{
retry: VISUAL_NOVEL_WORKS_WRITE_RETRY,
},
);
}
export const visualNovelWorksClient = {
delete: deleteVisualNovelWork,
getDetail: getVisualNovelWorkDetail,
list: listVisualNovelWorks,
publish: publishVisualNovelWork,
update: updateVisualNovelWork,
};