1
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user