Files
Genarrative/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx
T
kdletters ce13bdbb02 继续收口浅色返回按钮
将统一创作页与多个浅色工作台返回入口切换为 PlatformBackActionButton
按页面保留 compact 与 regular 返回尺寸及禁用态
清理冗余 ArrowLeft 引用并完成对应页面定向验证
2026-06-11 04:54:08 +08:00

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import type { VisualNovelEntryFormPayload } from './visualNovelEntryGeneration';
type VisualNovelAgentWorkspaceProps = {
session?: VisualNovelAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onCreateFromForm?: (payload: VisualNovelEntryFormPayload) => void;
initialFormPayload?: VisualNovelEntryFormPayload | null;
showBackButton?: boolean;
title?: string | null;
};
type VisualNovelFormState = {
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
};
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;
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 resolveStyleOptionId(
value: string | null | undefined,
): VisualNovelStyleOptionId {
return (
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === value)?.id ??
'cinematic-anime'
);
}
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,
imageSrc,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
imageSrc: string;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
aria-pressed={active}
aria-label={label}
onClick={onClick}
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:h-[5.45rem] sm:w-[6.4rem] ${
active
? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.10)] ring-2 ring-[var(--platform-warm-border)]'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-[var(--platform-surface-hover-border)] hover:bg-white/95'
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
{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-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
) : null}
<span
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
active
? 'bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'bg-white/88 text-[var(--platform-text-strong)]'
}`}
>
{label}
</span>
</button>
);
}
export function VisualNovelAgentWorkspace({
session = null,
isBusy = false,
error = null,
onBack,
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = null,
}: VisualNovelAgentWorkspaceProps) {
const [formState, setFormState] = useState<VisualNovelFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
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 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 (!canSubmit) {
return;
}
onCreateFromForm?.(formPayload);
};
return (
<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">
<PlatformBackActionButton
onClick={onBack}
disabled={isBusy}
className="px-3"
/>
</div>
) : null}
<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>
<PlatformPillBadge tone="warning" size="xs">
BETA
</PlatformPillBadge>
</div>
</div>
) : null}
<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-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">
<PlatformFieldLabel variant="form">一句话创作</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={formState.ideaText}
disabled={isBusy}
rows={5}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
ideaText: event.target.value,
}))
}
size="lg"
density="roomy"
className="h-full min-h-[7.75rem] rounded-[1.15rem] font-normal leading-6 sm:min-h-[9rem] lg:min-h-[14rem]"
aria-label="一句话创作"
/>
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="sm"
className="min-h-0 bg-white/52 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]"
>
<PlatformFieldLabel variant="form" className="mb-1.5">
视觉画风
</PlatformFieldLabel>
<div
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 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}
imageSrc={option.imageSrc}
label={option.label}
onClick={() =>
setFormState((current) => ({
...current,
visualStyleId: option.id,
}))
}
/>
))}
</div>
</PlatformSubpanel>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="rounded-2xl"
>
{error}
</PlatformStatusMessage>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<PlatformActionButton
disabled={!canSubmit}
onClick={startDraft}
className="min-h-10 gap-1.5 px-4 py-2 text-sm sm:min-h-11 sm:gap-2 sm:px-5"
>
{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>
</PlatformActionButton>
</div>
</div>
);
}
export default VisualNovelAgentWorkspace;