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