This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -1,123 +1,101 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/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 { AuthUiContext } from '../auth/AuthUiContext';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
VisualNovelAgentWorkspace,
} from './VisualNovelAgentWorkspace';
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
parseCreationAgentDocumentInput: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
uploadVisualNovelAsset: vi.fn(),
}));
const mockedParseCreationAgentDocumentInput = vi.mocked(
await import('../../services/creation-agent/creationAgentDocumentInput'),
);
const mockedVisualNovelAssetClient = vi.mocked(
await import('../../services/visual-novel-creation'),
);
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
function renderWorkspace(
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
) {
return render(
<AuthUiContext.Provider
value={
{
user: { id: 'user-1' },
platformTheme: 'light',
} as never
}
>
<VisualNovelAgentWorkspace
session={mockVisualNovelSession}
onBack={() => {}}
{...ui}
/>
</AuthUiContext.Provider>,
<VisualNovelAgentWorkspace onBack={() => {}} session={null} {...ui} />,
);
}
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace only exposes one-line input and visual style entry', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ onOpenResult });
renderWorkspace({ onCreateFromForm });
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
expect(screen.getByText('视觉画风')).toBeTruthy();
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '进入结果页' }));
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();
expect(screen.queryByLabelText('上传文档')).toBeNull();
expect(screen.queryByText('进入结果页')).toBeNull();
expect(screen.queryByText('Agent')).toBeNull();
});
test('visual novel workspace opens editable blank draft from blank source', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace submits idea and selected visual style as seed text', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ session: null, onOpenResult });
renderWorkspace({ onCreateFromForm });
await user.click(screen.getByRole('button', { name: '空白' }));
const openResultButtons = screen.getAllByRole('button', {
name: '进入结果页',
fireEvent.change(screen.getByLabelText('一句话创作'), {
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
});
await user.click(openResultButtons[0]!);
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
fireEvent.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(onOpenResult).toHaveBeenCalledTimes(1);
const session = onOpenResult.mock.calls[0]?.[0];
expect(session?.sourceMode).toBe('blank');
expect(session?.draft?.sourceMode).toBe('blank');
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
expect(onCreateFromForm).toHaveBeenCalledWith({
sourceMode: 'idea',
sourceAssetIds: [],
ideaText: '失忆画师在雨夜剧场寻找旧胶片。',
visualStyleId: 'pixel-noir',
visualStyleLabel: '像素霓虹',
visualStylePrompt:
'高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
seedText:
'失忆画师在雨夜剧场寻找旧胶片。\n视觉画风像素霓虹\n画风要求高可读像素视觉小说画风霓虹反差、硬朗轮廓和复古界面气质。',
});
});
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
const user = userEvent.setup();
const onCreateSession = vi.fn();
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
parseMock.mockResolvedValue({
document: {
fileName: '世界设定.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
sizeBytes: 128,
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
sourceAssetId: 'asset-doc-source',
},
});
uploadMock.mockResolvedValue({
assetObjectId: 'asset-doc-1',
assetKind: 'visual_novel_document',
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
});
renderWorkspace({ session: null, onCreateSession });
await user.click(screen.getByRole('button', { name: '文档' }));
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
const file = new File(['first chapter'], '世界设定.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
await user.upload(fileInput, file);
await user.click(screen.getByRole('button', { name: '生成底稿' }));
expect(parseMock).toHaveBeenCalledTimes(1);
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(onCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
sourceMode: 'document',
sourceAssetIds: ['asset-doc-1'],
seedText: expect.stringContaining('第一章'),
}),
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画风要求透明水彩与绘本质感。',
sourceAssetIds: [],
ideaText: '雨夜书店',
visualStyleId: 'watercolor' as const,
visualStyleLabel: '水彩绘本',
visualStylePrompt: '透明水彩与绘本质感。',
};
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
]);
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
8_000,
);
expect(progress.phaseId).toBe('generating');
expect(progress.overallProgress).toBeGreaterThan(0);
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
});

