Merge pull request 'hermes/visual-novel-genarrative' (#18) from hermes/visual-novel-genarrative into master

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-05-13 21:19:38 +08:00
14 changed files with 1050 additions and 89 deletions

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from 'vitest';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from './visualNovelEntryGeneration';
function createVisualNovelPayload(
overrides: Partial<VisualNovelEntryFormPayload> = {},
): VisualNovelEntryFormPayload {
return {
sourceMode: 'idea',
seedText:
'雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风映画动画\n画风要求电影感动画视觉小说画风。',
sourceAssetIds: [],
ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
visualStyleId: 'cinematic-anime',
visualStyleLabel: '映画动画',
visualStylePrompt: '电影感动画视觉小说画风。',
...overrides,
};
}
describe('visualNovelEntryGeneration', () => {
test('one-line visual novel generation exposes reference-flow stages', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
1_500,
);
expect(progress.steps.map((step) => step.id)).toEqual([
'visual-novel-intent',
'visual-novel-world',
'visual-novel-cast-scenes',
'visual-novel-opening',
'visual-novel-ready',
]);
expect(progress.phaseLabel).toBe('理解一句话创意');
expect(progress.steps[0]?.detail).toBe(
'提取核心题材、视觉画风、玩家身份和互动叙事目标。',
);
expect(progress.estimatedRemainingMs).toBe(44_500);
expect(progress.overallProgress).toBeGreaterThan(0);
});
test('one-line visual novel generation advances to opening choices before ready', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
35_000,
);
expect(progress.phaseId).toBe('visual-novel-opening');
expect(progress.phaseLabel).toBe('生成开场与选择');
expect(progress.steps[2]?.status).toBe('completed');
expect(progress.steps[3]?.status).toBe('active');
expect(progress.overallProgress).toBeLessThan(99);
});
test('one-line visual novel generation ready copy points to editable draft', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'ready',
46_000,
);
expect(progress.phaseId).toBe('ready');
expect(progress.phaseLabel).toBe('生成完成');
expect(progress.phaseDetail).toBe(
'视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。',
);
expect(progress.overallProgress).toBe(100);
});
test('one-line visual novel generation anchors include source, style and target', () => {
const entries = buildVisualNovelEntryGenerationAnchorEntries(
createVisualNovelPayload(),
);
expect(entries).toEqual([
{
id: 'visual-novel-idea',
label: '一句话',
value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: '映画动画',
},
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
]);
});
});

View File

