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