View File

@@ -1,70 +1,146 @@
import {
ArrowLeft,
FileText,
Loader2,
PenLine,
Upload,
Send,
Sparkles,
} from 'lucide-react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
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,
ExecuteVisualNovelAgentActionRequest,
SendVisualNovelMessageRequest,
VisualNovelAgentSessionSnapshot,
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
import { useAuthUi } from '../auth/AuthUiContext';
import { parseCreationAgentDocumentInput } from '../../services/creation-agent/creationAgentDocumentInput';
import { uploadVisualNovelAsset } from '../../services/visual-novel-creation';
import {
createBlankVisualNovelDraft,
createMockVisualNovelSessionFromDraft,
mockVisualNovelSession,
} from '../visual-novel-runtime/visualNovelMockData';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
type VisualNovelAgentWorkspaceProps = {
session?: VisualNovelAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
streamingReplyText?: string;
onBack: () => void;
onCreateSession?: (payload: CreateVisualNovelSessionRequest) => void;
onSubmitMessage?: (payload: SendVisualNovelMessageRequest) => void;
onExecuteAction?: (payload: ExecuteVisualNovelAgentActionRequest) => void;
onOpenResult?: (session: VisualNovelAgentSessionSnapshot) => void;
onCreateFromForm?: (payload: VisualNovelEntryFormPayload) => void;
initialFormPayload?: VisualNovelEntryFormPayload | null;
showBackButton?: boolean;
title?: string | null;
};
const SOURCE_OPTIONS: Array<{
id: VisualNovelSourceMode;
label: string;
icon: typeof PenLine;
}> = [
{ id: 'idea', label: '一句话', icon: Sparkles },
{ id: 'document', label: '文档', icon: FileText },
{ id: 'blank', label: '空白', icon: PenLine },
];
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
function buildClientMessageId() {
return `vn-message-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
type VisualNovelFormState = {
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
};
const VISUAL_NOVEL_STYLE_OPTIONS = [
{
id: 'cinematic-anime',
label: '映画动画',
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
},
{
id: 'watercolor',
label: '水彩绘本',
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
},
{
id: 'pixel-noir',
label: '像素霓虹',
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
},
{
id: 'ink-fantasy',
label: '水墨幻想',
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
},
{
id: 'soft-pastel',
label: '柔彩校园',
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
},
{
id: 'dark-gothic',
label: '暗色哥特',
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
},
] as const;
type VisualNovelStyleOptionId =
(typeof VISUAL_NOVEL_STYLE_OPTIONS)[number]['id'];
const EMPTY_FORM_STATE: VisualNovelFormState = {
ideaText: '',
visualStyleId: 'cinematic-anime',
};
function getVisualNovelStyleOption(optionId: VisualNovelStyleOptionId) {
return (
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === optionId) ??
VISUAL_NOVEL_STYLE_OPTIONS[0]
);
}
function clampDocumentSeedText(value: string) {
return value.trim().replace(/\s+/gu, ' ').slice(0, 4000);
function resolveStyleOptionId(
value: string | null | undefined,
): VisualNovelStyleOptionId {
return (
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === value)?.id ??
'cinematic-anime'
);
}
function VisualNovelSourceButton({
function resolveIdeaTextFromSession(
session: VisualNovelAgentSessionSnapshot | null | undefined,
) {
const userText =
session?.messages.find((message) => message.role === 'user')?.text ?? '';
return userText
.replace(/[:][^\n]*(\n|$)/u, '')
.replace(/[:][^\n]*(\n|$)/u, '')
.trim();
}
function resolveInitialFormState(
session: VisualNovelAgentSessionSnapshot | null | undefined,
initialFormPayload: VisualNovelEntryFormPayload | null = null,
): VisualNovelFormState {
return {
...EMPTY_FORM_STATE,
ideaText:
initialFormPayload?.ideaText?.trim() ||
resolveIdeaTextFromSession(session) ||
'',
visualStyleId: resolveStyleOptionId(initialFormPayload?.visualStyleId),
};
}
function buildVisualNovelSeedText(
ideaText: string,
visualStyleLabel: string,
visualStylePrompt: string,
) {
return [
ideaText.trim(),
`视觉画风:${visualStyleLabel}`,
`画风要求:${visualStylePrompt}`,
]
.filter(Boolean)
.join('\n');
}
function VisualNovelStyleButton({
active,
disabled,
icon: Icon,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
icon: typeof PenLine;
label: string;
onClick: () => void;
}) {
@@ -73,353 +149,349 @@ function VisualNovelSourceButton({
type="button"
disabled={disabled}
aria-pressed={active}
aria-label={label}
onClick={onClick}
className={`flex min-h-16 min-w-0 items-center gap-3 rounded-[1.1rem] border px-3 text-left transition ${
className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${
active
? 'border-[var(--platform-button-primary-border)] bg-[var(--platform-nav-active-fill)] text-[var(--platform-text-strong)]'
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)] hover:bg-white'
? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]'
: 'border-[var(--platform-subpanel-border)]'
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-white/80">
<Icon className="h-4 w-4" />
<span className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(244,247,255,0.9))]" />
<span className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.95),transparent_28%),linear-gradient(135deg,rgba(255,64,86,0.18),rgba(56,189,248,0.18))]" />
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(3,7,18,0.02)_0%,rgba(3,7,18,0.1)_42%,rgba(3,7,18,0.82)_100%)]" />
<span className="absolute inset-x-2 bottom-1.5 truncate rounded-full bg-black/26 px-1.5 py-0.5 text-center text-[11px] font-black text-white [text-shadow:0_1px_6px_rgba(0,0,0,0.9)]">
{label}
</span>
<span className="min-w-0 truncate text-sm font-black">{label}</span>
</button>
);
}
export function VisualNovelAgentWorkspace({
session = mockVisualNovelSession,
session = null,
isBusy = false,
error = null,
streamingReplyText = '',
onBack,
onCreateSession,
onSubmitMessage,
onExecuteAction,
onOpenResult,
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = null,
}: VisualNovelAgentWorkspaceProps) {
const [sourceMode, setSourceMode] = useState<VisualNovelSourceMode>(
session?.sourceMode ?? 'idea',
const [formState, setFormState] = useState<VisualNovelFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [seedText, setSeedText] = useState(
session?.messages.find((message) => message.role === 'user')?.text ?? '',
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const nextInitialFormKey =
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
}, [initialFormPayload, session]);
const ideaText = formState.ideaText.trim();
const selectedStyleOption = getVisualNovelStyleOption(
formState.visualStyleId,
);
const [documentAssetId, setDocumentAssetId] = useState(
session?.draft?.sourceAssetIds[0] ?? '',
);
const [documentAssetLabel, setDocumentAssetLabel] = useState('');
const [documentUploadError, setDocumentUploadError] = useState<string | null>(
null,
);
const [isDocumentUploading, setIsDocumentUploading] = useState(false);
const [messageText, setMessageText] = useState('');
const displaySession = session ?? mockVisualNovelSession;
const draft = displaySession.draft;
const authUi = useAuthUi();
const documentFileInputRef = useRef<HTMLInputElement | null>(null);
const canStart =
!isBusy &&
((sourceMode === 'blank') ||
(sourceMode === 'idea' && Boolean(seedText.trim())) ||
(sourceMode === 'document' && Boolean(documentAssetId.trim())));
const canSend = !isBusy && messageText.trim();
const pendingAction = displaySession.pendingAction;
const progressItems = useMemo(
() => [
{ label: '世界观', value: draft?.world.title || '-' },
{ label: '角色', value: `${draft?.characters.length ?? 0}` },
{ label: '场景', value: `${draft?.scenes.length ?? 0}` },
{ label: '阶段', value: `${draft?.storyPhases.length ?? 0}` },
],
[draft],
const formPayload = useMemo<VisualNovelEntryFormPayload>(
() => ({
sourceMode: 'idea',
seedText: buildVisualNovelSeedText(
ideaText,
selectedStyleOption.label,
selectedStyleOption.prompt,
),
sourceAssetIds: [],
ideaText,
visualStyleId: selectedStyleOption.id,
visualStyleLabel: selectedStyleOption.label,
visualStylePrompt: selectedStyleOption.prompt,
}),
[ideaText, selectedStyleOption],
);
const canSubmit = Boolean(ideaText && !isBusy);
const startDraft = () => {
if (!canStart) {
if (!canSubmit) {
return;
}
if (sourceMode === 'blank') {
const blankSession = createMockVisualNovelSessionFromDraft(
createBlankVisualNovelDraft('blank'),
);
onOpenResult?.(blankSession);
return;
}
onCreateSession?.({
sourceMode,
seedText: seedText.trim() || null,
sourceAssetIds:
sourceMode === 'document' && documentAssetId.trim()
? [documentAssetId.trim()]
: [],
});
};
const handleDocumentFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
setIsDocumentUploading(true);
setDocumentUploadError(null);
try {
const parsed = await parseCreationAgentDocumentInput(file);
const uploadedAsset = await uploadVisualNovelAsset({
kind: 'document',
file,
ownerUserId: authUi?.user?.id ?? null,
});
setDocumentAssetId(uploadedAsset.assetObjectId);
setDocumentAssetLabel(file.name.trim() || parsed.document.fileName);
setSeedText(clampDocumentSeedText(parsed.document.text));
} catch (uploadError) {
setDocumentUploadError(
uploadError instanceof Error
? uploadError.message
: '文档上传失败,请稍后重试。',
);
} finally {
setIsDocumentUploading(false);
}
};
const submitMessage = () => {
const text = messageText.trim();
if (!text || isBusy) {
return;
}
onSubmitMessage?.({
clientMessageId: buildClientMessageId(),
text,
});
setMessageText('');
onCreateFromForm?.(formPayload);
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</div>
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(20rem,0.55fr)]">
<section className="platform-subpanel rounded-[1.45rem] p-4 sm:p-5">
<div className="mb-4">
<h1 className="m-0 text-3xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-5xl">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-[11px] font-black text-rose-700">
BETA
</span>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{SOURCE_OPTIONS.map((option) => (
<VisualNovelSourceButton
key={option.id}
active={sourceMode === option.id}
disabled={isBusy}
icon={option.icon}
label={option.label}
onClick={() => setSourceMode(option.id)}
/>
))}
</div>
{sourceMode === 'document' ? (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={isBusy || isDocumentUploading}
onClick={() => documentFileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
>
{isDocumentUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{documentAssetId ? '重新上传文档' : '上传平台文档'}
</button>
{documentAssetId ? (
<span className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2 text-xs font-semibold text-[var(--platform-text-base)]">
{documentAssetLabel || '已绑定平台文档'}
</span>
) : null}
<input
ref={documentFileInputRef}
type="file"
accept=".txt,.md,.markdown,.docx,.csv,.json"
disabled={isBusy || isDocumentUploading}
onChange={(event) => {
void handleDocumentFileChange(event);
}}
className="hidden"
aria-label="上传文档"
/>
</div>
{documentAssetId ? (
<div className="truncate rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
ID{documentAssetId}
</div>
) : null}
{documentUploadError ? (
<div className="rounded-[1rem] border border-rose-200/50 bg-rose-500/10 px-4 py-2 text-sm leading-6 text-rose-700">
{documentUploadError}
</div>
) : null}
</div>
) : null}
<label className="mt-4 block">
<span className="sr-only"></span>
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={seedText}
disabled={isBusy || sourceMode === 'blank'}
rows={8}
onChange={(event) => setSeedText(event.target.value)}
placeholder={
sourceMode === 'document'
? '粘贴文档摘要或选择平台文档资产'
: sourceMode === 'blank'
? '空白创建将直接进入结果页'
: '雪夜列车、旧电台、失踪乘客'
value={formState.ideaText}
disabled={isBusy}
rows={5}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
ideaText: event.target.value,
}))
}
className="w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/88 px-4 py-4 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 disabled:opacity-60"
aria-label="创作想法"
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none sm:min-h-[9rem] lg:min-h-[14rem]"
aria-label="一句话创作"
/>
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div
className="flex snap-x gap-2 overflow-x-auto overscroll-x-contain pb-1 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="视觉画风"
>
{VISUAL_NOVEL_STYLE_OPTIONS.map((option) => (
<VisualNovelStyleButton
key={option.id}
active={formState.visualStyleId === option.id}
disabled={isBusy}
label={option.label}
onClick={() =>
setFormState((current) => ({
...current,
visualStyleId: option.id,
}))
}
/>
))}
</div>
</div>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<div className="mt-4 flex justify-end">
<button
type="button"
disabled={!canStart}
onClick={startDraft}
className="platform-button platform-button--primary min-h-11 px-5 py-3"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{sourceMode === 'blank' ? '进入结果页' : '生成底稿'}
</button>
</div>
</section>
<aside className="platform-subpanel flex min-h-[22rem] flex-col rounded-[1.45rem] p-4 sm:p-5">
<div className="grid grid-cols-4 gap-2">
{progressItems.map((item) => (
<div
key={item.label}
className="min-w-0 rounded-[0.9rem] bg-white/72 px-2 py-2 text-center"
>
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
{item.label}
</div>
<div className="mt-1 truncate text-sm font-black text-[var(--platform-text-strong)]">
{item.value}
</div>
</div>
))}
</div>
<div className="mt-4 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
{displaySession.messages.map((message) => (
<div
key={message.id}
className={`max-w-[88%] rounded-[1.1rem] px-3 py-2 text-sm leading-6 ${
message.role === 'user'
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
: 'bg-white/78 text-[var(--platform-text-strong)]'
}`}
>
{message.text}
</div>
))}
{streamingReplyText ? (
<div className="max-w-[88%] rounded-[1.1rem] bg-white/78 px-3 py-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{streamingReplyText}
</div>
) : null}
</div>
{pendingAction ? (
<button
type="button"
disabled={isBusy}
onClick={() =>
onExecuteAction?.({
actionId: pendingAction.actionId,
kind: pendingAction.kind,
targetId: pendingAction.targetId ?? null,
payload: pendingAction.payload,
})
}
className="platform-button platform-button--secondary mt-4 min-h-11 justify-center px-4 py-3"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{pendingAction.label || '执行操作'}
</button>
) : null}
<div className="mt-4 flex gap-2">
<input
value={messageText}
disabled={isBusy}
onChange={(event) => setMessageText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submitMessage();
}
}}
className="min-h-11 min-w-0 flex-1 rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-4 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="补充设定"
aria-label="补充设定"
/>
<button
type="button"
disabled={!canSend}
onClick={submitMessage}
className="platform-icon-button h-11 w-11"
aria-label="发送"
title="发送"
>
<Send className="h-4 w-4" />
</button>
</div>
</aside>
</div>
</div>
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={isBusy || !displaySession.draft}
onClick={() => onOpenResult?.(displaySession)}
className="platform-button platform-button--primary min-h-11 px-5 py-3"
disabled={!canSubmit}
onClick={startDraft}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{session ? (
<Sparkles className="h-4 w-4" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
</span>
</span>
</button>
</div>
</div>
);
}
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;