@@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries(
label: '视觉画风',
value: payload.visualStyleLabel,
},
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
].filter((entry) => entry.value.trim());
}
@@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress(
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-intent',
label: '理解一句话创意',
detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。',
weight: 16,
durationMs: 6_000,
},
{
id: 'visual-novel-world',
label: '扩展世界观',
detail: '生成世界背景、故事前提、文学风格和玩家角色。',
weight: 22,
durationMs: 10_000,
},
{
id: 'visual-novel-cast-scenes',
label: '设计角色与场景',
detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。',
weight: 28,
durationMs: 16_000,
},
{
id: 'visual-novel-opening',
label: '生成开场与选择',
detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。',
weight: 24,
durationMs: 10_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。',
weight: 10,
durationMs: 3_000,
},
];
let elapsedBeforeStep = 0;
@@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress(
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
const estimatedTotalMs = timeline.reduce(
(sum, step) => sum + step.durationMs,
0,
);
return {
phaseId: phase,
phaseId: phase === 'generating' ? activeStep.id : phase,
phaseLabel:
phase === 'ready'
? '生成完成'
@@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress(
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
@@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress(
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =

View File

@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
createVisualNovelBackgroundMusicTask: vi.fn(),
createVisualNovelSoundEffectTask: vi.fn(),
generateVisualNovelImageAsset: vi.fn(),
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
publishVisualNovelBackgroundMusicAsset: vi.fn(),
publishVisualNovelSoundEffectAsset: vi.fn(),
@@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/');
});
test('visual novel result generates scene background from asset picker', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import('../../services/visual-novel-creation');
const generateImageMock = vi.mocked(
visualNovelCreation.generateVisualNovelImageAsset,
);
generateImageMock.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp',
assetId: 'asset-scene-ai',
model: 'test-image-model',
size: '1280*720',
taskId: 'task-scene-ai',
prompt: '默认图片提示词',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const editorDialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(
within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!,
);
await user.click(
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
name: 'AI生成',
}),
);
await user.click(within(editorDialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(generateImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/vn-profile/scene-ai.webp',
);
});

View File

@@ -4,16 +4,16 @@ import {
ImagePlus,
Images,
Loader2,
type LucideIcon,
Music,
Save,
PenLine,
Play,
Save,
Settings,
Sparkles,
Upload,
Waves,
X,
type LucideIcon,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -27,9 +27,12 @@ import type {
VisualNovelStoryPhaseDraft,
VisualNovelValidationIssue,
} from '../../../packages/shared/src/contracts/visualNovel';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import {
buildVisualNovelImageGenerationPrompt,
createVisualNovelBackgroundMusicTask,
createVisualNovelSoundEffectTask,
generateVisualNovelImageAsset,
listVisualNovelHistoryAssets,
publishVisualNovelBackgroundMusicAsset,
publishVisualNovelSoundEffectAsset,
@@ -38,7 +41,6 @@ import {
type VisualNovelHistoryAssetKind,
type VisualNovelUploadAssetKind,
} from '../../services/visual-novel-creation';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
profileId?: string | null;
entityId?: string | null;
previewTone: 'image' | 'audio';
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
};
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
type VisualNovelImageGeneratorKind =
| 'cover'
| 'scene_background'
| 'character_standee';
type VisualNovelImageGeneratorConfig = {
kind: VisualNovelImageGeneratorKind;
draft: VisualNovelResultDraft;
scene?: VisualNovelSceneDraft | null;
character?: VisualNovelCharacterDraft | null;
};
type VisualNovelAudioGeneratorConfig = {
kind: VisualNovelAudioGeneratorKind;
scene: VisualNovelSceneDraft;
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
Boolean(config.historyKind),
);
const [isUploading, setIsUploading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
};
}, [config.historyKind]);
const handleGenerateImage = async () => {
if (!config.imageGeneratorConfig || config.previewTone !== 'image') {
return;
}
setIsGeneratingImage(true);
setError(null);
try {
const result = await generateVisualNovelImageAsset({
...config.imageGeneratorConfig,
prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig),
});
onSelect({
assetObjectId: result.assetId || result.taskId,
assetKind:
config.uploadKind === 'character_standee'
? 'character_visual'
: config.uploadKind === 'cover'
? 'visual_novel_cover_image'
: 'scene_image',
objectKey: '',
imageSrc: result.imageSrc,
profileId: config.profileId ?? null,
entityId: config.entityId ?? null,
});
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: 'AI 图片生成失败。',
);
} finally {
setIsGeneratingImage(false);
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
<div className="mb-4 flex flex-wrap gap-2">
<button
type="button"
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
>
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
)}
</button>
{config.imageGeneratorConfig && config.previewTone === 'image' ? (
<button
type="button"
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => {
void handleGenerateImage();
}}
className="platform-button platform-button--primary min-h-10 px-4 py-2 text-sm"
>
{isGeneratingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
AI生成
</button>
) : null}
<input
ref={fileInputRef}
type="file"
accept={config.accept}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onChange={(event) => {
void handleUpload(event);
}}
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
entityId,
historyKind,
icon: Icon,
imageGeneratorConfig,
label,
onSelect,
previewTone,
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
entityId?: string | null;
historyKind?: VisualNovelHistoryAssetKind;
icon: LucideIcon;
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
label: string;
onSelect: (asset: VisualNovelAssetReference) => void;
previewTone: 'image' | 'audio';
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
profileId,
entityId,
previewTone,
imageGeneratorConfig,
}}
disabled={disabled}
onClose={() => setIsPickerOpen(false)}
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
accept="image/png,image/jpeg,image/webp"
profileId={draft.profileId}
previewTone="image"
imageGeneratorConfig={{ kind: 'cover', draft }}
onSelect={(asset) =>
onChange({ ...draft, coverImageSrc: asset.imageSrc })
}
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
function VisualNovelCharacterEditor({
item,
disabled,
draft,
onChange,
}: {
item: VisualNovelCharacterDraft;
disabled: boolean;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelCharacterDraft) => void;
}) {
return (
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
profileId={null}
entityId={item.characterId}
previewTone="image"
imageGeneratorConfig={{
kind: 'character_standee',
draft,
character: item,
}}
onSelect={(asset) =>
onChange({
...item,
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
item,
disabled,
profileId,
draft,
onChange,
}: {
item: VisualNovelSceneDraft;
disabled: boolean;
profileId?: string | null;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelSceneDraft) => void;
}) {
return (
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="image"
imageGeneratorConfig={{
kind: 'scene_background',
draft,
scene: item,
}}
onSelect={(asset) =>
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
}
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
<VisualNovelCharacterEditor
item={target.item}
disabled={disabled}
draft={draft}
onChange={updateCharacter}
/>
) : null}
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
item={target.item}
disabled={disabled}
profileId={draft.profileId}
draft={draft}
onChange={updateScene}
/>
) : null}

