This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -1,16 +1,16 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
VisualNovelAgentWorkspace,
} from './VisualNovelAgentWorkspace';
} from './visualNovelEntryGeneration';
function renderWorkspace(
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
@@ -27,8 +27,42 @@ test('visual novel workspace only exposes one-line input and visual style entry'
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
expect(screen.getByText('视觉画风')).toBeTruthy();
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
expect(
screen
.getByRole('button', { name: '映画动画' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/cinematic-anime.png');
expect(
screen
.getByRole('button', { name: '水彩绘本' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/watercolor.png');
expect(
screen
.getByRole('button', { name: '像素霓虹' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/pixel-noir.png');
expect(
screen
.getByRole('button', { name: '水墨幻想' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/ink-fantasy.png');
expect(
screen
.getByRole('button', { name: '柔彩校园' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/soft-pastel.png');
expect(
screen
.getByRole('button', { name: '暗色哥特' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/dark-gothic.png');
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
@@ -47,9 +81,7 @@ test('visual novel workspace submits idea and selected visual style as seed text
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
});
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
fireEvent.click(
screen.getByRole('button', { name: /稿/u }),
);
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
sourceMode: 'idea',
@@ -67,16 +99,15 @@ test('visual novel workspace submits idea and selected visual style as seed text
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
);
expect(
(screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value,
).toBe('想做一个雪夜列车和旧电台有关的悬疑视觉小说。');
});
test('visual novel generation helpers build process page data', () => {
const payload = {
sourceMode: 'idea' as const,
seedText:
'雨夜书店\n视觉画风水彩绘本\n画风要求透明水彩与绘本质感。',
seedText: '雨夜书店\n视觉画风水彩绘本\n画风要求透明水彩与绘本质感。',
sourceAssetIds: [],
ideaText: '雨夜书店',
visualStyleId: 'watercolor' as const,

View File

@@ -1,12 +1,8 @@
import { ArrowLeft, Loader2, Sparkles, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type {
CreateVisualNovelSessionRequest,
VisualNovelAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import type { VisualNovelEntryFormPayload } from './visualNovelEntryGeneration';
type VisualNovelAgentWorkspaceProps = {
session?: VisualNovelAgentSessionSnapshot | null;
@@ -19,19 +15,6 @@ type VisualNovelAgentWorkspaceProps = {
title?: string | null;
};
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
type VisualNovelFormState = {
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
@@ -41,31 +24,37 @@ const VISUAL_NOVEL_STYLE_OPTIONS = [
{
id: 'cinematic-anime',
label: '映画动画',
imageSrc: '/visual-novel-style-references/cinematic-anime.png',
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
},
{
id: 'watercolor',
label: '水彩绘本',
imageSrc: '/visual-novel-style-references/watercolor.png',
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
},
{
id: 'pixel-noir',
label: '像素霓虹',
imageSrc: '/visual-novel-style-references/pixel-noir.png',
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
},
{
id: 'ink-fantasy',
label: '水墨幻想',
imageSrc: '/visual-novel-style-references/ink-fantasy.png',
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
},
{
id: 'soft-pastel',
label: '柔彩校园',
imageSrc: '/visual-novel-style-references/soft-pastel.png',
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
},
{
id: 'dark-gothic',
label: '暗色哥特',
imageSrc: '/visual-novel-style-references/dark-gothic.png',
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
},
] as const;
@@ -136,11 +125,13 @@ function buildVisualNovelSeedText(
function VisualNovelStyleButton({
active,
disabled,
imageSrc,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
imageSrc: string;
label: string;
onClick: () => void;
}) {
@@ -157,7 +148,16 @@ function VisualNovelStyleButton({
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95'
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
{imageSrc ? (
<img
src={imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
)}
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
{active ? (
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
@@ -299,6 +299,7 @@ export function VisualNovelAgentWorkspace({
key={option.id}
active={formState.visualStyleId === option.id}
disabled={isBusy}
imageSrc={option.imageSrc}
label={option.label}
onClick={() =>
setFormState((current) => ({
@@ -348,158 +349,4 @@ export function VisualNovelAgentWorkspace({
);
}
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
return {
phaseId: phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}
export default VisualNovelAgentWorkspace;

View File

@@ -0,0 +1,178 @@
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CreateVisualNovelSessionRequest } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
type VisualNovelStyleOptionId =
| 'cinematic-anime'
| 'watercolor'
| 'pixel-noir'
| 'ink-fantasy'
| 'soft-pastel'
| 'dark-gothic';
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
return {
phaseId: phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}