This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -0,0 +1,123 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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';
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>>) {
return render(
<AuthUiContext.Provider
value={
{
user: { id: 'user-1' },
platformTheme: 'light',
} as never
}
>
<VisualNovelAgentWorkspace
session={mockVisualNovelSession}
onBack={() => {}}
{...ui}
/>
</AuthUiContext.Provider>,
);
}
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
renderWorkspace({ onOpenResult });
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '进入结果页' }));
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
});
test('visual novel workspace opens editable blank draft from blank source', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
renderWorkspace({ session: null, onOpenResult });
await user.click(screen.getByRole('button', { name: '空白' }));
const openResultButtons = screen.getAllByRole('button', {
name: '进入结果页',
});
await user.click(openResultButtons[0]!);
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);
});
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;
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('第一章'),
}),
);
});

View File

@@ -0,0 +1,425 @@
import {
ArrowLeft,
FileText,
Loader2,
PenLine,
Upload,
Send,
Sparkles,
} from 'lucide-react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
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';
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;
};
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 },
];
function buildClientMessageId() {
return `vn-message-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
function clampDocumentSeedText(value: string) {
return value.trim().replace(/\s+/gu, ' ').slice(0, 4000);
}
function VisualNovelSourceButton({
active,
disabled,
icon: Icon,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
icon: typeof PenLine;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
aria-pressed={active}
onClick={onClick}
className={`flex min-h-16 min-w-0 items-center gap-3 rounded-[1.1rem] border px-3 text-left transition ${
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'
} ${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>
<span className="min-w-0 truncate text-sm font-black">{label}</span>
</button>
);
}
export function VisualNovelAgentWorkspace({
session = mockVisualNovelSession,
isBusy = false,
error = null,
streamingReplyText = '',
onBack,
onCreateSession,
onSubmitMessage,
onExecuteAction,
onOpenResult,
}: VisualNovelAgentWorkspaceProps) {
const [sourceMode, setSourceMode] = useState<VisualNovelSourceMode>(
session?.sourceMode ?? 'idea',
);
const [seedText, setSeedText] = useState(
session?.messages.find((message) => message.role === 'user')?.text ?? '',
);
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 startDraft = () => {
if (!canStart) {
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('');
};
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="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">
</h1>
</div>
<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>
<textarea
value={seedText}
disabled={isBusy || sourceMode === 'blank'}
rows={8}
onChange={(event) => setSeedText(event.target.value)}
placeholder={
sourceMode === 'document'
? '粘贴文档摘要或选择平台文档资产'
: sourceMode === 'blank'
? '空白创建将直接进入结果页'
: '雪夜列车、旧电台、失踪乘客'
}
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="创作想法"
/>
</label>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 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>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={isBusy || !displaySession.draft}
onClick={() => onOpenResult?.(displaySession)}
className="platform-button platform-button--primary min-h-11 px-5 py-3"
>
</button>
</div>
</div>
);
}
export default VisualNovelAgentWorkspace;