View File

@@ -68,6 +68,28 @@ async function openCreationAgentSsePost(
return response;
}
type CreationAgentNormalizedStreamEvent =
| {
kind: 'reply_delta';
text: string;
}
| {
kind: 'session';
session: unknown;
}
| {
kind: 'error';
message: string;
}
| null;
type CreationAgentStreamOptions = TextStreamOptions & {
normalizeEvent?: (
eventName: string,
parsed: Record<string, unknown>,
) => CreationAgentNormalizedStreamEvent;
};
/**
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
@@ -128,7 +150,7 @@ export function createCreationAgentClient<
const streamMessage = async (
sessionId: string,
payload: TSendMessagePayload,
options: TextStreamOptions = {},
options: CreationAgentStreamOptions = {},
): Promise<TSession> => {
const response = await openCreationAgentSsePost(
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,

View File

@@ -1,6 +1,9 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { readCreationAgentSessionFromSse } from './creationAgentSse';
import {
normalizeVisualNovelAgentStreamEvent,
readCreationAgentSessionFromSse,
} from './creationAgentSse';
function createChunkedStreamResponse(chunks: Uint8Array[]) {
const stream = new ReadableStream<Uint8Array>({
@@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event'
expect(updates).toEqual(['先把方洞万能的反差定住。']);
});
test('readCreationAgentSessionFromSse can normalize typed visual novel stream events', async () => {
const encoder = new TextEncoder();
const session = {
sessionId: 'vn-session-1',
ownerUserId: 'user-1',
progressPercent: 100,
stage: 'draft_ready',
};
const onUpdate = vi.fn();
const response = createChunkedStreamResponse([
encoder.encode(
'data: {"type":"start","sessionId":"vn-session-1"}\n\n' +
'data: {"type":"phase","phase":"synthesis"}\n\n' +
'data: {"type":"text_delta","text":"视觉小说底稿已生成。"}\n\n' +
`data: ${JSON.stringify({ type: 'complete', session })}\n\n` +
'data: {"type":"done"}\n\n',
),
]);
await expect(
readCreationAgentSessionFromSse(response, {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
onUpdate,
}),
).resolves.toEqual(session);
expect(onUpdate).toHaveBeenCalledWith('视觉小说底稿已生成。');
});
test('readCreationAgentSessionFromSse surfaces typed visual novel error events', async () => {
const encoder = new TextEncoder();
const response = createChunkedStreamResponse([
encoder.encode(
'data: {"type":"error","message":"视觉小说流式创作失败","retryable":true}\n\n',
),
]);
await expect(
readCreationAgentSessionFromSse(response, {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
}),
).rejects.toThrow('视觉小说流式创作失败');
});

View File

@@ -1,9 +1,27 @@
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
import type { TextStreamOptions } from '../aiTypes';
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
fallbackMessage: string;
incompleteMessage: string;
resolveSession?: (rawSession: unknown) => TSession | null;
normalizeEvent?: (
eventName: string,
parsed: Record<string, unknown>,
) =>
| {
kind: 'reply_delta';
text: string;
}
| {
kind: 'session';
session: unknown;
}
| {
kind: 'error';
message: string;
}
| null;
};
function findSseEventBoundary(buffer: string) {
@@ -65,6 +83,66 @@ function parseJsonObject(data: string) {
}
}
type NormalizedCreationAgentSseEvent = NonNullable<
CreationAgentSseOptions<unknown>['normalizeEvent']
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
? TResult
: never;
function normalizeDefaultCreationAgentEvent(
eventName: string,
parsed: Record<string, unknown>,
): NormalizedCreationAgentSseEvent {
if (eventName === 'reply_delta') {
const text = parsed.text;
return typeof text === 'string' ? { kind: 'reply_delta', text } : null;
}
if (eventName === 'session' && parsed.session) {
return { kind: 'session', session: parsed.session };
}
if (eventName === 'error') {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '';
return { kind: 'error', message };
}
return null;
}
export function normalizeVisualNovelAgentStreamEvent(
eventName: string,
parsed: Record<string, unknown>,
): NormalizedCreationAgentSseEvent {
const typedEventName =
eventName === 'message' && typeof parsed.type === 'string'
? parsed.type
: eventName;
const event = {
...parsed,
type: typedEventName,
} as VisualNovelAgentStreamEvent;
switch (event.type) {
case 'text_delta':
return typeof event.text === 'string'
? { kind: 'reply_delta', text: event.text }
: null;
case 'complete':
return event.session ? { kind: 'session', session: event.session } : null;
case 'error':
return {
kind: 'error',
message: event.message.trim(),
};
default:
return normalizeDefaultCreationAgentEvent(eventName, parsed);
}
}
export async function readCreationAgentSessionFromSse<TSession>(
response: Response,
options: CreationAgentSseOptions<TSession>,
@@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse<TSession>(
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
let buffer = '';
let finalSession: TSession | null = null;
const normalizeEvent =
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const consumeBuffer = () => {
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
@@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse<TSession>(
}
const parsed = parseJsonObject(data);
if (!parsed) {
continue;
}
const normalized = normalizeEvent(eventName, parsed);
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
if (normalized?.kind === 'reply_delta') {
options.onUpdate?.(normalized.text);
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(parsed.session);
if (normalized?.kind === 'session') {
finalSession = resolveSession(normalized.session);
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
if (normalized?.kind === 'error') {
throw new Error(normalized.message || options.fallbackMessage);
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
consumeBuffer();
}
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
buffer += decoder.decode();
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 (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(parsed.session);
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
}
consumeBuffer();
if (!finalSession) {
throw new Error(options.incompleteMessage);

View File

@@ -72,7 +72,7 @@ export async function streamRpgCreationMessage(
sessionId: string,
payload: SendRpgAgentMessageRequest,
options: TextStreamOptions = {},
) {
): Promise<RpgAgentSessionSnapshot> {
const response = await openRpgCreationSsePost(
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload,

View File

@@ -1,3 +1,4 @@
export * from './visualNovelCreationClient';
export * from './visualNovelAssetClient';
export * from './visualNovelAudioGenerationClient';
export * from './visualNovelCreationClient';
export * from './visualNovelImageGenerationClient';

View File

@@ -9,7 +9,10 @@ import type {
} from '../../../packages/shared/src/contracts/visualNovel';
import type { TextStreamOptions } from '../aiTypes';
import { type ApiRetryOptions, requestJson } from '../apiClient';
import { createCreationAgentClient } from '../creation-agent';
import {
createCreationAgentClient,
normalizeVisualNovelAgentStreamEvent,
} from '../creation-agent';
const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions';
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
@@ -61,7 +64,10 @@ export function streamVisualNovelMessage(
payload: SendVisualNovelMessageRequest,
options: TextStreamOptions = {},
) {
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, {
...options,
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
});
}
export function executeVisualNovelAction(

View File

@@ -0,0 +1,158 @@
import type {
VisualNovelCharacterDraft,
VisualNovelResultDraft,
VisualNovelSceneDraft,
} from '../../../packages/shared/src/contracts/visualNovel';
import type {
CustomWorldSceneImageRequest,
CustomWorldSceneImageResult,
} from '../aiTypes';
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
export type VisualNovelImageGenerationKind =
| 'cover'
| 'scene_background'
| 'character_standee';
export type VisualNovelImageGenerationRequest = {
kind: VisualNovelImageGenerationKind;
draft: VisualNovelResultDraft;
scene?: VisualNovelSceneDraft | null;
character?: VisualNovelCharacterDraft | null;
prompt?: string;
referenceImageSrc?: string;
};
function buildVisualNovelProfile(
draft: VisualNovelResultDraft,
): CustomWorldSceneImageRequest['profile'] {
return {
id: draft.profileId?.trim() || 'visual-novel-draft',
name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品',
subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说',
summary: draft.workDescription.trim() || draft.world.summary.trim(),
tone:
draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说',
playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择',
settingText: [
draft.world.premise,
draft.world.background,
draft.world.literaryStyle,
]
.map((part) => part.trim())
.filter(Boolean)
.join('\n'),
};
}
function buildVisualNovelLandmark(
payload: VisualNovelImageGenerationRequest,
): CustomWorldSceneImageRequest['landmark'] {
if (payload.kind === 'scene_background' && payload.scene) {
return {
id: payload.scene.sceneId,
name: payload.scene.name.trim() || '视觉小说场景',
description: payload.scene.description.trim() || payload.draft.world.summary,
};
}
if (payload.kind === 'character_standee' && payload.character) {
return {
id: payload.character.characterId,
name: `${payload.character.name.trim() || '视觉小说角色'}立绘`,
description: [
payload.character.appearance,
payload.character.personality,
payload.character.role,
payload.character.relationshipToPlayer,
]
.map((part) => part?.trim() ?? '')
.filter(Boolean)
.join(''),
};
}
return {
id: payload.draft.profileId?.trim() || 'visual-novel-cover',
name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`,
description:
payload.draft.workDescription.trim() ||
payload.draft.world.summary.trim() ||
payload.draft.world.premise.trim(),
};
}
function buildDefaultVisualNovelImagePrompt(
payload: VisualNovelImageGenerationRequest,
) {
const draft = payload.draft;
if (payload.kind === 'scene_background' && payload.scene) {
return [
`视觉小说场景背景:${payload.scene.name}`,
payload.scene.description,
draft.world.defaultTone,
'16:9 横版背景图,无文字,无 UI无人物特写',
]
.map((part) => part.trim())
.filter(Boolean)
.join('');
}
if (payload.kind === 'character_standee' && payload.character) {
return [
`视觉小说角色立绘:${payload.character.name}`,
payload.character.appearance,
payload.character.personality,
payload.character.tone,
'透明感二次元全身或半身立绘,干净背景,无文字,无 UI',
]
.map((part) => part.trim())
.filter(Boolean)
.join('');
}
return [
`视觉小说作品封面:${draft.workTitle}`,
draft.workDescription,
draft.world.summary,
draft.world.defaultTone,
'精致视觉小说封面构图,无文字,无 UI适合 4:3/16:9 裁切',
]
.map((part) => part.trim())
.filter(Boolean)
.join('');
}
function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) {
if (kind === 'character_standee') {
return '768*1024';
}
return '1280*720';
}
export async function generateVisualNovelImageAsset(
payload: VisualNovelImageGenerationRequest,
): Promise<CustomWorldSceneImageResult> {
const userPrompt =
payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload);
if (!userPrompt.trim()) {
throw new Error('请先补充图片生成提示词。');
}
return generateRpgWorldSceneImage({
profile: buildVisualNovelProfile(payload.draft),
landmark: buildVisualNovelLandmark(payload),
userPrompt,
size: resolveVisualNovelImageSize(payload.kind),
...(payload.referenceImageSrc?.trim()
? { referenceImageSrc: payload.referenceImageSrc.trim() }
: {}),
});
}
export function buildVisualNovelImageGenerationPrompt(
payload: VisualNovelImageGenerationRequest,
) {
return buildDefaultVisualNovelImagePrompt(payload);
}