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

@@ -102,7 +102,7 @@ export function SkillEffectPreview({
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
const [replayTick, setReplayTick] = useState(0);
const [restartTick, setRestartTick] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
@@ -216,7 +216,7 @@ export function SkillEffectPreview({
active = false;
timers.forEach(timerId => window.clearTimeout(timerId));
};
}, [character, initialMonsters, mode, replayTick, scenePreset, skill]);
}, [character, initialMonsters, mode, restartTick, scenePreset, skill]);
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
@@ -229,12 +229,12 @@ export function SkillEffectPreview({
</div>
<button
type="button"
onClick={() => setReplayTick(value => value + 1)}
onClick={() => setRestartTick(value => value + 1)}
disabled={!skill || isPlaying}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-200 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<RotateCcw className="h-3.5 w-3.5" />
<span>{isPlaying ? '播放中' : '重预览'}</span>
<span>{isPlaying ? '播放中' : '重预览'}</span>
</button>
</div>

View File

@@ -88,7 +88,7 @@ type CreationAgentWorkspaceProps = {
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
const DOCUMENT_INPUT_ACCEPT =
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
'.txt,.md,.markdown,.docx,.csv,.json,text/plain,text/markdown,text/csv,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp';
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {

View File

@@ -0,0 +1,423 @@
import {
Bell,
Bookmark,
ChevronRight,
Gamepad2,
Menu,
MessageCircle,
Moon,
Music,
PanelLeftClose,
Settings,
Sparkles,
UserRound,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import type { CreativeAgentInputPart } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf';
import { RpgEntryBrandLogo } from '../rpg-entry/RpgEntryBrandLogo';
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
import { createCreativeAgentClientMessageId } from './creativeAgentViewModel';
type CreativeAgentHomePrompt = {
id: string;
label: string;
prompt: string;
icon: typeof Sparkles;
tone: 'cool' | 'green' | 'warm' | 'purple' | 'rose';
badge?: string;
};
export type CreativeAgentHistoryItem = {
id: string;
title: string;
groupLabel: string;
source: CreationWorkShelfItem;
};
type CreativeAgentHomeProps = {
recentItems: CreativeAgentHistoryItem[];
isBusy: boolean;
error: string | null;
onStartNewChat: () => void;
onOpenHistoryItem: (item: CreationWorkShelfItem) => void;
onOpenDrafts: () => void;
onOpenAccount: () => void;
onOpenSettings: () => void;
onSubmitMessage: (payload: {
clientMessageId: string;
content: CreativeAgentInputPart[];
}) => void;
};
const PROMPT_SUGGESTIONS: CreativeAgentHomePrompt[] = [
{
id: 'identity',
label: '你是谁',
prompt: '介绍一下你能帮我创作什么。',
icon: Sparkles,
tone: 'cool',
},
{
id: 'flash-app',
label: '一句话生成闪应用',
prompt: '帮我把一个灵感做成可互动的小应用。',
icon: Moon,
tone: 'green',
},
{
id: 'mini-game',
label: '捏个小游戏',
prompt: '帮我做一个适合马上玩的创意小游戏。',
icon: Gamepad2,
tone: 'warm',
},
{
id: 'world-model',
label: '体验世界模型',
prompt: '用一个世界设定帮我生成可体验的互动内容。',
icon: Bookmark,
tone: 'purple',
badge: 'Beta',
},
{
id: 'music',
label: '音乐扭蛋',
prompt: '把一段音乐灵感做成互动拼图。',
icon: Music,
tone: 'rose',
},
];
function buildCreativeHomeInputParts(payload: {
text: string;
image: { imageUrl: string; thumbnailUrl: string } | null;
}): CreativeAgentInputPart[] {
const content: CreativeAgentInputPart[] = [];
if (payload.text) {
content.push({
type: 'input_text',
text: payload.text,
});
}
if (payload.image) {
content.push({
type: 'input_image',
imageUrl: payload.image.imageUrl,
thumbnailUrl: payload.image.thumbnailUrl,
assetId: null,
});
}
return content;
}
function groupRecentItemsByLabel(items: CreativeAgentHistoryItem[]) {
const groups: Array<{ label: string; items: CreativeAgentHistoryItem[] }> = [];
for (const item of items) {
const lastGroup = groups[groups.length - 1];
if (lastGroup?.label === item.groupLabel) {
lastGroup.items.push(item);
continue;
}
groups.push({
label: item.groupLabel,
items: [item],
});
}
return groups;
}
function CreativeAgentPromptButton({
item,
disabled,
onClick,
}: {
item: CreativeAgentHomePrompt;
disabled: boolean;
onClick: () => void;
}) {
const Icon = item.icon;
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`creative-agent-home__prompt creative-agent-home__prompt--${item.tone}`}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="truncate">{item.label}</span>
{item.badge ? (
<span className="creative-agent-home__prompt-badge">{item.badge}</span>
) : null}
</button>
);
}
function CreativeAgentDrawer({
open,
recentItems,
onClose,
onStartNewChat,
onOpenHistoryItem,
onOpenDrafts,
onOpenAccount,
onOpenSettings,
}: {
open: boolean;
recentItems: CreativeAgentHistoryItem[];
onClose: () => void;
onStartNewChat: () => void;
onOpenHistoryItem: (item: CreationWorkShelfItem) => void;
onOpenDrafts: () => void;
onOpenAccount: () => void;
onOpenSettings: () => void;
}) {
const groupedItems = useMemo(
() => groupRecentItemsByLabel(recentItems),
[recentItems],
);
return (
<>
<div
className={`creative-agent-drawer-backdrop ${open ? 'creative-agent-drawer-backdrop--open' : ''}`}
onClick={onClose}
/>
<aside
className={`creative-agent-drawer ${open ? 'creative-agent-drawer--open' : ''}`}
aria-hidden={!open}
>
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 px-5 pb-5 pt-[max(1.1rem,env(safe-area-inset-top))]">
<RpgEntryBrandLogo decorative />
<button
type="button"
onClick={onClose}
className="platform-icon-button"
aria-label="关闭侧边栏"
title="关闭"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</header>
<div className="shrink-0 space-y-3 px-5">
<button
type="button"
onClick={() => {
onStartNewChat();
onClose();
}}
className="creative-agent-drawer__new-chat"
>
<MessageCircle className="h-5 w-5" />
<span></span>
</button>
<button
type="button"
onClick={() => {
onOpenDrafts();
onClose();
}}
className="creative-agent-drawer__nav-row"
>
<Bookmark className="h-5 w-5" />
<span></span>
<ChevronRight className="ml-auto h-4 w-4 opacity-55" />
</button>
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto px-5 pb-5">
{groupedItems.length > 0 ? (
groupedItems.map((group) => (
<section key={group.label} className="mb-6">
<div className="creative-agent-drawer__group-label">
{group.label}
</div>
<div className="mt-3 space-y-3">
{group.items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onOpenHistoryItem(item.source);
onClose();
}}
className="creative-agent-drawer__history-item"
>
{item.title}
</button>
))}
</div>
</section>
))
) : (
<div className="creative-agent-drawer__empty"></div>
)}
</div>
<footer className="flex shrink-0 items-center justify-between gap-3 px-5 py-5 pb-[max(1.15rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => {
onOpenAccount();
onClose();
}}
className="creative-agent-drawer__avatar"
aria-label="账号"
>
<UserRound className="h-5 w-5" />
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
onOpenSettings();
onClose();
}}
className="platform-icon-button"
aria-label="外观"
title="外观"
>
<Moon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
onOpenSettings();
onClose();
}}
className="platform-icon-button"
aria-label="设置"
title="设置"
>
<Settings className="h-4 w-4" />
</button>
</div>
</footer>
</div>
</aside>
</>
);
}
export function CreativeAgentHome({
recentItems,
isBusy,
error,
onStartNewChat,
onOpenHistoryItem,
onOpenDrafts,
onOpenAccount,
onOpenSettings,
onSubmitMessage,
}: CreativeAgentHomeProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const submitText = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText || isBusy) {
return;
}
onSubmitMessage({
clientMessageId: createCreativeAgentClientMessageId(),
content: [
{
type: 'input_text',
text: trimmedText,
},
],
});
};
return (
<div className="creative-agent-home platform-remap-surface">
<div className="creative-agent-home__backdrop" />
<header className="creative-agent-home__topbar">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="creative-agent-home__topbar-button"
aria-label="打开侧边栏"
title="菜单"
>
<Menu className="h-6 w-6" />
</button>
<RpgEntryBrandLogo className="creative-agent-home__brand" decorative />
<button
type="button"
onClick={onOpenAccount}
className="creative-agent-home__topbar-button"
aria-label="通知与账户"
title="通知与账户"
>
<Bell className="h-5 w-5" />
</button>
</header>
<main className="creative-agent-home__main">
<div className="creative-agent-home__hero">
<h1>Hi, </h1>
<p></p>
</div>
<div className="creative-agent-home__prompt-grid">
{PROMPT_SUGGESTIONS.map((item) => (
<CreativeAgentPromptButton
key={item.id}
item={item}
disabled={isBusy}
onClick={() => submitText(item.prompt)}
/>
))}
<button
type="button"
className="creative-agent-home__reward"
disabled={isBusy}
onClick={() => submitText('帮我做一个能马上分享的创意拼图。')}
>
<Sparkles className="h-6 w-6" />
<span> 1亿</span>
</button>
</div>
{error ? (
<div className="creative-agent-home__error">{error}</div>
) : null}
</main>
<div className="creative-agent-home__composer">
<CreativeAgentInputComposer
variant="floating"
isBusy={isBusy}
placeholder="问一问百梦"
onSubmit={(payload) => {
const content = buildCreativeHomeInputParts(payload);
if (content.length === 0) {
return;
}
onSubmitMessage({
clientMessageId: createCreativeAgentClientMessageId(),
content,
});
}}
/>
</div>
<CreativeAgentDrawer
open={drawerOpen}
recentItems={recentItems}
onClose={() => setDrawerOpen(false)}
onStartNewChat={onStartNewChat}
onOpenHistoryItem={onOpenHistoryItem}
onOpenDrafts={onOpenDrafts}
onOpenAccount={onOpenAccount}
onOpenSettings={onOpenSettings}
/>
</div>
);
}
export default CreativeAgentHome;

View File

@@ -0,0 +1,160 @@
import { ArrowUp, ImagePlus, Loader2, Plus, X } from 'lucide-react';
import { type ChangeEvent, useState } from 'react';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
export type CreativeAgentComposerImage = {
imageUrl: string;
thumbnailUrl: string;
label: string;
};
type CreativeAgentInputComposerProps = {
isBusy: boolean;
variant?: 'panel' | 'floating';
placeholder?: string;
onSubmit: (payload: {
text: string;
image: CreativeAgentComposerImage | null;
}) => void;
};
export function CreativeAgentInputComposer({
isBusy,
variant = 'panel',
placeholder = '想做成什么拼图?',
onSubmit,
}: CreativeAgentInputComposerProps) {
const [text, setText] = useState('');
const [image, setImage] = useState<CreativeAgentComposerImage | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const canSubmit = !isBusy && Boolean(text.trim() || image);
const handleImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setImage({
imageUrl: dataUrl,
thumbnailUrl: dataUrl,
label: file.name.trim() || '参考图',
});
setImageError(null);
} catch (error) {
setImageError(
error instanceof Error ? error.message : '参考图读取失败,请重试。',
);
}
};
const submit = () => {
if (!canSubmit) {
return;
}
onSubmit({
text: text.trim(),
image,
});
setText('');
setImage(null);
setImageError(null);
};
const floating = variant === 'floating';
return (
<section
className={
floating
? 'creative-agent-composer creative-agent-composer--floating'
: 'platform-subpanel rounded-[1.35rem] p-3 sm:p-4'
}
>
<div className="flex items-end gap-2">
<label
className={`platform-icon-button h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''} ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
title={image ? '更换参考图' : '添加参考图'}
>
{floating ? (
<Plus className="h-5 w-5" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<span className="sr-only">{image ? '更换参考图' : '添加参考图'}</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleImageChange(event);
}}
className="hidden"
/>
</label>
<textarea
value={text}
disabled={isBusy}
rows={2}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submit();
}
}}
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
placeholder={placeholder}
aria-label="智能创作输入"
/>
<button
type="button"
disabled={!canSubmit}
onClick={submit}
className="platform-icon-button h-11 w-11 shrink-0"
aria-label="发送"
title="发送"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</button>
</div>
{image ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-2">
<img
src={image.thumbnailUrl}
alt="创作参考图"
className="h-12 w-12 rounded-[0.8rem] object-cover"
/>
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{image.label}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => setImage(null)}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{imageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">{imageError}</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,119 @@
import {
CheckCircle2,
CircleDot,
Clock3,
Loader2,
TriangleAlert,
} from 'lucide-react';
import type { CreativeAgentProcessItem } from './creativeAgentViewModel';
type CreativeAgentProcessPanelProps = {
items: CreativeAgentProcessItem[];
isStreaming: boolean;
};
const PROCESS_TONE_CLASS: Record<CreativeAgentProcessItem['tone'], string> = {
active: 'border-[rgba(255,105,145,0.38)] bg-white/82',
done: 'border-emerald-200/80 bg-emerald-50/82',
info: 'border-[var(--platform-subpanel-border)] bg-white/68',
warning: 'border-amber-200/80 bg-amber-50/82',
danger: 'border-red-200/80 bg-red-50/86',
};
function ProcessIcon({ item }: { item: CreativeAgentProcessItem }) {
if (item.tone === 'active') {
return <Loader2 className="h-3.5 w-3.5 animate-spin" />;
}
if (item.tone === 'done') {
return <CheckCircle2 className="h-3.5 w-3.5" />;
}
if (item.tone === 'warning' || item.tone === 'danger') {
return <TriangleAlert className="h-3.5 w-3.5" />;
}
return <CircleDot className="h-3.5 w-3.5" />;
}
export function CreativeAgentProcessPanel({
items,
isStreaming,
}: CreativeAgentProcessPanelProps) {
const visibleItems = items.slice(-12).reverse();
if (visibleItems.length === 0) {
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<Clock3 className="h-4 w-4 text-[var(--platform-text-soft)]" />
</div>
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-base)]">
</div>
</section>
);
}
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{isStreaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</div>
<div className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/62 px-2.5 py-1 text-[11px] font-bold text-[var(--platform-text-base)]">
{items.length}
</div>
</div>
<div className="mt-3 max-h-[22rem] space-y-2 overflow-y-auto pr-1">
{visibleItems.map((item) => (
<article
key={item.id}
className={`rounded-[1rem] border px-3 py-3 ${PROCESS_TONE_CLASS[item.tone]}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/82 text-[var(--platform-text-strong)] shadow-sm">
<ProcessIcon item={item} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-white/72 px-2 py-0.5 text-[11px] font-black text-[var(--platform-text-soft)]">
{item.meta}
</span>
<div className="min-w-0 flex-1 text-sm font-black leading-5 text-[var(--platform-text-strong)]">
{item.title}
</div>
</div>
{item.detail ? (
<div className="mt-1 text-xs leading-5 text-[var(--platform-text-base)]">
{item.detail}
</div>
) : null}
{item.detailLines.length > 0 ? (
<div className="mt-2 space-y-1">
{item.detailLines.map((line, index) => (
<div
key={`${item.id}-line-${index}`}
className="truncate rounded-[0.7rem] bg-white/58 px-2 py-1 text-[11px] font-semibold leading-4 text-[var(--platform-text-base)]"
title={line}
>
{line}
</div>
))}
</div>
) : null}
</div>
</div>
</article>
))}
</div>
</section>
);
}
export default CreativeAgentProcessPanel;

View File

@@ -0,0 +1,62 @@
import { Check, Loader2 } from 'lucide-react';
import type { CreativeAgentStage } from '../../../packages/shared/src/contracts/creativeAgent';
import {
CREATIVE_AGENT_TIMELINE,
getCreativeAgentStageDisplayLabel,
} from './creativeAgentViewModel';
type CreativeAgentStageTimelineProps = {
stage: CreativeAgentStage;
};
export function CreativeAgentStageTimeline({
stage,
}: CreativeAgentStageTimelineProps) {
const activeIndex = CREATIVE_AGENT_TIMELINE.indexOf(stage);
const safeActiveIndex =
activeIndex >= 0
? activeIndex
: stage === 'waiting_template_confirmation'
? CREATIVE_AGENT_TIMELINE.indexOf('selecting_puzzle_template') + 1
: -1;
return (
<div
className="grid grid-cols-3 gap-2 sm:grid-cols-5 xl:grid-cols-9"
aria-label="智能创作阶段"
>
{CREATIVE_AGENT_TIMELINE.map((item, index) => {
const isActive = item === stage && stage !== 'target_ready';
const isDone = safeActiveIndex > index || stage === 'target_ready';
const label = getCreativeAgentStageDisplayLabel(
item,
isActive ? 'active' : isDone ? 'done' : 'idle',
);
return (
<div
key={item}
className={`flex min-h-[3.5rem] items-center gap-2 rounded-[1rem] border px-3 py-2 text-xs font-bold ${
isActive
? 'border-[var(--platform-button-primary-border)] bg-white/90 text-[var(--platform-text-strong)] shadow-sm'
: isDone
? 'border-emerald-200/70 bg-emerald-50/82 text-emerald-700'
: 'border-[var(--platform-subpanel-border)] bg-white/46 text-[var(--platform-text-soft)]'
}`}
>
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/82">
{isActive ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isDone ? (
<Check className="h-3.5 w-3.5" />
) : (
<span>{index + 1}</span>
)}
</span>
<span className="leading-4">{label}</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { CreativeAgentTemplateConfirmPanel } from './CreativeAgentTemplateConfirmPanel';
function createSelection(
overrides: Partial<PuzzleCreativeTemplateSelection> = {},
): PuzzleCreativeTemplateSelection {
return {
templateId: 'puzzle.default-creative',
title: '创意拼图',
reason: '这份素材适合转成可编辑、可试玩的拼图草稿。',
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数和每关图片生成次数估算',
},
supportedLevelMode: 'single_or_multi',
selectedLevelMode: 'single_level',
plannedLevelCount: 1,
requiresUserConfirmation: true,
...overrides,
};
}
test('shows cost range and opens an independent adjustment dialog', () => {
const onConfirm = vi.fn();
render(
<CreativeAgentTemplateConfirmPanel
selection={createSelection()}
isBusy={false}
onConfirm={onConfirm}
onCancel={() => {}}
/>,
);
const confirmDialog = screen.getByRole('dialog', { name: '确认拼图模板' });
expect(within(confirmDialog).getByText('预计 2 到 12 光点')).toBeTruthy();
expect(within(confirmDialog).getByText('创意拼图')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: //u }));
const adjustDialog = screen.getByRole('dialog', { name: '调整拼图模板' });
expect(adjustDialog.parentElement).not.toBe(confirmDialog);
fireEvent.click(within(adjustDialog).getByRole('button', { name: '多关卡' }));
fireEvent.change(within(adjustDialog).getByLabelText('计划关卡数'), {
target: { value: '4' },
});
fireEvent.click(within(adjustDialog).getByRole('button', { name: '完成' }));
fireEvent.click(within(confirmDialog).getByRole('button', { name: //u }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
selectedLevelMode: 'multi_level',
plannedLevelCount: 4,
}),
);
});

View File

@@ -0,0 +1,287 @@
import { Check, Puzzle, SlidersHorizontal, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { useAuthUi } from '../auth/AuthUiContext';
type CreativeAgentTemplateConfirmPanelProps = {
selection: PuzzleCreativeTemplateSelection;
isBusy: boolean;
onConfirm: (selection: PuzzleCreativeTemplateSelection) => void;
onCancel: () => void;
};
function clampLevelCount(value: number, selection: PuzzleCreativeTemplateSelection) {
const { min, max } = resolveLevelCountBounds(selection);
return Math.max(min, Math.min(max, value));
}
function resolveLevelCountBounds(selection: PuzzleCreativeTemplateSelection) {
if (selection.selectedLevelMode === 'single_level') {
return {
min: 1,
max: 1,
};
}
return {
min: 2,
max: 6,
};
}
function canUseLevelMode(
selection: PuzzleCreativeTemplateSelection,
mode: PuzzleCreativeTemplateSelection['selectedLevelMode'],
) {
if (selection.supportedLevelMode === 'single') {
return mode === 'single_level';
}
if (selection.supportedLevelMode === 'multi') {
return mode === 'multi_level';
}
return true;
}
export function CreativeAgentTemplateConfirmPanel({
selection,
isBusy,
onConfirm,
onCancel,
}: CreativeAgentTemplateConfirmPanelProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [isAdjustOpen, setIsAdjustOpen] = useState(false);
const [draftSelection, setDraftSelection] = useState(selection);
const levelCountBounds = resolveLevelCountBounds(draftSelection);
useEffect(() => {
setDraftSelection(selection);
}, [selection]);
const pointsText = `${draftSelection.costRange.minPoints}${draftSelection.costRange.maxPoints} 光点`;
const panel = (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[136] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget && !isBusy) {
onCancel();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="确认拼图模板"
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,42rem)] w-full max-w-xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="min-w-0">
<div className="truncate text-lg font-black text-[var(--platform-text-strong)]">
{draftSelection.title}
</div>
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-base)]">
{pointsText}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={onCancel}
className="platform-icon-button"
aria-label="取消模板"
title="取消"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-3">
<div className="overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/68">
<div className="aspect-[16/9] bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
{'previewImageSrc' in draftSelection &&
typeof draftSelection.previewImageSrc === 'string' &&
draftSelection.previewImageSrc.trim() ? (
<img
src={draftSelection.previewImageSrc}
alt={draftSelection.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
<Puzzle className="h-6 w-6" />
</span>
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-[1.25rem] p-4">
<div className="text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{draftSelection.reason}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="platform-subpanel rounded-[1.15rem] p-4">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-base font-black text-[var(--platform-text-strong)]">
{draftSelection.selectedLevelMode === 'single_level'
? '单关卡'
: '多关卡'}
</div>
</div>
<div className="platform-subpanel rounded-[1.15rem] p-4">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-base font-black text-[var(--platform-text-strong)]">
{draftSelection.plannedLevelCount}
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] sm:flex-row sm:justify-end">
<button
type="button"
disabled={isBusy}
onClick={() => setIsAdjustOpen((current) => !current)}
className="platform-button platform-button--ghost"
>
<span className="inline-flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
</span>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => onConfirm(draftSelection)}
className="platform-button platform-button--primary"
>
<span className="inline-flex items-center gap-2">
<Check className="h-4 w-4" />
</span>
</button>
</div>
</section>
{isAdjustOpen ? (
<section
role="dialog"
aria-modal="true"
aria-label="调整拼图模板"
className="platform-modal-shell platform-remap-surface fixed inset-x-3 bottom-3 z-[138] mx-auto w-auto max-w-lg overflow-hidden rounded-[1.5rem] shadow-[0_18px_64px_rgba(0,0,0,0.42)] sm:inset-x-4 sm:bottom-auto sm:top-1/2 sm:-translate-y-1/2"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-black text-[var(--platform-text-strong)]">
</div>
<button
type="button"
disabled={isBusy}
onClick={() => setIsAdjustOpen(false)}
className="platform-icon-button"
aria-label="关闭调整"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 px-5 py-4">
<div className="grid grid-cols-2 gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ value: 'single_level' as const, label: '单关卡' },
{ value: 'multi_level' as const, label: '多关卡' },
].map((item) => (
<button
key={item.value}
type="button"
disabled={isBusy || !canUseLevelMode(draftSelection, item.value)}
onClick={() => {
setDraftSelection((current) => ({
...current,
selectedLevelMode: item.value,
plannedLevelCount:
item.value === 'single_level'
? 1
: Math.max(2, current.plannedLevelCount),
}));
}}
className={`min-h-10 rounded-[0.8rem] px-3 text-sm font-bold ${
draftSelection.selectedLevelMode === item.value
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)]'
}`}
>
{item.label}
</button>
))}
</div>
<label className="flex min-h-11 items-center gap-3">
<span className="shrink-0 text-sm font-bold text-[var(--platform-text-base)]">
</span>
<input
type="number"
min={levelCountBounds.min}
max={levelCountBounds.max}
disabled={
isBusy || draftSelection.selectedLevelMode === 'single_level'
}
value={draftSelection.plannedLevelCount}
onChange={(event) => {
const nextValue = Number.parseInt(
event.target.value || '1',
10,
);
setDraftSelection((current) => ({
...current,
plannedLevelCount: clampLevelCount(
Number.isNaN(nextValue) ? 1 : nextValue,
current,
),
}));
}}
className="min-h-11 min-w-0 flex-1 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 text-sm font-bold text-[var(--platform-text-strong)] outline-none"
aria-label="计划关卡数"
/>
</label>
</div>
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
<button
type="button"
disabled={isBusy}
onClick={() => setIsAdjustOpen(false)}
className="platform-button platform-button--primary"
>
</button>
</div>
</section>
) : null}
</div>
);
if (typeof document === 'undefined') {
return null;
}
return createPortal(panel, document.body);
}

View File

@@ -0,0 +1,249 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type {
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeAgentStage,
} from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleCreativeTemplateProtocol } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
import { resolveCreativeAgentTargetSelectionStage } from './creativeAgentViewModel';
import { CreativeAgentWorkspace } from './CreativeAgentWorkspace';
function createTemplate(
overrides: Partial<PuzzleCreativeTemplateProtocol> = {},
): PuzzleCreativeTemplateProtocol {
return {
templateId: 'puzzle.default-creative',
title: '创意拼图',
summary: '把图文灵感做成拼图。',
previewImageSrc: null,
supportedLevelMode: 'single_or_multi',
minLevelCount: 1,
maxLevelCount: 6,
defaultLevelCount: 1,
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
requiredDraftFields: ['workTitle'],
imagePolicy: {
allowUploadedImageDirectly: true,
allowGeneratedImages: true,
allowPerLevelReferenceImage: true,
defaultCandidateCountPerLevel: 1,
},
...overrides,
};
}
function createSession(
overrides: Partial<CreativeAgentSessionSnapshot> = {},
): CreativeAgentSessionSnapshot {
return {
sessionId: 'creative-session-1',
stage: 'target_ready',
inputSummary: {
text: '做一个生日拼图',
entryContext: 'creation_home',
images: [],
materialSummary: '做一个生日拼图',
unsupportedCapabilities: [
{
playType: 'rpg',
title: 'RPG',
status: 'unsupported',
reason: 'Phase 1 暂不开放',
},
],
},
messages: [
{
id: 'assistant-1',
role: 'assistant',
kind: 'chat',
text: '拼图草稿已准备好。',
createdAt: '2026-05-05T10:00:00.000Z',
},
],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: {
playType: 'puzzle',
targetSessionId: 'puzzle-session-1',
targetStage: 'puzzle-result',
resultProfileId: 'puzzle-profile-1',
},
updatedAt: '2026-05-05T10:00:00.000Z',
...overrides,
};
}
test('target ready session exposes the puzzle result entry action', () => {
const onOpenTarget = vi.fn();
const eventLog: CreativeAgentSseEvent[] = [
{
event: 'puzzle_cost_range',
data: {
sessionId: 'creative-session-1',
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
},
},
];
render(
<CreativeAgentWorkspace
session={createSession()}
isBusy={false}
isStreaming={false}
error={null}
eventLog={eventLog}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={() => {}}
onCancelTemplate={() => {}}
onOpenTarget={onOpenTarget}
/>,
);
expect(screen.getByText('拼图草稿已就绪')).toBeTruthy();
expect(screen.getByText('可以进入结果页继续编辑')).toBeTruthy();
expect(screen.getByText('预计 2-12 光点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开草稿' }));
expect(onOpenTarget).toHaveBeenCalledTimes(1);
});
test('waiting confirmation shows template catalog before template config dialog', () => {
const onConfirmTemplate = vi.fn();
render(
<CreativeAgentWorkspace
session={createSession({
stage: 'waiting_template_confirmation',
targetBinding: null,
puzzleTemplateCatalog: [
createTemplate(),
createTemplate({
templateId: 'puzzle.travel-memory',
title: '旅行记忆拼图',
summary: '把一次出行拆成地点、风景和故事节点拼图。',
defaultLevelCount: 3,
costRange: {
minPoints: 4,
maxPoints: 16,
pricingUnit: 'point',
reason: '按旅行节点估算',
},
}),
],
})}
isBusy={false}
isStreaming={false}
error={null}
eventLog={[]}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={onConfirmTemplate}
onOpenTarget={() => {}}
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
expect(screen.getByText('预计 4 到 16 光点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onConfirmTemplate).toHaveBeenCalledWith(
expect.objectContaining({
templateId: 'puzzle.travel-memory',
selectedLevelMode: 'multi_level',
plannedLevelCount: 3,
}),
);
});
test('switching creative session clears pending template config dialog', () => {
const firstSession = createSession({
sessionId: 'creative-session-first',
stage: 'waiting_template_confirmation',
targetBinding: null,
puzzleTemplateCatalog: [createTemplate()],
});
const secondSession = createSession({
sessionId: 'creative-session-second',
stage: 'waiting_template_confirmation',
targetBinding: null,
puzzleTemplateCatalog: [],
});
const { rerender } = render(
<CreativeAgentWorkspace
session={firstSession}
isBusy={false}
isStreaming={false}
error={null}
eventLog={[]}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={() => {}}
onOpenTarget={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
rerender(
<CreativeAgentWorkspace
session={secondSession}
isBusy={false}
isStreaming={false}
error={null}
eventLog={[]}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={() => {}}
onOpenTarget={() => {}}
/>,
);
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
});
test('target ready puzzle result binding resolves to puzzle-result stage', () => {
expect(resolveCreativeAgentTargetSelectionStage('puzzle-result')).toBe(
'puzzle-result',
);
expect(
resolveCreativeAgentTargetSelectionStage('puzzle-agent-workspace'),
).toBe('puzzle-agent-workspace');
});
test('target ready timeline renders completed labels instead of active labels', () => {
render(<CreativeAgentStageTimeline stage={'target_ready' as CreativeAgentStage} />);
expect(screen.getByText('素材已理解')).toBeTruthy();
expect(screen.getByText('构思已完成')).toBeTruthy();
expect(screen.getByText('草稿已生成')).toBeTruthy();
expect(screen.queryByText('正在理解素材')).toBeNull();
expect(screen.queryByText('正在构思')).toBeNull();
});

View File

@@ -0,0 +1,313 @@
import { ArrowLeft, CheckCircle2, Puzzle } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type {
CreativeAgentInputPart,
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
} from '../../../packages/shared/src/contracts/creativeAgent';
import type {
PuzzleCreativeTemplateProtocol,
PuzzleCreativeTemplateSelection,
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
import { CreativeAgentProcessPanel } from './CreativeAgentProcessPanel';
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
import { CreativeAgentTemplateConfirmPanel } from './CreativeAgentTemplateConfirmPanel';
import {
buildCreativeAgentProcessItems,
buildPuzzleTemplateSelectionFromProtocol,
createCreativeAgentClientMessageId,
CREATIVE_AGENT_STAGE_LABEL,
} from './creativeAgentViewModel';
type CreativeAgentWorkspaceProps = {
session: CreativeAgentSessionSnapshot | null;
isBusy: boolean;
isStreaming: boolean;
error: string | null;
eventLog: CreativeAgentSseEvent[];
onBack: () => void;
onSubmitMessage: (payload: {
clientMessageId: string;
content: CreativeAgentInputPart[];
}) => void;
onConfirmTemplate: (selection: PuzzleCreativeTemplateSelection) => void;
onCancelTemplate?: () => void;
onOpenTarget: () => void;
};
type CreativeAgentTemplateCatalogPanelProps = {
templates: PuzzleCreativeTemplateProtocol[];
isBusy: boolean;
onSelect: (template: PuzzleCreativeTemplateProtocol) => void;
};
function CreativeAgentTemplateCatalogPanel({
templates,
isBusy,
onSelect,
}: CreativeAgentTemplateCatalogPanelProps) {
if (templates.length === 0) {
return null;
}
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="grid gap-3 sm:grid-cols-3">
{templates.map((template) => (
<button
key={template.templateId}
type="button"
disabled={isBusy}
onClick={() => onSelect(template)}
className="group min-h-[10.5rem] rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/88 disabled:opacity-55"
>
<div className="overflow-hidden rounded-[0.95rem] border border-white/70 bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
<div className="flex aspect-[16/9] items-center justify-center">
{template.previewImageSrc ? (
<img
src={template.previewImageSrc}
alt={template.title}
className="h-full w-full object-cover"
/>
) : (
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
<Puzzle className="h-4 w-4" />
</span>
)}
</div>
</div>
<div className="mt-3 text-sm font-black text-[var(--platform-text-strong)]">
{template.title}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{template.summary}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 光点`}
</div>
</button>
))}
</div>
</section>
);
}
export function CreativeAgentWorkspace({
session,
isBusy,
isStreaming,
error,
eventLog,
onBack,
onSubmitMessage,
onConfirmTemplate,
onCancelTemplate,
onOpenTarget,
}: CreativeAgentWorkspaceProps) {
const stage = session?.stage ?? 'idle';
const messages = session?.messages ?? [];
const selection = session?.puzzleTemplateSelection ?? null;
const templateCatalog = session?.puzzleTemplateCatalog ?? [];
const targetBinding = session?.targetBinding ?? null;
const [pendingSelection, setPendingSelection] =
useState<PuzzleCreativeTemplateSelection | null>(null);
useEffect(() => {
// 中文注释:会话切换时清掉本地待确认模板,避免上一轮选择残留到新会话。
setPendingSelection(null);
}, [session?.sessionId]);
const processItems = useMemo(
() => buildCreativeAgentProcessItems(eventLog, session),
[eventLog, session],
);
const visibleSelection = targetBinding ? null : (selection ?? pendingSelection);
const shouldShowTemplateCatalog =
!targetBinding &&
!selection &&
templateCatalog.length > 0 &&
stage === 'waiting_template_confirmation';
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)] xl:px-1">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
<div className="platform-pill platform-pill--cool px-3 text-[11px]">
{CREATIVE_AGENT_STAGE_LABEL[stage]}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="space-y-4 pb-4">
<section className="platform-surface platform-surface--hero relative overflow-hidden rounded-[1.6rem] px-4 py-5 sm:px-5">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex items-end justify-between gap-4">
<div className="min-w-0">
<div className="text-2xl font-black leading-tight text-white sm:text-3xl">
</div>
<div className="mt-2 max-w-xl text-sm font-semibold leading-6 text-zinc-100/86">
稿
</div>
</div>
<span className="hidden h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/18 text-white sm:inline-flex">
<Puzzle className="h-5 w-5" />
</span>
</div>
</section>
<CreativeAgentStageTimeline stage={stage} />
{targetBinding ? (
<section className="platform-subpanel flex flex-col gap-3 rounded-[1.35rem] p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CheckCircle2 className="h-5 w-5" />
</span>
<div>
<div className="text-base font-black text-[var(--platform-text-strong)]">
稿
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{targetBinding.targetStage === 'puzzle-result'
? '可以进入结果页继续编辑'
: '可以进入拼图工作区继续处理'}
</div>
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={onOpenTarget}
className="platform-button platform-button--primary"
>
稿
</button>
</section>
) : null}
{messages.length > 0 ? (
<div className="space-y-2">
{messages.map((message) => (
<div
key={message.id}
className={`max-w-[86%] rounded-[1.15rem] px-4 py-3 text-sm leading-6 ${
message.role === 'user'
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
: 'platform-subpanel text-[var(--platform-text-base)]'
}`}
>
{message.text}
</div>
))}
</div>
) : (
<div className="platform-subpanel rounded-[1.35rem] p-4 text-sm font-semibold text-[var(--platform-text-base)]">
</div>
)}
<CreativeAgentProcessPanel
items={processItems}
isStreaming={isStreaming}
/>
{shouldShowTemplateCatalog ? (
<CreativeAgentTemplateCatalogPanel
templates={templateCatalog}
isBusy={isBusy || isStreaming}
onSelect={(template) => {
setPendingSelection(
buildPuzzleTemplateSelectionFromProtocol(template),
);
}}
/>
) : null}
{session?.puzzleImageGenerationPlan ? (
<div className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{session.puzzleImageGenerationPlan.levels.map((level) => (
<div
key={level.levelId}
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 px-3 py-3"
>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{level.levelName}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{level.pictureDescription}
</div>
</div>
))}
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
<div className="pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<CreativeAgentInputComposer
isBusy={isBusy || isStreaming}
onSubmit={({ text, image }) => {
const content: CreativeAgentInputPart[] = [];
if (text) {
content.push({
type: 'input_text',
text,
});
}
if (image) {
content.push({
type: 'input_image',
imageUrl: image.imageUrl,
thumbnailUrl: image.thumbnailUrl,
assetId: null,
});
}
onSubmitMessage({
clientMessageId: createCreativeAgentClientMessageId(),
content,
});
}}
/>
</div>
{visibleSelection && visibleSelection.requiresUserConfirmation ? (
<CreativeAgentTemplateConfirmPanel
selection={visibleSelection}
isBusy={isBusy || isStreaming}
onConfirm={(nextSelection) => {
setPendingSelection(null);
onConfirmTemplate(nextSelection);
}}
onCancel={() => {
setPendingSelection(null);
onCancelTemplate?.();
}}
/>
) : null}
</div>
);
}
export default CreativeAgentWorkspace;

View File

@@ -0,0 +1,320 @@
import { expect, test } from 'vitest';
import type {
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
} from '../../../packages/shared/src/contracts/creativeAgent';
import { buildCreativeAgentProcessItems } from './creativeAgentViewModel';
function createSession(
overrides: Partial<CreativeAgentSessionSnapshot> = {},
): CreativeAgentSessionSnapshot {
return {
sessionId: 'creative-session-1',
stage: 'target_ready',
inputSummary: {
text: '做一个生日拼图',
entryContext: 'creation_home',
images: [],
materialSummary: '做一个生日拼图',
unsupportedCapabilities: [],
},
messages: [],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: {
playType: 'puzzle',
targetSessionId: 'puzzle-session-1',
targetStage: 'puzzle-result',
resultProfileId: 'puzzle-profile-1',
},
updatedAt: '2026-05-05T10:00:00.000Z',
...overrides,
};
}
test('buildCreativeAgentProcessItems expands creative agent sse details', () => {
const eventLog: CreativeAgentSseEvent[] = [
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'perceiving',
},
},
{
event: 'thought_summary_delta',
data: {
sessionId: 'creative-session-1',
thoughtId: 'thought-1',
textDelta: '正在理解生日素材,',
},
},
{
event: 'thought_summary_delta',
data: {
sessionId: 'creative-session-1',
thoughtId: 'thought-1',
textDelta: '并准备转成拼图关卡。',
},
},
{
event: 'tool_started',
data: {
sessionId: 'creative-session-1',
toolCallId: 'tool-1',
toolName: 'retrieve_puzzle_template_catalog',
summary: '读取拼图模板',
},
},
{
event: 'tool_completed',
data: {
sessionId: 'creative-session-1',
toolCallId: 'tool-1',
toolName: 'retrieve_puzzle_template_catalog',
summary: '已读取拼图模板',
},
},
{
event: 'puzzle_template_catalog',
data: {
sessionId: 'creative-session-1',
templates: [
{
templateId: 'puzzle.default-creative',
title: '创意拼图',
summary: '把图文灵感做成拼图。',
previewImageSrc: null,
supportedLevelMode: 'single_or_multi',
minLevelCount: 1,
maxLevelCount: 6,
defaultLevelCount: 1,
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
requiredDraftFields: ['workTitle'],
imagePolicy: {
allowUploadedImageDirectly: true,
allowGeneratedImages: true,
allowPerLevelReferenceImage: true,
defaultCandidateCountPerLevel: 1,
},
},
],
},
},
{
event: 'puzzle_template_selection',
data: {
sessionId: 'creative-session-1',
selection: {
templateId: 'puzzle.default-creative',
title: '创意拼图',
reason: '适合把生日素材做成拼图。',
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
supportedLevelMode: 'single_or_multi',
selectedLevelMode: 'multi_level',
plannedLevelCount: 3,
requiresUserConfirmation: true,
},
},
},
{
event: 'puzzle_level_plan',
data: {
sessionId: 'creative-session-1',
plan: {
mode: 'multi_level',
templateId: 'puzzle.default-creative',
estimatedCostRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
levels: [
{
levelId: 'level-1',
levelName: '生日开场',
pictureDescription: '蛋糕和礼物',
imagePrompt: '蛋糕和礼物',
pictureReference: null,
candidateCount: 1,
},
],
},
},
},
{
event: 'done',
data: {
sessionId: 'creative-session-1',
},
},
];
const items = buildCreativeAgentProcessItems(eventLog, createSession());
expect(items.map((item) => item.title)).toContain('素材已理解');
expect(items.map((item) => item.title)).toContain('思考摘要');
expect(items.find((item) => item.title === '思考摘要')?.detail).toBe(
'正在理解生日素材,并准备转成拼图关卡。',
);
expect(items.find((item) => item.title === '思考摘要')?.tone).toBe('done');
expect(items.map((item) => item.title)).toContain('开始:读取拼图模板');
expect(items.find((item) => item.title === '开始:读取拼图模板')?.tone).toBe(
'done',
);
expect(items.map((item) => item.title)).toContain('读取 1 个模板');
expect(items.map((item) => item.title)).toContain('选择 创意拼图');
expect(items.map((item) => item.title)).toContain('规划 1 个关卡');
expect(items.at(-1)?.detailLines).toContain('目标会话puzzle-session-1');
expect(items.some((item) => item.tone === 'active')).toBe(false);
});
test('buildCreativeAgentProcessItems only keeps current running stage active', () => {
const eventLog: CreativeAgentSseEvent[] = [
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'perceiving',
},
},
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'thinking',
},
},
];
const items = buildCreativeAgentProcessItems(
eventLog,
createSession({
stage: 'thinking',
targetBinding: null,
}),
);
expect(items.find((item) => item.title === '素材已理解')?.tone).toBe('done');
expect(items.find((item) => item.title === '正在构思')?.tone).toBe('active');
expect(items.filter((item) => item.tone === 'active')).toHaveLength(1);
});
test('buildCreativeAgentProcessItems stops spinners after waiting confirmation', () => {
const eventLog: CreativeAgentSseEvent[] = [
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'perceiving',
},
},
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'selecting_puzzle_template',
},
},
{
event: 'thought_summary_delta',
data: {
sessionId: 'creative-session-1',
thoughtId: 'thought-1',
textDelta: '已经选择合适模板。',
},
},
{
event: 'tool_started',
data: {
sessionId: 'creative-session-1',
toolCallId: 'tool-start',
toolName: 'retrieve_puzzle_template_catalog',
summary: '读取拼图模板',
},
},
{
event: 'tool_completed',
data: {
sessionId: 'creative-session-1',
toolCallId: 'tool-done',
toolName: 'retrieve_puzzle_template_catalog',
summary: '已读取拼图模板',
},
},
{
event: 'stage',
data: {
sessionId: 'creative-session-1',
stage: 'waiting_template_confirmation',
},
},
];
const items = buildCreativeAgentProcessItems(
eventLog,
createSession({
stage: 'waiting_template_confirmation',
targetBinding: null,
}),
);
expect(items.find((item) => item.title === '思考摘要')?.tone).toBe('done');
expect(items.find((item) => item.title === '开始:读取拼图模板')?.tone).toBe(
'done',
);
expect(items.find((item) => item.title === '等待确认')?.tone).toBe('warning');
expect(items.some((item) => item.tone === 'active')).toBe(false);
});
test('buildCreativeAgentProcessItems falls back to session snapshots', () => {
const session = createSession({
puzzleTemplateSelection: {
templateId: 'puzzle.default-creative',
title: '创意拼图',
reason: '适合拼图创作。',
costRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
supportedLevelMode: 'single_or_multi',
selectedLevelMode: 'single_level',
plannedLevelCount: 1,
requiresUserConfirmation: true,
},
});
const items = buildCreativeAgentProcessItems([], session);
expect(items.map((item) => item.title)).toContain('选择 创意拼图');
expect(items.map((item) => item.title)).toContain('拼图草稿已绑定');
});
test('buildCreativeAgentProcessItems renders waiting session fallback as static', () => {
const items = buildCreativeAgentProcessItems(
[],
createSession({
stage: 'waiting_template_confirmation',
targetBinding: null,
}),
);
expect(items.map((item) => item.title)).toContain('等待确认');
expect(items.find((item) => item.title === '等待确认')?.tone).toBe('warning');
expect(items.some((item) => item.tone === 'active')).toBe(false);
});

View File

@@ -0,0 +1,582 @@
import type {
CreativeAgentSessionSnapshot,
CreativeAgentSseEvent,
CreativeAgentStage,
CreativeTargetSessionBinding,
} from '../../../packages/shared/src/contracts/creativeAgent';
import type {
PuzzleCreativeTemplateProtocol,
PuzzleCreativeTemplateSelection,
PuzzleLevelGenerationMode,
PuzzleSupportedLevelMode,
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
export const CREATIVE_AGENT_STAGE_LABEL: Record<CreativeAgentStage, string> = {
idle: '等待输入',
perceiving: '正在理解素材',
thinking: '正在构思',
remembering: '正在整理上下文',
selecting_puzzle_template: '正在选择拼图模板',
waiting_template_confirmation: '等待确认',
planning_puzzle_levels: '正在规划关卡',
acting: '正在生成草稿',
reflecting: '正在检查结果',
collaborating: '正在协作收口',
target_ready: '草稿已就绪',
waiting_user: '等待补充',
failed: '生成失败',
};
const CREATIVE_AGENT_STAGE_DONE_LABEL: Partial<
Record<CreativeAgentStage, string>
> = {
perceiving: '素材已理解',
thinking: '构思已完成',
remembering: '上下文已整理',
selecting_puzzle_template: '模板已选择',
planning_puzzle_levels: '关卡已规划',
acting: '草稿已生成',
reflecting: '结果已检查',
collaborating: '协作已收口',
target_ready: '草稿已就绪',
};
const CREATIVE_AGENT_STAGE_WAITING_LABEL: Partial<
Record<CreativeAgentStage, string>
> = {
idle: '等待输入',
waiting_template_confirmation: '等待确认',
waiting_user: '等待补充',
failed: '生成失败',
};
const CREATIVE_AGENT_STAGE_IDLE_LABEL: Record<CreativeAgentStage, string> = {
idle: '等待输入',
perceiving: '理解素材',
thinking: '构思方向',
remembering: '整理上下文',
selecting_puzzle_template: '选择拼图模板',
waiting_template_confirmation: '等待确认',
planning_puzzle_levels: '规划关卡',
acting: '生成草稿',
reflecting: '检查结果',
collaborating: '协作收口',
target_ready: '草稿就绪',
waiting_user: '等待补充',
failed: '生成失败',
};
export const CREATIVE_AGENT_TIMELINE: CreativeAgentStage[] = [
'perceiving',
'thinking',
'remembering',
'selecting_puzzle_template',
'planning_puzzle_levels',
'acting',
'reflecting',
'collaborating',
'target_ready',
];
export type CreativeAgentTargetSelectionStage =
| 'puzzle-agent-workspace'
| 'puzzle-result';
export function resolveCreativeAgentTargetSelectionStage(
targetStage: CreativeTargetSessionBinding['targetStage'],
): CreativeAgentTargetSelectionStage {
return targetStage === 'puzzle-agent-workspace'
? 'puzzle-agent-workspace'
: 'puzzle-result';
}
export function createCreativeAgentClientMessageId(prefix = 'creative-agent') {
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
}
function resolveTemplateLevelMode(
supportedLevelMode: PuzzleSupportedLevelMode,
defaultLevelCount: number,
): PuzzleLevelGenerationMode {
if (supportedLevelMode === 'single') {
return 'single_level';
}
if (supportedLevelMode === 'multi') {
return 'multi_level';
}
return defaultLevelCount > 1 ? 'multi_level' : 'single_level';
}
function resolveTemplatePlannedLevelCount(
supportedLevelMode: PuzzleSupportedLevelMode,
defaultLevelCount: number,
) {
if (supportedLevelMode === 'single') {
return 1;
}
if (supportedLevelMode === 'multi') {
return Math.max(2, defaultLevelCount);
}
return Math.max(1, defaultLevelCount);
}
export function buildPuzzleTemplateSelectionFromProtocol(
template: PuzzleCreativeTemplateProtocol,
): PuzzleCreativeTemplateSelection {
const plannedLevelCount = resolveTemplatePlannedLevelCount(
template.supportedLevelMode,
template.defaultLevelCount,
);
return {
templateId: template.templateId,
title: template.title,
reason: template.summary,
costRange: template.costRange,
supportedLevelMode: template.supportedLevelMode,
selectedLevelMode: resolveTemplateLevelMode(
template.supportedLevelMode,
plannedLevelCount,
),
plannedLevelCount,
requiresUserConfirmation: true,
};
}
export type CreativeAgentStageDisplayStatus =
| 'active'
| 'done'
| 'waiting'
| 'idle';
export function getCreativeAgentStageDisplayLabel(
stage: CreativeAgentStage,
status: CreativeAgentStageDisplayStatus,
) {
if (status === 'done') {
return CREATIVE_AGENT_STAGE_DONE_LABEL[stage] ?? CREATIVE_AGENT_STAGE_LABEL[stage];
}
if (status === 'waiting') {
return CREATIVE_AGENT_STAGE_WAITING_LABEL[stage] ?? CREATIVE_AGENT_STAGE_LABEL[stage];
}
if (status === 'idle') {
return CREATIVE_AGENT_STAGE_IDLE_LABEL[stage];
}
return CREATIVE_AGENT_STAGE_LABEL[stage];
}
export type CreativeAgentProcessTone =
| 'active'
| 'done'
| 'info'
| 'warning'
| 'danger';
export type CreativeAgentProcessItem = {
id: string;
meta: string;
title: string;
detail: string | null;
detailLines: string[];
tone: CreativeAgentProcessTone;
};
const CREATIVE_AGENT_TOOL_LABEL: Record<string, string> = {
retrieve_puzzle_template_catalog: '读取拼图模板',
select_puzzle_template: '选择拼图模板',
confirm_puzzle_template: '确认模板',
create_puzzle_agent_session: '创建拼图草稿',
compile_puzzle_creative_draft: '编译拼图草稿',
plan_puzzle_level_images: '规划关卡图片',
generate_puzzle_level_images: '生成关卡图片',
apply_puzzle_draft_natural_language_edit: '写回草稿修改',
validate_puzzle_result_preview: '校验草稿预览',
start_puzzle_draft_test_run: '启动拼图试玩',
};
type ProcessBuildContext = {
activeStageEventIndex: number;
eventLog: CreativeAgentSseEvent[];
isComplete: boolean;
};
function formatPuzzleLevelMode(mode: PuzzleLevelGenerationMode) {
return mode === 'single_level' ? '单关卡' : '多关卡';
}
function formatTargetStage(stage: CreativeTargetSessionBinding['targetStage']) {
return stage === 'puzzle-agent-workspace'
? '拼图工作区'
: stage === 'puzzle-runtime'
? '拼图运行态'
: '拼图结果页';
}
function resolveToolLabel(toolName: string) {
return CREATIVE_AGENT_TOOL_LABEL[toolName] ?? toolName;
}
function buildStageProcessItem(
event: Extract<CreativeAgentSseEvent, { event: 'stage' }>,
index: number,
context: ProcessBuildContext,
): CreativeAgentProcessItem {
const stage = event.data.stage;
const isWaiting =
stage === 'waiting_template_confirmation' || stage === 'waiting_user';
const isActive =
index === context.activeStageEventIndex &&
!context.isComplete &&
!isWaiting &&
stage !== 'target_ready' &&
stage !== 'failed';
const tone: CreativeAgentProcessTone =
stage === 'failed'
? 'danger'
: isWaiting
? 'warning'
: isActive
? 'active'
: 'done';
return {
id: `${index}-stage-${stage}`,
meta: '阶段',
title: getCreativeAgentStageDisplayLabel(
stage,
isActive ? 'active' : isWaiting ? 'waiting' : 'done',
),
detail: '阶段切换',
detailLines: [],
tone,
};
}
function buildEventProcessItem(
event: CreativeAgentSseEvent,
index: number,
context: ProcessBuildContext,
): CreativeAgentProcessItem | null {
switch (event.event) {
case 'stage':
return buildStageProcessItem(event, index, context);
case 'tool_started': {
const toolLabel = resolveToolLabel(event.data.toolName);
const isToolResolved =
context.isComplete ||
context.eventLog.slice(index + 1).some(
(nextEvent) =>
nextEvent.event === 'tool_completed' &&
(nextEvent.data.toolCallId === event.data.toolCallId ||
nextEvent.data.toolName === event.data.toolName),
);
return {
id: `${index}-tool-started-${event.data.toolCallId}`,
meta: '工具调用',
title: `开始:${toolLabel}`,
detail: event.data.summary ?? event.data.toolName,
detailLines: [],
tone: isToolResolved ? 'done' : 'active',
};
}
case 'tool_completed': {
const toolLabel = resolveToolLabel(event.data.toolName);
return {
id: `${index}-tool-completed-${event.data.toolCallId}`,
meta: '工具完成',
title: `完成:${toolLabel}`,
detail: event.data.summary ?? event.data.toolName,
detailLines: [],
tone: 'done',
};
}
case 'puzzle_template_selection':
return {
id: `${index}-template-${event.data.selection.templateId}`,
meta: '模板',
title: `选择 ${event.data.selection.title}`,
detail: event.data.selection.reason,
detailLines: [
`${formatPuzzleLevelMode(event.data.selection.selectedLevelMode)} · ${event.data.selection.plannedLevelCount}`,
],
tone: 'info',
};
case 'puzzle_template_catalog':
return {
id: `${index}-template-catalog`,
meta: '模板',
title: `读取 ${event.data.templates.length} 个模板`,
detail:
event.data.templates
.slice(0, 3)
.map((template) => template.title)
.join('、') || null,
detailLines: [],
tone: 'info',
};
case 'puzzle_cost_range':
return {
id: `${index}-cost-${event.data.costRange.minPoints}-${event.data.costRange.maxPoints}`,
meta: '消耗',
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} 光点`,
detail: event.data.costRange.reason,
detailLines: [],
tone: 'info',
};
case 'puzzle_level_plan': {
const candidateCount = event.data.plan.levels.reduce(
(total, level) => total + level.candidateCount,
0,
);
return {
id: `${index}-level-plan-${event.data.plan.templateId}`,
meta: '关卡',
title: `规划 ${event.data.plan.levels.length} 个关卡`,
detail: `${formatPuzzleLevelMode(event.data.plan.mode)} · ${candidateCount} 张候选图`,
detailLines: event.data.plan.levels.slice(0, 4).map((level) =>
[
level.levelName,
level.pictureDescription,
level.pictureReference ? `参考图:${level.pictureReference}` : null,
]
.filter(Boolean)
.join(' · '),
),
tone: 'info',
};
}
case 'reflection':
return {
id: `${index}-reflection`,
meta: '检查',
title: event.data.pass ? '检查通过' : '需要调整',
detail: event.data.summary,
detailLines: event.data.warnings,
tone: event.data.pass ? 'done' : 'warning',
};
case 'target_session':
return {
id: `${index}-target-${event.data.binding.targetSessionId}`,
meta: '目标',
title: '拼图草稿已绑定',
detail: formatTargetStage(event.data.binding.targetStage),
detailLines: [`目标会话:${event.data.binding.targetSessionId}`],
tone: 'done',
};
case 'error':
return {
id: `${index}-error-${event.data.code}`,
meta: '异常',
title: event.data.message,
detail: event.data.code,
detailLines: [],
tone: 'danger',
};
case 'done':
return {
id: `${index}-done`,
meta: '完成',
title: '本轮完成',
detail: null,
detailLines: [],
tone: 'done',
};
case 'agent_message_delta':
case 'thought_summary_delta':
case 'session':
return null;
}
return null;
}
function buildThoughtSummaryItems(
eventLog: CreativeAgentSseEvent[],
isComplete: boolean,
): CreativeAgentProcessItem[] {
const thoughtMap = new Map<string, string>();
let latestThoughtId: string | null = null;
for (const event of eventLog) {
if (event.event !== 'thought_summary_delta') {
continue;
}
const currentText = thoughtMap.get(event.data.thoughtId) ?? '';
thoughtMap.set(event.data.thoughtId, `${currentText}${event.data.textDelta}`);
latestThoughtId = event.data.thoughtId;
}
const items: CreativeAgentProcessItem[] = [];
for (const [thoughtId, text] of thoughtMap.entries()) {
const detail = text.trim();
if (!detail) {
continue;
}
items.push({
id: `thought-${thoughtId}`,
meta: '思考',
title: '思考摘要',
detail,
detailLines: [],
tone: !isComplete && thoughtId === latestThoughtId ? 'active' : 'done',
});
}
return items;
}
function buildSessionFallbackItems(
session: CreativeAgentSessionSnapshot | null,
eventLog: CreativeAgentSseEvent[],
): CreativeAgentProcessItem[] {
if (!session) {
return [];
}
const items: CreativeAgentProcessItem[] = [];
if (
session.puzzleTemplateCatalog.length > 0 &&
!eventLog.some((event) => event.event === 'puzzle_template_catalog')
) {
items.push({
id: `session-template-catalog-${session.puzzleTemplateCatalog.length}`,
meta: '模板',
title: `读取 ${session.puzzleTemplateCatalog.length} 个模板`,
detail: session.puzzleTemplateCatalog
.slice(0, 3)
.map((template) => template.title)
.join('、'),
detailLines: [],
tone: 'info',
});
}
if (
session.puzzleTemplateSelection &&
!eventLog.some((event) => event.event === 'puzzle_template_selection')
) {
items.push({
id: `session-template-${session.puzzleTemplateSelection.templateId}`,
meta: '模板',
title: `选择 ${session.puzzleTemplateSelection.title}`,
detail: session.puzzleTemplateSelection.reason,
detailLines: [
`${formatPuzzleLevelMode(session.puzzleTemplateSelection.selectedLevelMode)} · ${session.puzzleTemplateSelection.plannedLevelCount}`,
],
tone: 'info',
});
}
if (
session.puzzleImageGenerationPlan &&
!eventLog.some((event) => event.event === 'puzzle_level_plan')
) {
const plan = session.puzzleImageGenerationPlan;
items.push({
id: `session-level-plan-${plan.templateId}`,
meta: '关卡',
title: `规划 ${plan.levels.length} 个关卡`,
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} 光点`,
detailLines: plan.levels.slice(0, 4).map((level) =>
[
level.levelName,
level.pictureDescription,
level.pictureReference ? `参考图:${level.pictureReference}` : null,
]
.filter(Boolean)
.join(' · '),
),
tone: 'info',
});
}
if (
session.targetBinding &&
!eventLog.some((event) => event.event === 'target_session')
) {
items.push({
id: `session-target-${session.targetBinding.targetSessionId}`,
meta: '目标',
title: '拼图草稿已绑定',
detail: formatTargetStage(session.targetBinding.targetStage),
detailLines: [`目标会话:${session.targetBinding.targetSessionId}`],
tone: 'done',
});
}
if (
items.length === 0 &&
session.stage !== 'idle' &&
!eventLog.some((event) => event.event === 'stage')
) {
const isWaiting =
session.stage === 'waiting_template_confirmation' ||
session.stage === 'waiting_user';
const isDone = session.stage === 'target_ready';
const tone: CreativeAgentProcessTone =
session.stage === 'failed'
? 'danger'
: isWaiting
? 'warning'
: isDone
? 'done'
: 'active';
items.push({
id: `session-stage-${session.stage}`,
meta: '阶段',
title: getCreativeAgentStageDisplayLabel(
session.stage,
tone === 'active' ? 'active' : tone === 'done' ? 'done' : 'waiting',
),
detail: '当前状态',
detailLines: [],
tone,
});
}
return items;
}
export function buildCreativeAgentProcessItems(
eventLog: CreativeAgentSseEvent[],
session: CreativeAgentSessionSnapshot | null,
) {
const terminalStageSeen = eventLog.some(
(event) =>
event.event === 'stage' &&
(event.data.stage === 'waiting_template_confirmation' ||
event.data.stage === 'target_ready' ||
event.data.stage === 'waiting_user' ||
event.data.stage === 'failed'),
);
const isComplete =
eventLog.some((event) => event.event === 'done') ||
terminalStageSeen ||
session?.stage === 'waiting_template_confirmation' ||
session?.stage === 'target_ready' ||
session?.stage === 'waiting_user' ||
session?.stage === 'failed';
const activeStageEventIndex = eventLog.reduce(
(latestIndex, event, index) => (event.event === 'stage' ? index : latestIndex),
-1,
);
const context: ProcessBuildContext = {
activeStageEventIndex,
eventLog,
isComplete,
};
return [
...buildThoughtSummaryItems(eventLog, isComplete),
...eventLog
.map((event, index) => buildEventProcessItem(event, index, context))
.filter((item): item is CreativeAgentProcessItem => Boolean(item)),
...buildSessionFallbackItems(session, eventLog),
].slice(-24);
}

View File

@@ -117,6 +117,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();

View File

@@ -4,8 +4,9 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldProfile } from '../../types';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
@@ -57,6 +58,10 @@ type CustomWorldCreationHubProps = {
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
mode?: 'full' | 'start-only' | 'works-only';
};
function EmptyState({ title }: { title: string }) {
@@ -149,6 +154,10 @@ export function CustomWorldCreationHub({
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -161,11 +170,13 @@ export function CustomWorldCreationHub({
match3dItems,
squareHoleItems,
puzzleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
}),
[
bigFishItems,
@@ -176,9 +187,11 @@ export function CustomWorldCreationHub({
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteVisualNovel,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
visualNovelItems,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
@@ -206,6 +219,9 @@ export function CustomWorldCreationHub({
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
@@ -239,6 +255,12 @@ export function CustomWorldCreationHub({
onDeletePuzzle?.(sourceItem);
};
}
case 'visual-novel': {
const sourceItem = item.source.item;
return () => {
onDeleteVisualNovel?.(sourceItem);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
@@ -277,23 +299,30 @@ export function CustomWorldCreationHub({
};
}
const showStartCard = mode !== 'works-only';
const showWorkShelf = mode !== 'start-only';
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
onCreateType={onCreateType}
/>
{showStartCard ? (
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
onCreateType={onCreateType}
/>
) : null}
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
{showWorkShelf ? (
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
) : null}
{error ? (
{showWorkShelf && error ? (
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
<div>{error}</div>
<button
@@ -306,49 +335,51 @@ export function CustomWorldCreationHub({
</div>
) : null}
{loading ? (
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
{showWorkShelf ? (
loading ? (
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>
) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
))}
</div>
) : filteredItems.length > 0 ? (
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>
) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)
) : null}
</div>
</div>
);

View File

@@ -52,13 +52,26 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[4.6rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border p-0 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] xl:min-h-[6.4rem] ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
: 'border-white/18 bg-white/16 text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex min-h-5 items-center justify-end gap-2 sm:items-start sm:gap-3">
<img
src={item.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
<div
className={`absolute inset-0 ${
item.locked
? 'bg-[linear-gradient(90deg,rgba(3,7,18,0.58),rgba(3,7,18,0.14)),linear-gradient(180deg,rgba(3,7,18,0.05)_0%,rgba(3,7,18,0.2)_42%,rgba(3,7,18,0.82)_100%)]'
: 'bg-[linear-gradient(90deg,rgba(3,7,18,0.54),rgba(3,7,18,0.04)),linear-gradient(180deg,rgba(3,7,18,0.03)_0%,rgba(3,7,18,0.14)_42%,rgba(3,7,18,0.78)_100%)]'
}`}
/>
<div className="relative z-10 flex min-h-5 items-center justify-end gap-2 px-3 pt-2.5 sm:items-start sm:gap-3 sm:px-4 sm:pt-4 xl:px-3.5 xl:pt-3">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
{item.badge}
@@ -71,13 +84,13 @@ export function CustomWorldCreationStartCard({
)}
</div>
<div className="mt-auto pt-1.5 sm:pt-4 xl:pt-2">
<div className="truncate text-base font-black leading-tight text-inherit sm:text-lg xl:text-base">
<div className="relative z-10 mt-auto px-3 pb-2.5 pt-1.5 text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.76)] sm:px-4 sm:pb-4 sm:pt-4 xl:px-3.5 xl:pb-3 xl:pt-2">
<div className="truncate text-base font-black leading-tight text-white sm:text-lg xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
item.locked ? 'text-white/72' : 'text-white/88'
}`}
>
{item.subtitle}

View File

@@ -0,0 +1,47 @@
import { expect, test } from 'vitest';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
visualNovelItems: [
{
runtimeKind: 'visual-novel',
profileId: 'vn-profile-demo-12345678',
ownerUserId: 'user-1',
title: '雨夜终章',
description: '失踪列车上的选择。',
coverImageSrc: '/vn-cover.png',
tags: ['悬疑', '列车'],
publishStatus: 'published',
publishReady: true,
playCount: 12,
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: '2026-05-07T00:00:00.000Z',
},
{
runtimeKind: 'visual-novel',
profileId: 'vn-profile-draft-00000001',
ownerUserId: 'user-1',
title: '',
description: '',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-06T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items[0]?.kind).toBe('visual-novel');
expect(items[0]?.publicWorkCode).toBe('VN-12345678');
expect(items[0]?.sharePath).toContain('/works/detail?work=VN-12345678');
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});

View File

@@ -3,6 +3,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
@@ -10,6 +11,7 @@ import {
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
@@ -18,7 +20,8 @@ export type CreationWorkShelfKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'puzzle';
| 'puzzle'
| 'visual-novel';
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
@@ -70,6 +73,10 @@ export type CreationWorkShelfSource =
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
}
| {
kind: 'visual-novel';
item: VisualNovelWorkSummary;
};
export type CreationWorkShelfItem = {
@@ -100,11 +107,13 @@ export function buildCreationWorkShelfItems(params: {
match3dItems?: Match3DWorkSummary[];
squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteVisualNovel?: boolean;
}) {
const {
rpgItems,
@@ -113,11 +122,13 @@ export function buildCreationWorkShelfItems(params: {
match3dItems = [],
squareHoleItems = [],
puzzleItems,
visualNovelItems = [],
canDeleteRpg = false,
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteVisualNovel = false,
} = params;
return [
@@ -136,6 +147,9 @@ export function buildCreationWorkShelfItems(params: {
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel),
),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
@@ -337,6 +351,53 @@ function mapPuzzleWorkToShelfItem(
};
}
function mapVisualNovelWorkToShelfItem(
item: VisualNovelWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const status =
item.publishStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildVisualNovelPublicWorkCode(item.profileId) : null;
const title = item.title?.trim() || '未命名视觉小说';
const summary =
item.description?.trim() ||
(status === 'draft' ? '未填写作品描述' : '');
return {
id: item.profileId,
kind: 'visual-novel',
status,
title,
summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '视觉小说', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
source: { kind: 'visual-novel', item },
};
}
function mapSquareHoleWorkToShelfItem(
item: SquareHoleWorkSummary,
canDelete: boolean,

View File

@@ -14,6 +14,8 @@ export interface PlatformEntryCreationTypeModalProps {
onSelectMatch3D: () => void;
onSelectSquareHole: () => void;
onSelectPuzzle: () => void;
onSelectCreativeAgent: () => void;
onSelectVisualNovel: () => void;
}
function CreationTypeCard(props: {
@@ -29,31 +31,44 @@ function CreationTypeCard(props: {
type="button"
disabled={disabled}
onClick={onSelect}
className={`platform-interactive-card relative flex min-h-[8.25rem] flex-col overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[10rem] flex-col overflow-hidden rounded-[1.65rem] border p-0 text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-white'
: 'border-[var(--platform-cool-border)] bg-white text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex min-h-6 items-start justify-end gap-3">
<img
src={item.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
<div
className={`absolute inset-0 ${
item.locked
? 'bg-[linear-gradient(180deg,rgba(3,7,18,0.1)_0%,rgba(3,7,18,0.22)_42%,rgba(3,7,18,0.84)_100%)] backdrop-blur-[1px]'
: 'bg-[linear-gradient(180deg,rgba(3,7,18,0.03)_0%,rgba(3,7,18,0.16)_42%,rgba(3,7,18,0.82)_100%)]'
}`}
/>
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-3 text-[var(--platform-text-soft)]">
{item.badge}
</span>
) : null}
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
<span className="text-lg leading-none text-white/62">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-auto pt-4">
<div className="text-xl font-black leading-tight text-inherit">
<div className="relative z-10 mt-auto px-4 pb-4 pt-8 text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.78)]">
<div className="text-xl font-black leading-tight text-white">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
item.locked ? 'text-white/74' : 'text-white/88'
}`}
>
{item.subtitle}
@@ -77,6 +92,8 @@ export function PlatformEntryCreationTypeModal({
onSelectMatch3D,
onSelectSquareHole,
onSelectPuzzle,
onSelectCreativeAgent,
onSelectVisualNovel,
}: PlatformEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
@@ -117,6 +134,12 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'puzzle') {
onSelectPuzzle();
}
if (item.id === 'creative-agent') {
onSelectCreativeAgent();
}
if (item.id === 'visual-novel') {
onSelectVisualNovel();
}
}}
/>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'square-hole') {
return '方洞挑战';
}
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
return '视觉小说';
}
return 'RPG';
}

View File

@@ -23,6 +23,7 @@ test('platform creation types are derived from new work entry config', () => {
title: puzzleConfig?.title,
subtitle: puzzleConfig?.subtitle,
badge: puzzleConfig?.badge,
imageSrc: puzzleConfig?.imageSrc,
locked: false,
hidden: false,
}),
@@ -30,30 +31,34 @@ test('platform creation types are derived from new work entry config', () => {
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
title: match3dConfig?.title,
subtitle: match3dConfig?.subtitle,
badge: match3dConfig?.badge,
imageSrc: match3dConfig?.imageSrc,
locked: false,
hidden: false,
hidden: !match3dConfig?.visible,
}),
);
});
test('every platform creation type has a generated reference image', () => {
expect(
NEW_WORK_ENTRY_CONFIG.creationTypes.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
),
).toBe(true);
});
test('new work entry config controls visibility and open order', () => {
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
expect(isPlatformCreationTypeVisible('creative-agent')).toBe(false);
expect(visibleIds).not.toContain('rpg');
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds).toContain('match3d');
expect(visibleIds[0]).toBe('puzzle');
expect(visibleIds).toEqual([
'puzzle',
'match3d',
'square-hole',
'airp',
'visual-novel',
]);
expect(visibleIds).not.toContain('match3d');
expect(visibleIds).not.toContain('creative-agent');
expect(visibleIds).toEqual(['puzzle', 'square-hole', 'visual-novel', 'airp']);
});

View File

@@ -10,6 +10,7 @@ export type PlatformCreationTypeCard = {
title: string;
subtitle: string;
badge: string;
imageSrc: string;
locked: boolean;
hidden?: boolean;
};
@@ -47,6 +48,7 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] =
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
hidden: !item.visible,
}));

View File

@@ -29,6 +29,11 @@ export type SelectionStage =
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-result'
| 'visual-novel-gallery-detail'
| 'visual-novel-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
@@ -68,8 +68,61 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = width;
naturalHeight = height;
width = width;
height = height;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}
function stubCanvas(dataUrl: string, drawImage = vi.fn()) {
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName !== 'canvas') {
return originalCreateElement(tagName);
}
return {
width: 0,
height: 0,
getContext: () => ({
drawImage,
fillRect: vi.fn(),
fillStyle: '',
imageSmoothingEnabled: false,
imageSmoothingQuality: 'low',
}),
toDataURL: vi.fn(() => dataUrl),
} as unknown as HTMLCanvasElement;
});
return drawImage;
}
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
@@ -85,8 +138,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('创建拼图')).toBeTruthy();
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull();
expect(screen.queryByText('Template')).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
@@ -98,16 +152,16 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
aiRedraw: true,
});
expect(screen.getByText('消耗2光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace applies a creation template prompt', () => {
test('puzzle workspace keeps the reference image upload as a primary panel', () => {
const onCreateFromForm = vi.fn();
render(
const { container } = render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
@@ -117,22 +171,66 @@ test('puzzle workspace applies a creation template prompt', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '宠物可爱拼图模板' }));
const uploadInput = screen.getByLabelText('上传拼图图片', {
selector: 'input',
});
const uploadCard = uploadInput.closest('.puzzle-image-upload-card');
expect(uploadCard).not.toBeNull();
expect(uploadCard?.closest('.platform-subpanel')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
expect(screen.getByText('拼图画面')).toBeTruthy();
expect(screen.getByText('点击上传拼图图片')).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
).toBe('');
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).placeholder,
).toBe('');
expect(screen.queryByText(//u)).toBeNull();
expect(screen.getByLabelText('画面描述').className).toContain(
'min-h-[clamp(5rem,15svh,7rem)]',
);
expect(screen.getAllByText('宠物可爱拼图').length).toBeGreaterThan(1);
expect(uploadCard?.className).toContain('aspect-square');
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在阳光窗台上看着毛线球。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
pictureDescription:
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
pictureDescription: '一只猫在阳光窗台上看着毛线球。',
}),
);
});
test('puzzle upload card stays light in light theme', () => {
const onCreateFromForm = vi.fn();
const { container } = render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
const uploadLabel = screen.getByText('点击上传拼图图片');
expect(uploadLabel).toBeTruthy();
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeNull();
expect(screen.queryByText('AI重绘')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
'bg-white/90',
);
expect(container.querySelector('.puzzle-image-upload-card')?.className).not.toContain(
'bg-slate-950',
);
});
test('puzzle workspace falls back to compile action for restored sessions', () => {
const onExecuteAction = vi.fn();
const onCreateFromForm = vi.fn();
@@ -156,6 +254,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
});
});
@@ -236,9 +335,9 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
/>,
);
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'旧街灯牌下的猫。',
);
expect(
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
).toBe('旧街灯牌下的猫。');
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '旧街灯牌下的猫和发光雨伞。' },
@@ -253,5 +352,125 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace hides prompt and cost when AI redraw is off', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const input = screen.getByLabelText('上传拼图图片', {
selector: 'input',
});
fireEvent.change(input, {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByLabelText('画面AI重绘要求提示词')).toBeTruthy();
});
expect(screen.queryByText('first-level.png')).toBeNull();
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
expect((aiRedrawSwitch as HTMLInputElement).checked).toBe(true);
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求提示词')).toBeNull();
expect(screen.queryByText('消耗2光点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: uploadedDataUrl,
imageModel: 'gpt-image-2',
aiRedraw: false,
});
});
test('puzzle workspace shows AI redraw switch only after upload', async () => {
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
/>,
);
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
});
});
test('puzzle workspace opens crop tool for non-square uploads', async () => {
const sourceDataUrl = 'data:image/png;base64,wide-source';
const croppedDataUrl = 'data:image/jpeg;base64,cropped-square';
stubReferenceImageUpload(sourceDataUrl, 800, 600);
const drawImage = stubCanvas(croppedDataUrl);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
/>,
);
fireEvent.change(
screen.getByLabelText('上传拼图图片', { selector: 'input' }),
{
target: {
files: [new File(['x'], 'wide.png', { type: 'image/png' })],
},
},
);
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪拼图图片' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '裁剪拼图图片' })).toBeNull();
});
expect(screen.queryByText('wide.png')).toBeNull();
expect(screen.getByAltText('拼图图片')).toBeTruthy();
expect(drawImage).toHaveBeenCalledWith(
expect.anything(),
100,
0,
600,
600,
0,
0,
600,
600,
);
});

View File

@@ -1,5 +1,12 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import {
type ChangeEvent,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
@@ -7,8 +14,11 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -26,6 +36,8 @@ type PuzzleAgentWorkspaceProps = {
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
};
type PuzzleFormState = {
@@ -33,6 +45,7 @@ type PuzzleFormState = {
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
aiRedraw: boolean;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
@@ -40,21 +53,42 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
type PuzzleImageCropState = {
source: string;
label: string;
imageSize: { width: number; height: number };
cropX: number;
cropY: number;
scale: number;
error: string | null;
isSaving: boolean;
};
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const shouldTreatEmptyPayloadAsFreshForm =
!session &&
Boolean(initialFormPayload) &&
Object.keys(initialFormPayload ?? {}).length === 0;
if (shouldTreatEmptyPayloadAsFreshForm) {
return EMPTY_FORM_STATE;
}
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
? '已选择拼图图片'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
};
}
@@ -66,9 +100,10 @@ function resolveInitialFormState(
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
? '已选择拼图图片'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
};
}
@@ -86,9 +121,212 @@ function resolveInitialFormState(
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
}
function clampPuzzleImageCrop(
imageSize: { width: number; height: number },
scale: number,
crop: { x: number; y: number },
) {
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
const maxCropX = Math.max(0, imageSize.width - cropSize);
const maxCropY = Math.max(0, imageSize.height - cropSize);
return {
x: Math.max(0, Math.min(maxCropX, crop.x)),
y: Math.max(0, Math.min(maxCropY, crop.y)),
};
}
function PuzzleImageCropModal({
state,
onScaleChange,
onCropChange,
onClose,
onSubmit,
}: {
state: PuzzleImageCropState;
onScaleChange: (value: number) => void;
onCropChange: (nextCrop: { x: number; y: number }) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragStartRef = useRef<{
pointerId: number;
clientX: number;
clientY: number;
cropX: number;
cropY: number;
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const cropSize = Math.min(state.imageSize.width, state.imageSize.height) /
state.scale;
const maxCropX = Math.max(0, state.imageSize.width - cropSize);
const maxCropY = Math.max(0, state.imageSize.height - cropSize);
const backgroundSize = `${(state.imageSize.width / cropSize) * 100}% ${(state.imageSize.height / cropSize) * 100}%`;
const backgroundPosition = `${maxCropX > 0 ? (state.cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (state.cropY / maxCropY) * 100 : 50}%`;
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current;
const preview = previewRef.current;
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
return;
}
const rect = preview.getBoundingClientRect();
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
onCropChange({
x:
dragStart.cropX -
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
y:
dragStart.cropY -
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
});
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (dragStartRef.current?.pointerId === event.pointerId) {
dragStartRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-image-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="puzzle-image-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭拼图图片裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.2rem] border border-white/12 bg-cover bg-center"
style={{
backgroundImage: `url("${state.source}")`,
backgroundSize,
backgroundPosition,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
}}
role="img"
aria-label="拼图图片裁剪预览"
onPointerDown={(event) => {
dragStartRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
cropX: state.cropX,
cropY: state.cropY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={updateDragCrop}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
/>
<div className="mt-5 space-y-4">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="1"
max="3"
step="0.01"
value={state.scale}
onChange={(event) => onScaleChange(Number(event.target.value))}
className="w-full"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropX}
step="1"
value={Math.min(state.cropX, maxCropX)}
onChange={(event) =>
onCropChange({
x: Number(event.target.value),
y: state.cropY,
})
}
className="w-full"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropY}
step="1"
value={Math.min(state.cropY, maxCropY)}
onChange={(event) =>
onCropChange({
x: state.cropX,
y: Number(event.target.value),
})
}
className="w-full"
/>
</label>
</div>
</div>
{state.error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{state.error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={state.isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{state.isSaving ? '裁剪中' : '应用'}
</button>
</div>
</div>
</div>
</div>
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
@@ -102,6 +340,8 @@ export function PuzzleAgentWorkspace({
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -109,9 +349,7 @@ export function PuzzleAgentWorkspace({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [selectedTemplateId, setSelectedTemplateId] = useState(
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
@@ -139,18 +377,23 @@ export function PuzzleAgentWorkspace({
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
const canSubmit = Boolean(pictureDescription) && !isBusy;
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
[
formState.aiRedraw,
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
@@ -158,6 +401,8 @@ export function PuzzleAgentWorkspace({
);
const autosaveSignature = JSON.stringify([
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
@@ -209,35 +454,119 @@ export function PuzzleAgentWorkspace({
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const cropSize = Math.min(uploadImage.width, uploadImage.height);
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
imageSize: {
width: uploadImage.width,
height: uploadImage.height,
},
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
scale: 1,
error: null,
isSaving: false,
});
setReferenceImageError(null);
return;
}
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: file.name.trim() || '本地参考图',
referenceImageSrc: uploadImage.dataUrl,
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
: '拼图图片读取失败,请重试。',
);
}
};
const applyTemplatePrompt = (templateId: string) => {
const template = PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === templateId,
);
if (!template) {
const updateCropState = (nextCrop: { x: number; y: number }) => {
setCropState((current) => {
if (!current) {
return current;
}
const clamped = clampPuzzleImageCrop(
current.imageSize,
current.scale,
nextCrop,
);
return {
...current,
cropX: clamped.x,
cropY: clamped.y,
};
});
};
const updateCropScale = (nextScale: number) => {
setCropState((current) => {
if (!current) {
return current;
}
const scale = Math.max(1, Math.min(3, nextScale || 1));
const clamped = clampPuzzleImageCrop(current.imageSize, scale, {
x: current.cropX,
y: current.cropY,
});
return {
...current,
scale,
cropX: clamped.x,
cropY: clamped.y,
};
});
};
const applyCropState = async () => {
const currentCropState = cropState;
if (!currentCropState) {
return;
}
setSelectedTemplateId(template.id);
setFormState((current) => ({
...current,
pictureDescription: template.prompt,
}));
setCropState({
...currentCropState,
isSaving: true,
error: null,
});
try {
const cropSize =
Math.min(
currentCropState.imageSize.width,
currentCropState.imageSize.height,
) / currentCropState.scale;
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropX,
cropY: currentCropState.cropY,
cropSize,
});
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
setReferenceImageError(null);
} catch (cropError) {
setCropState({
...currentCropState,
isSaving: false,
error:
cropError instanceof Error
? cropError.message
: '拼图图片裁剪失败,请重试。',
});
}
};
const submitForm = () => {
@@ -245,11 +574,15 @@ export function PuzzleAgentWorkspace({
return;
}
const payloadPictureDescription = formState.aiRedraw
? pictureDescription
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
const payload = {
seedText: pictureDescription,
pictureDescription,
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
if (!session && onCreateFromForm) {
@@ -259,161 +592,181 @@ export function PuzzleAgentWorkspace({
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
pictureDescription,
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
});
};
const pictureDescriptionLabel = formState.referenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述';
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl 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] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
{showBackButton ? (
<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] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
<span className="text-xs font-black text-[var(--platform-text-soft)]">
Template
</span>
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
{PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === selectedTemplateId,
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
{title ? (
<div className="mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-4xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
<div
className="flex gap-3 overflow-x-auto pb-2"
aria-label="拼图创作模板"
>
{PUZZLE_CREATION_TEMPLATES.map((template) => {
const selected = template.id === selectedTemplateId;
return (
<button
key={template.id}
type="button"
disabled={isBusy}
onClick={() => applyTemplatePrompt(template.id)}
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
selected
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={`${template.title}模板`}
>
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
<img
src={template.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</span>
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
{template.title}
</span>
{selected ? (
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
</span>
) : null}
</button>
);
})}
</div>
</div>
) : null}
<div className="mt-4 space-y-4">
<label
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span>
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<label className="block">
<span className="sr-only"></span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[20rem]"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
<section className="overflow-visible">
<div
className={`grid gap-3 sm:gap-4 ${
formState.aiRedraw
? 'lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'lg:grid-cols-1'
}`}
>
<div className={`min-w-0 ${isBusy ? 'opacity-55' : ''}`}>
<div className="mb-2 text-sm font-black text-[var(--platform-text-strong)]">
</div>
</label>
{formState.referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<div className="puzzle-image-upload-card relative aspect-square w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_16px_34px_rgba(222,82,124,0.12)] transition">
<input
id="puzzle-image-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传拼图图片"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
<label
htmlFor="puzzle-image-upload-input"
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'
}
>
<span className="sr-only">
{formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'}
</span>
</label>
{formState.referenceImageSrc ? (
<img
src={formState.referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
alt="拼图图"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-7 w-7 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
{formState.referenceImageSrc ? (
<label className="absolute right-3 top-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={formState.aiRedraw}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
aiRedraw: event.target.checked,
}))
}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null}
</div>
<label
htmlFor="puzzle-image-upload-input"
className={`mt-2 block text-center text-sm font-black text-[var(--platform-text-strong)] transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
</label>
</div>
{formState.aiRedraw ? (
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
{pictureDescriptionLabel}
</span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={2}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="min-h-[clamp(5rem,15svh,7rem)] w-full resize-none rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[8.5rem] lg:min-h-[10.5rem]"
aria-label={pictureDescriptionLabel}
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{formState.referenceImageLabel || '已选择参考图'}
</div>
</div>
</label>
) : null}
</div>
<div className="mt-3 space-y-3">
{formState.referenceImageSrc ? (
<div className="flex items-center justify-end">
<button
type="button"
disabled={isBusy}
@@ -422,14 +775,18 @@ export function PuzzleAgentWorkspace({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-xs"
aria-label="移除拼图图片"
title="移除拼图图片"
>
<X className="h-4 w-4" />
<span className="inline-flex items-center gap-1.5">
<X className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
@@ -459,12 +816,25 @@ export function PuzzleAgentWorkspace({
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
) : null}
</span>
</button>
</div>
{cropState ? (
<PuzzleImageCropModal
state={cropState}
onScaleChange={updateCropScale}
onCropChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {
void applyCropState();
}}
/>
) : null}
</div>
);
}

View File

@@ -589,4 +589,40 @@ describe('PuzzleResultView', () => {
}),
);
});
test('shows creative agent draft edit bar and submits the current draft', () => {
const onSubmit = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
creativeDraftEdit={{
isBusy: false,
error: null,
onSubmit,
}}
/>,
);
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
target: { value: '把标题改得轻松一点' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
expect(onSubmit).toHaveBeenCalledWith({
instruction: '把标题改得轻松一点',
currentDraft: expect.objectContaining({
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
pictureReference: null,
}),
],
}),
});
});
});

View File

@@ -4,6 +4,7 @@ import {
ImagePlus,
Images,
Loader2,
MessageSquareText,
Play,
Plus,
Sparkles,
@@ -13,6 +14,7 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
@@ -41,6 +43,14 @@ type PuzzleResultViewProps = {
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onStartTestRun?: (draft: PuzzleResultDraft) => void;
creativeDraftEdit?: {
isBusy: boolean;
error: string | null;
onSubmit: (payload: {
instruction: string;
currentDraft: PuzzleResultDraft;
}) => Promise<CreativeDraftEditResult | null> | void;
} | null;
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
@@ -113,6 +123,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
pictureReference: level.pictureReference ?? null,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
@@ -160,6 +171,7 @@ function createBlankPuzzleLevel(
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
levelName: '',
pictureDescription: '',
pictureReference: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -835,6 +847,20 @@ function PuzzleLevelDetailDialog({
</label>
</div>
<input
value={level.pictureReference ?? ''}
disabled={isBusy}
onChange={(event) =>
onLevelChange({
...level,
pictureReference: event.target.value,
})
}
className="mt-3 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="参考图链接或资产ID"
aria-label="图面参考"
/>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
@@ -1112,6 +1138,78 @@ function PuzzlePublishDialog({
);
}
function PuzzleCreativeDraftEditBar({
currentDraft,
error,
isBusy,
onSubmit,
}: {
currentDraft: PuzzleResultDraft;
error: string | null;
isBusy: boolean;
onSubmit: (payload: {
instruction: string;
currentDraft: PuzzleResultDraft;
}) => Promise<CreativeDraftEditResult | null> | void;
}) {
const [instruction, setInstruction] = useState('');
const trimmedInstruction = instruction.trim();
const canSubmit = Boolean(trimmedInstruction) && !isBusy;
const submit = () => {
if (!canSubmit) {
return;
}
void onSubmit({
instruction: trimmedInstruction,
currentDraft,
});
setInstruction('');
};
return (
<section className="platform-subpanel mb-3 rounded-[1.35rem] p-3 sm:p-4">
<div className="flex items-end gap-2">
<span className="mb-1 hidden h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/72 text-[var(--platform-text-base)] sm:inline-flex">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquareText className="h-4 w-4" />
)}
</span>
<textarea
value={instruction}
disabled={isBusy}
rows={2}
onChange={(event) => setInstruction(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submit();
}
}}
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
placeholder="让 Agent 调整标题、标签或关卡描述"
aria-label="智能修订拼图草稿"
/>
<button
type="button"
disabled={!canSubmit}
onClick={submit}
className="platform-button platform-button--secondary min-h-11 px-4 py-2 text-sm"
>
{isBusy ? '修改中' : '修改'}
</button>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-[1rem] text-sm leading-6">
{error}
</div>
) : null}
</section>
);
}
function PuzzleLevelListTab({
editState,
imageRefreshKey,
@@ -1316,6 +1414,7 @@ export function PuzzleResultView({
onBack,
onExecuteAction,
onStartTestRun,
creativeDraftEdit = null,
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
@@ -1491,6 +1590,15 @@ export function PuzzleResultView({
<PuzzleResultTabs activeTab={activeTab} onChange={setActiveTab} />
{creativeDraftEdit ? (
<PuzzleCreativeDraftEditBar
currentDraft={syncedDraft}
error={creativeDraftEdit.error}
isBusy={creativeDraftEdit.isBusy}
onSubmit={creativeDraftEdit.onSubmit}
/>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{activeTab === 'levels' ? (
<PuzzleLevelListTab

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
@@ -43,6 +44,13 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
@@ -109,6 +117,20 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { squareHoleCreationClient } from '../../services/square-hole-creation';
import {
dropSquareHoleShape,
finishSquareHoleTimeUp,
restartSquareHoleRun,
startSquareHoleRun,
stopSquareHoleRun,
} from '../../services/square-hole-runtime';
import {
deleteSquareHoleWork,
getSquareHoleWorkDetail,
listSquareHoleGallery,
listSquareHoleWorks,
} from '../../services/square-hole-works';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -136,18 +158,51 @@ async function clickFirstAsyncButtonByName(
await user.click(buttons[0]!);
}
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '草稿');
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
}
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '发现');
const panel = getPlatformTabPanel('category');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByPlaceholderText(
'搜索作品号、名称、作者、描述',
),
).toBeTruthy();
return panel;
}
async function openProfilePlayedWorks(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '我的');
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('可继续')).toBeTruthy();
}
async function openExistingRpgDraft(
user: ReturnType<typeof userEvent.setup>,
actionName: string | RegExp = /(?:|)/u,
) {
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: actionName }));
}
@@ -289,6 +344,40 @@ vi.mock('../../services/match3d-runtime', () => ({
stopMatch3DRun: vi.fn(),
}));
vi.mock('../../services/square-hole-creation', () => ({
squareHoleCreationClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
},
}));
vi.mock('../../services/square-hole-runtime', () => ({
dropSquareHoleShape: vi.fn(),
finishSquareHoleTimeUp: vi.fn(),
getSquareHoleRun: vi.fn(),
restartSquareHoleRun: vi.fn(),
startSquareHoleRun: vi.fn(),
stopSquareHoleRun: vi.fn(),
}));
vi.mock('../../services/square-hole-works', () => ({
deleteSquareHoleWork: vi.fn(),
getSquareHoleWorkDetail: vi.fn(),
listSquareHoleGallery: vi.fn(),
listSquareHoleWorks: vi.fn(),
}));
vi.mock('../../services/creative-agent', () => ({
cancelCreativeAgentSession: vi.fn(),
confirmCreativePuzzleTemplate: vi.fn(),
createCreativeAgentSession: vi.fn(),
streamCreativeAgentMessage: vi.fn(),
streamCreativeDraftEdit: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
@@ -573,6 +662,168 @@ const mockAuthUser: AuthUser = {
createdAt: new Date().toISOString(),
};
function buildMockCreativeAgentSession(
overrides: Partial<CreativeAgentSessionSnapshot> = {},
): CreativeAgentSessionSnapshot {
const sessionId = overrides.sessionId ?? 'creative-agent-session-1';
return {
sessionId,
stage: 'waiting_user',
inputSummary: {
text: null,
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [
{
id: 'creative-agent-message-1',
role: 'assistant',
kind: 'chat',
text: '说一个灵感,我来帮你做成互动内容。',
createdAt: '2026-05-05T10:00:00.000Z',
},
],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleAgentSession(
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
) {
return buildMockSquareHoleAgentSessionImpl(overrides);
}
function buildMockSquareHoleAgentSessionImpl(
overrides: Partial<{
sessionId: string;
stage: string;
messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>;
updatedAt: string;
}> = {},
) {
const sessionId = overrides.sessionId ?? 'square-hole-session-1';
return {
sessionId,
currentTurn: 0,
progressPercent: 20,
stage: 'collecting_config',
anchorPack: {
theme: {
key: 'theme',
label: '题材主题',
value: '霓虹形状',
status: 'confirmed',
},
twistRule: {
key: 'twistRule',
label: '反直觉规则',
value: '颜色会误导洞口',
status: 'confirmed',
},
shapeCount: {
key: 'shapeCount',
label: '形状数量',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '5',
status: 'confirmed',
},
},
config: {
themeText: '霓虹形状',
twistRule: '颜色会误导洞口',
shapeCount: 12,
difficulty: 5,
shapeOptions: [
{
optionId: 'shape-square',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-square',
imagePrompt: '霓虹方块',
imageSrc: null,
},
],
holeOptions: [
{
holeId: 'hole-square',
holeKind: 'square',
label: '方洞',
imagePrompt: '发光方洞',
imageSrc: null,
},
],
backgroundPrompt: '霓虹街机背景',
coverImageSrc: null,
backgroundImageSrc: null,
},
draft: null,
messages: [
{
id: 'square-hole-message-1',
role: 'assistant',
kind: 'chat',
text: '先确定方洞挑战的题材和反直觉规则。',
createdAt: '2026-05-01T10:00:00.000Z',
},
],
lastAssistantReply: '先确定方洞挑战的题材和反直觉规则。',
publishedProfileId: null,
updatedAt: '2026-05-01T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleRun(profileId: string) {
return {
runId: `square-hole-run-${profileId}`,
profileId,
ownerUserId: 'user-2',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1_000,
durationLimitMs: 600_000,
remainingMs: 600_000,
totalShapeCount: 12,
completedShapeCount: 0,
combo: 0,
bestCombo: 0,
score: 0,
ruleLabel: '颜色会误导洞口',
currentShape: {
shapeId: 'shape-1',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-square',
color: '#ff5f7e',
imageSrc: null,
},
holes: [
{
holeId: 'hole-square',
holeKind: 'square',
label: '方洞',
x: 0.2,
y: 0.5,
},
],
lastFeedback: null,
};
}
function buildMockPuzzleRun(
profileId: string,
levelName: string,
@@ -1779,6 +2030,50 @@ beforeEach(() => {
vi.mocked(stopMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
vi.mocked(squareHoleCreationClient.createSession).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(squareHoleCreationClient.getSession).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(squareHoleCreationClient.streamMessage).mockResolvedValue(
buildMockSquareHoleAgentSession(),
);
vi.mocked(squareHoleCreationClient.executeAction).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(listSquareHoleWorks).mockResolvedValue({
items: [],
});
vi.mocked(listSquareHoleGallery).mockResolvedValue({
items: [],
});
vi.mocked(getSquareHoleWorkDetail).mockRejectedValue(
new Error('未找到方洞挑战作品'),
);
vi.mocked(deleteSquareHoleWork).mockResolvedValue({
items: [],
});
vi.mocked(startSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(dropSquareHoleShape).mockResolvedValue({
feedback: {
accepted: true,
rejectReason: null,
message: '投入成功',
},
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(restartSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(finishSquareHoleTimeUp).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(stopSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
@@ -1871,44 +2166,119 @@ beforeEach(() => {
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
vi.mocked(createCreativeAgentSession).mockResolvedValue({
session: buildMockCreativeAgentSession(),
});
vi.mocked(streamCreativeAgentMessage).mockImplementation(
async (sessionId, payload) =>
buildMockCreativeAgentSession({
sessionId,
stage: 'collaborating',
messages: [
{
id: 'creative-agent-message-1',
role: 'assistant',
kind: 'chat',
text: '说一个灵感,我来帮你做成互动内容。',
createdAt: '2026-05-05T10:00:00.000Z',
},
{
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.content
.map((part) =>
part.type === 'input_text' ? part.text.trim() : '参考图',
)
.filter(Boolean)
.join(' / '),
createdAt: '2026-05-05T10:01:00.000Z',
},
{
id: 'creative-agent-message-2',
role: 'assistant',
kind: 'chat',
text: '收到,我先帮你整理成可创作方案。',
createdAt: '2026-05-05T10:01:01.000Z',
},
],
}),
);
vi.mocked(cancelCreativeAgentSession).mockResolvedValue({
session: buildMockCreativeAgentSession({ stage: 'failed' }),
});
vi.mocked(confirmCreativePuzzleTemplate).mockResolvedValue({
session: buildMockCreativeAgentSession(),
});
vi.mocked(streamCreativeDraftEdit).mockResolvedValue(
buildMockCreativeAgentSession(),
);
});
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
test('create tab shows template tabs and embeds puzzle form by default', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const airpButton = screen.getByRole('button', { name: /AIRP/u });
const visualNovelButton = screen.getByRole('button', {
name: //u,
});
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src,
).toContain('/creation-type-references/square-hole.webp');
expect(
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
).toContain('/creation-type-references/visual-novel.webp');
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(<TestWrapper withAuth />);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await openCreationHub(user);
await openCreateTemplateHub(user);
const generateButton = await screen.findByRole('button', {
name: /稿/u,
});
expect(
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
expect(listBigFishGallery).not.toHaveBeenCalled();
await user.click(generateButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
@@ -2002,7 +2372,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
@@ -2056,7 +2426,7 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
@@ -2112,7 +2482,7 @@ test('create tab resumes agent workspace when session has no draft profile even
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -2122,7 +2492,7 @@ test('create tab resumes agent workspace when session has no draft profile even
expect(screen.queryByText('世界档案')).toBeNull();
});
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
test('opening a compiled draft with a missing agent session falls back to draft hub', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks)
@@ -2166,20 +2536,22 @@ test('opening a compiled draft with a missing agent session falls back to create
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
const fallbackDraftPanel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(getPlatformTabPanel('create')).getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
within(fallbackDraftPanel).getByText(
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
),
).toBeTruthy();
});
expect(window.location.search).toBe('');
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
expect(screen.getByText('还没有作品')).toBeTruthy();
expect(within(fallbackDraftPanel).getByText('还没有作品')).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-missing'),
).toBeNull();
@@ -2374,6 +2746,7 @@ test('logged out public detail gates big fish start before local runtime', async
/>,
);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
@@ -2469,23 +2842,22 @@ test('creation hub clears all private work shelves immediately after logout stat
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
await openCreationHub(user);
const createPanel = getPlatformTabPanel('create');
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(listRpgCreationWorks).toHaveBeenCalled();
});
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(await within(draftPanel).findByText('拼图退出缓存作品')).toBeTruthy();
expect(within(draftPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(draftPanel).queryByText('大鱼退出缓存作品')).toBeNull();
rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
expect(screen.queryByText('RPG 退出缓存作品')).toBeNull();
expect(screen.queryByText('拼图退出缓存作品')).toBeNull();
});
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and mobile game category channel', async () => {
@@ -2522,12 +2894,15 @@ test('published puzzle works appear on home and mobile game category channel', a
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await clickFirstButtonByName(user, '发现');
await user.click(screen.getByRole('button', { name: '分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
const discoverPanel = getPlatformTabPanel('category');
expect(
within(homePanel).getAllByRole('button', { name: //u }).length,
within(discoverPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
expect(
within(discoverPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
@@ -2564,12 +2939,13 @@ test('published big fish works stay hidden from platform home and game category
});
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await clickFirstButtonByName(user, '发现');
await user.click(screen.getByRole('button', { name: '分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
const discoverPanel = getPlatformTabPanel('category');
expect(within(discoverPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
expect(
within(homePanel).queryAllByRole('button', { name: //u }).length,
within(discoverPanel).queryAllByRole('button', { name: //u }).length,
).toBe(0);
});
@@ -2603,6 +2979,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '发现');
await user.click(await screen.findByRole('button', { name: '排行' }));
await waitFor(() => {
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
@@ -2647,32 +3024,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
});
});
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await openCreationHub(user);
const puzzleButton = await screen.findByRole('button', {
name: /.*/u,
});
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
const openLoginModal = vi.fn();
@@ -2779,7 +3130,7 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
).toBeTruthy();
});
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
@@ -2792,36 +3143,35 @@ test('new puzzle creation entry maps raw bearer token errors to user-facing auth
render(<TestWrapper withAuth />);
await openCreationHub(user);
const puzzleButton = screen.getByRole('button', {
name: /.*/u,
});
await openCreateTemplateHub(user);
const generateButton = screen.getByRole('button', { name: /稿/u });
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generateButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(
await within(getPlatformTabPanel('create')).findByText(
await screen.findByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
test('hidden big fish creation entry does not render in platform create hub', async () => {
test('create tab does not render legacy gameplay creation entries', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
expect(screen.queryByText('选择创作类型')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createBigFishCreationSession).not.toHaveBeenCalled();
});
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
@@ -2832,9 +3182,9 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
const button = screen.getByRole('button', { name: /.*/u });
const button = screen.getByRole('button', { name: /稿/u });
await user.click(button);
await waitFor(() => {
@@ -2845,12 +3195,12 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
).toBeGreaterThan(0);
});
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
expect(screen.queryByText(//u)).toBeNull();
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
});
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
new Error('读取作品广场失败'),
@@ -2858,23 +3208,16 @@ test('visible match3d creation card opens workspace even when public galleries f
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
new Error('读取抓大鹅广场失败'),
);
vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({
session: match3dSession,
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
await user.click(
screen.getByRole('button', { name: /.*/u }),
);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('抓大鹅工作区match3d-agent-session-1')).toBeTruthy();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('puzzle draft result back button returns to creation hub', async () => {
@@ -2905,7 +3248,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -2919,11 +3262,9 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -2955,7 +3296,7 @@ test('published puzzle work card restores its source session for editing', async
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -3089,6 +3430,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
item: puzzleWork,
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3274,6 +3616,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
item: profileId === similarWork.profileId ? similarWork : entryWork,
}));
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3375,6 +3718,7 @@ test('first puzzle runtime back click can open remix result page', async () => {
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3428,6 +3772,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3471,6 +3816,7 @@ test('public code search opens a published big fish work by BF code', async () =
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3523,6 +3869,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3672,7 +4019,7 @@ test('failed draft work continues on generation progress view instead of agent w
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -4193,7 +4540,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -4370,9 +4717,7 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.getByText('10分钟创作一个精品互动玩法')).toBeTruthy();
});
expect(
@@ -4625,7 +4970,9 @@ test('agent draft result can open from server result preview without embedded le
);
});
test('authenticated users with save archives default into the saves tab', async () => {
test('authenticated users can open save archives from the profile played panel', async () => {
const user = userEvent.setup();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
@@ -4642,15 +4989,18 @@ test('authenticated users with save archives default into the saves tab', async
render(<TestWrapper withAuth />);
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
await openProfilePlayedWorks(user);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.getAllByText('旧灯塔与失控航路').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
expect(screen.queryByText('ARCHIVE')).toBeNull();
expect(screen.queryByText('最近存档')).toBeNull();
});
test('puzzle save archive highlights work title and level subtitle', async () => {
const user = userEvent.setup();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'puzzle:puzzle-profile-1',
@@ -4667,6 +5017,8 @@ test('puzzle save archive highlights work title and level subtitle', async () =>
render(<TestWrapper withAuth />);
await openProfilePlayedWorks(user);
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
expect(screen.queryByText('ARCHIVE')).toBeNull();
@@ -4689,16 +5041,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByRole('button', {
name: /.*/u,
}),
within(getPlatformTabPanel('create')).getByText(
'10分钟创作一个精品互动玩法',
),
).toBeTruthy();
});
@@ -4750,6 +5102,8 @@ test('save tab can resume a selected archive directly into the game', async () =
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await openProfilePlayedWorks(user);
await clickFirstAsyncButtonByName(user, //u);
await waitFor(() => {
@@ -4827,7 +5181,7 @@ test('creation hub published work can open detail view before deleting from deta
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
@@ -4902,7 +5256,7 @@ test('creation hub published work enters existing detail view', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
@@ -4976,7 +5330,7 @@ test('creation hub published work experience button enters world directly', asyn
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '启动' }));
@@ -5060,7 +5414,7 @@ test('creation hub published work card keeps delete action guarded by detail flo
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));

View File

@@ -939,6 +939,23 @@ test('shows a reachable login entry in logged out mobile shell', async () => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
test('logged out bottom nav keeps creation centered with recommend icon', () => {
const { container } = renderLoggedOutHomeView(vi.fn());
const nav = container.querySelector('.platform-bottom-nav');
expect(nav).toBeTruthy();
const buttons = within(nav as HTMLElement).getAllByRole('button');
expect(buttons.map((button) => button.textContent)).toEqual([
'推荐',
'创作',
'发现',
]);
expect(buttons[0]?.querySelector('.lucide-gamepad-2')).toBeTruthy();
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
});
test('mobile home search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();

View File

@@ -1,5 +1,4 @@
import {
Archive,
ArrowRight,
Bell,
BookOpen,
@@ -8,20 +7,20 @@ import {
ChevronRight,
Clock3,
Coins,
Compass,
Copy,
Gamepad2,
Heart,
House,
LogIn,
MessageCircle,
Pencil,
Plus,
Search,
Settings,
SlidersHorizontal,
Sparkles,
Star,
Ticket,
Trophy,
UserPlus,
UserRound,
} from 'lucide-react';
@@ -83,6 +82,7 @@ import {
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
@@ -136,6 +136,7 @@ export interface RpgEntryHomeViewProps {
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onRechargeSuccess?: () => void | Promise<void>;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
}
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
@@ -161,7 +162,7 @@ const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category';
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
const COMMUNITY_QR_CODES = [
@@ -177,13 +178,14 @@ const COMMUNITY_QR_CODES = [
},
] as const;
const MOBILE_HOME_CHANNELS: Array<{
id: MobileHomeChannel;
const DISCOVER_CHANNELS: Array<{
id: DiscoverChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日游戏' },
{ id: 'category', label: '游戏分类' },
{ id: 'today', label: '今日' },
{ id: 'category', label: '分类' },
{ id: 'ranking', label: '排行' },
];
const PLATFORM_RANKING_TABS: Array<{
@@ -377,6 +379,7 @@ function WorldCard({
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
variant = 'standard',
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
@@ -385,6 +388,7 @@ function WorldCard({
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const coverSlides = useMemo(() => {
@@ -465,7 +469,7 @@ function WorldCard({
onClick={onClick}
aria-label={cardLabel}
data-mobile-feed-card-key={feedCardKey}
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
className={`platform-public-work-card ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
@@ -724,6 +728,7 @@ function PlatformTabButton({
<button
type="button"
onClick={onClick}
aria-label={label}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="platform-bottom-nav__button-content">
@@ -874,7 +879,10 @@ function PlatformRankingItem({
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
return (
<button
@@ -936,7 +944,10 @@ function PlatformCategoryGameItem({
describePublicGalleryCardKind(entry),
...tags.filter((tag) => tag !== categoryTag),
].slice(0, 3);
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
const summaryText =
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
@@ -1139,6 +1150,8 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1249,6 +1262,8 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '抓鹅'
: isSquareHoleGalleryEntry(entry)
? '方洞'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}
@@ -2549,20 +2564,30 @@ function ProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
}) {
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
@@ -2590,6 +2615,11 @@ function ProfilePlayedWorksModal({
{error}
</div>
) : null}
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
@@ -2600,43 +2630,73 @@ function ProfilePlayedWorksModal({
/>
))}
</div>
) : playedWorks.length > 0 ? (
<div className="mt-5 space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
) : hasArchiveEntries || hasPlayedWorks ? (
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<div className="space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{' '}
{formatCompactPlayTime(
work.lastObservedPlayTimeMs,
)}
</span>
</div>
</button>
))}
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</button>
))}
</section>
) : null}
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
@@ -2681,6 +2741,7 @@ export function RpgEntryHomeView({
onOpenPlayedWork,
onRechargeSuccess,
createTabContent,
draftTabContent,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
@@ -2733,9 +2794,10 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const mobileFeedRef = useRef<HTMLElement | null>(null);
const [discoverChannel, setDiscoverChannel] =
useState<DiscoverChannel>('recommend');
const mobileRecommendFeedRef = useRef<HTMLElement | null>(null);
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
@@ -2842,18 +2904,29 @@ export function RpgEntryHomeView({
isReferralCenterInitialized &&
Boolean(referralCenter) &&
referralCenter?.hasRedeemedCode !== true;
const tabIcons = {
home: House,
category: Trophy,
create: Sparkles,
saves: Archive,
profile: UserRound,
} as const;
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Plus,
saves: Pencil,
profile: UserRound,
}
: {
home: Gamepad2,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
};
const tabLabels = {
home: '首页',
category: '排行',
home: '推荐',
category: '发现',
create: '创作',
saves: '存档',
saves: '草稿',
profile: '我的',
} as const;
@@ -3398,11 +3471,19 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
mobileHomeChannel === 'recommend'
? [...featuredShelf, ...latestEntries]
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(latestEntries);
sourceEntries.forEach((entry) => {
@@ -3410,16 +3491,22 @@ export function RpgEntryHomeView({
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
const mobileFeedCarouselEnabled =
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
!isDesktopLayout &&
((activeTab === 'home' && recommendedFeedEntries.length > 0) ||
(activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today')));
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileFeedRef.current;
const feedElement =
activeTab === 'home'
? mobileRecommendFeedRef.current
: mobileDiscoverFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
@@ -3490,7 +3577,13 @@ export function RpgEntryHomeView({
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
}, [
discoverChannel,
discoverFeedEntries,
activeTab,
mobileFeedCarouselEnabled,
recommendedFeedEntries,
]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
@@ -3499,9 +3592,6 @@ export function RpgEntryHomeView({
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -3512,7 +3602,101 @@ export function RpgEntryHomeView({
onTabChange('category');
};
const mobileHomeContent: ReactNode = (
const mobileRankingPanel: ReactNode = (
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : rankingEntries.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2.5">
{rankingEntries.map((entry, index) => (
<PlatformRankingItem
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry}
rank={index + 1}
metricLabel={activeRankingConfig.metricLabel}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text={activeRankingConfig.emptyText} />
)}
</section>
);
const mobileRecommendContent: ReactNode = (
<div
className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
<section
ref={mobileRecommendFeedRef}
className="platform-mobile-home-feed platform-mobile-recommend-feed"
>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : recommendedFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-4">
{recommendedFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-recommend`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
variant="immersive"
/>
);
})}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
</div>
);
const mobileDiscoverContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
@@ -3531,13 +3715,13 @@ export function RpgEntryHomeView({
) : (
<>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{MOBILE_HOME_CHANNELS.map((channel) => {
const active = mobileHomeChannel === channel.id;
{DISCOVER_CHANNELS.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setMobileHomeChannel(channel.id)}
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
@@ -3552,7 +3736,9 @@ export function RpgEntryHomeView({
</div>
) : null}
{mobileHomeChannel === 'category' ? (
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-category-list-panel">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
@@ -3609,17 +3795,20 @@ export function RpgEntryHomeView({
)}
</section>
) : (
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
) : discoverFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
{discoverFeedEntries.map((entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
key={`${cardKey}:mobile-feed:${discoverChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
@@ -3641,60 +3830,13 @@ export function RpgEntryHomeView({
</div>
);
const categoryContent: ReactNode = (
<div className={categoryPageClass}>
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : rankingEntries.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2.5">
{rankingEntries.map((entry, index) => (
<PlatformRankingItem
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry}
rank={index + 1}
metricLabel={activeRankingConfig.metricLabel}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text={activeRankingConfig.emptyText} />
)}
</section>
</div>
const categoryContent: ReactNode = isDesktopLayout ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}>{mobileRankingPanel}</div>
) : (
mobileDiscoverContent
);
const createContent: ReactNode = createTabContent ?? (
const fallbackCreateStartContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
@@ -3720,7 +3862,11 @@ export function RpgEntryHomeView({
</div>
</div>
</button>
</div>
);
const fallbackDraftContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{isLoadingPlatform ? (
@@ -3756,53 +3902,10 @@ export function RpgEntryHomeView({
</div>
);
const savesContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? (
<>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{saveError}
</div>
) : null}
const createContent: ReactNode = createTabContent ?? fallbackCreateStartContent;
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="全部存档" detail="最近更新时间排序" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取存档..." />
) : saveEntries.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={entry.worldKey}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="还没有可恢复的存档,去首页开始一段新的游玩吧。" />
)}
</section>
</>
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
className="platform-button platform-button--primary mt-4"
>
</button>
</div>
</section>
)}
</div>
const savesContent: ReactNode = (
draftTabContent ?? fallbackDraftContent
);
const profileContent: ReactNode = (
@@ -4326,7 +4429,7 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
@@ -4399,7 +4502,7 @@ export function RpgEntryHomeView({
if (!isDesktopLayout) {
return (
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
<button
@@ -4410,19 +4513,23 @@ export function RpgEntryHomeView({
<LogIn className="h-3.5 w-3.5" />
</button>
) : null}
) : (
<button
type="button"
onClick={openUserSurface}
className="platform-icon-button platform-mobile-topbar__action shrink-0"
aria-label="通知与账户"
>
<Bell className="h-4 w-4" />
</button>
)}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div
className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
style={{
borderColor: 'var(--platform-line-soft)',
}}
>
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
@@ -4472,8 +4579,12 @@ export function RpgEntryHomeView({
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
@@ -4606,8 +4717,12 @@ export function RpgEntryHomeView({
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (

View File

@@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启智能创作工作区超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
}

View File

@@ -2,9 +2,13 @@ import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isVisualNovelGalleryEntry,
mapVisualNovelWorkToPlatformGalleryCard,
resolvePlatformPublicWorkCode,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
@@ -106,3 +110,25 @@ test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
},
]);
});
test('maps visual novel work to platform gallery card with VN public code', () => {
const card = mapVisualNovelWorkToPlatformGalleryCard({
runtimeKind: 'visual-novel',
profileId: 'vn-profile-demo-12345678',
ownerUserId: 'user-1',
title: '雨夜终章',
description: '失踪列车上的选择。',
coverImageSrc: '/vn-cover.png',
tags: ['悬疑', '列车'],
publishStatus: 'published',
publishReady: true,
playCount: 7,
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: '2026-05-07T00:00:00.000Z',
});
expect(isVisualNovelGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('VN-12345678');
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
});

View File

@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
@@ -18,6 +19,7 @@ import {
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
@@ -30,7 +32,8 @@ export type PlatformWorldCardLike =
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard;
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
@@ -132,12 +135,35 @@ export type PlatformSquareHoleGalleryCard = {
updatedAt: string;
};
export type PlatformVisualNovelGalleryCard = {
sourceType: 'visual-novel';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard;
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
@@ -169,6 +195,12 @@ export function isSquareHoleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'square-hole';
}
export function isVisualNovelGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformVisualNovelGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -280,6 +312,32 @@ export function mapBigFishWorkToPlatformGalleryCard(
};
}
export function mapVisualNovelWorkToPlatformGalleryCard(
work: VisualNovelWorkSummary,
): PlatformVisualNovelGalleryCard {
return {
sourceType: 'visual-novel',
workId: work.profileId,
profileId: work.profileId,
sourceSessionId: null,
publicWorkCode: buildVisualNovelPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',
worldName: work.title,
subtitle: '视觉小说模板',
summaryText: work.description,
coverImageSrc: work.coverImageSrc,
themeTags: work.tags.length > 0 ? work.tags : ['视觉小说'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -452,6 +510,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: ['方洞'];
}
if (isVisualNovelGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['视觉小说'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -534,6 +598,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isVisualNovelGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}

View File

@@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import { WorldType, type CustomWorldProfile } from '../../types';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { type CustomWorldProfile,WorldType } from '../../types';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
@@ -173,6 +173,7 @@ describe('RPG Agent 草稿恢复', () => {
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),

View File

@@ -329,14 +329,8 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
setPlatformTabState(
isAuthenticated &&
canReadProtectedData &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
// 中文注释saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页。
setPlatformTabState('home');
}
} finally {
if (isActive) {

View File

@@ -54,6 +54,7 @@ type UseRpgEntryLibraryDetailParams = {
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformTabToDraft: () => void;
setPlatformError: (error: string | null) => void;
appendBrowseHistoryEntry: (
entry: PlatformBrowseHistoryWriteEntry,
@@ -106,6 +107,7 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate,
setPlatformTabToDraft,
setPlatformError,
appendBrowseHistoryEntry,
refreshCustomWorldWorks,
@@ -347,7 +349,7 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource(null);
await refreshCustomWorldWorks().catch(() => []);
setPlatformError(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
);
} else {
setPlatformError(
@@ -355,7 +357,7 @@ export function useRpgEntryLibraryDetail(
);
}
setPlatformTabToCreate();
setPlatformTabToDraft();
setSelectionStage('platform');
return;
}
@@ -405,6 +407,7 @@ export function useRpgEntryLibraryDetail(
setGeneratedCustomWorldProfile,
setPlatformError,
setPlatformTabToCreate,
setPlatformTabToDraft,
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,

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;

View File

@@ -0,0 +1,130 @@
/* @vitest-environment jsdom */
import { render, screen, within } 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 { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
uploadVisualNovelAsset: vi.fn(),
}));
vi.mock('../../services/assetReadUrlService', () => ({
resolveAssetReadUrl: vi.fn().mockResolvedValue(''),
}));
test('visual novel result opens complex editors as a dialog', async () => {
const user = userEvent.setup();
render(
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '林遥' });
expect(within(dialog).getByDisplayValue('林遥')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
});
test('visual novel result exposes test run action with current draft', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onStartTestRun.mock.calls[0]?.[0].workTitle).toBe('雪线电台');
});
test('visual novel result sends edited character draft to save and test run', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const onStartTestRun = vi.fn();
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
/>,
);
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '林遥' });
const nameInput = within(dialog).getByDisplayValue('林遥');
await user.clear(nameInput);
await user.type(nameInput, '林遥改');
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
const saveButtons = screen.getAllByRole('button', { name: '保存草稿' });
await user.click(saveButtons[1]!);
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onSaveDraft.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
expect(onStartTestRun.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
});
test('visual novel result uploads scene and character assets into platform references', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const uploadMock = vi.mocked(
await import('../../services/visual-novel-creation'),
).uploadVisualNovelAsset;
uploadMock.mockResolvedValue({
assetObjectId: 'asset-scene-1',
assetKind: 'scene_image',
objectKey: 'generated-custom-world-scenes/vn-profile/scene-1/background.png',
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-1/background.png',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '风雪站台' });
const backgroundButtons = within(dialog).getAllByRole('button', {
name: '背景图',
});
await user.click(backgroundButtons[0]!);
const fileInput = within(screen.getByRole('dialog', { name: '背景图' })).getByLabelText(
'上传背景图文件',
) as HTMLInputElement;
await user.upload(
fileInput,
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
);
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(onSaveDraft).toHaveBeenCalled();
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toContain(
'/generated-custom-world-scenes/',
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelAttributePanelProps = {
run: VisualNovelRunSnapshot;
};
export function VisualNovelAttributePanel({ run }: VisualNovelAttributePanelProps) {
const metrics = Object.entries(run.metrics);
if (metrics.length === 0) {
return (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
);
}
return (
<div className="space-y-3">
{metrics.map(([key, value]) => (
<div key={key}>
<div className="mb-1 flex items-center justify-between text-xs font-bold text-[var(--platform-text-soft)]">
<span>{key}</span>
<span>{value}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--platform-track-fill)]">
<div
className="h-full rounded-full bg-[var(--platform-button-primary-fill)]"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
</div>
))}
</div>
);
}
export default VisualNovelAttributePanel;

View File

@@ -0,0 +1,112 @@
import { RotateCcw } from 'lucide-react';
import type {
VisualNovelHistoryEntry,
VisualNovelRunSnapshot,
VisualNovelRuntimeStep,
} from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelHistoryPanelProps = {
run: VisualNovelRunSnapshot;
allowRegeneration?: boolean;
isBusy?: boolean;
onRegenerateHistoryEntry?: (entryId: string) => void;
};
function renderStepText(step: VisualNovelRuntimeStep) {
if (step.type === 'narration') {
return step.text;
}
if (step.type === 'dialogue') {
return `${step.characterName}: ${step.text}`;
}
if (step.type === 'transition') {
return step.text ?? '';
}
if (step.type === 'choice') {
return step.choices.map((choice) => choice.text).join(' / ');
}
return '';
}
function VisualNovelHistoryEntryCard({
entry,
allowRegeneration,
isBusy,
onRegenerateHistoryEntry,
}: {
entry: VisualNovelHistoryEntry;
allowRegeneration: boolean;
isBusy: boolean;
onRegenerateHistoryEntry?: (entryId: string) => void;
}) {
const visibleStepTexts = entry.steps.map(renderStepText).filter(Boolean);
const canRegenerate =
allowRegeneration && entry.source === 'assistant' && onRegenerateHistoryEntry;
return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3">
<div className="mb-2 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span>#{entry.turnIndex}</span>
<span>{entry.source === 'player' ? '玩家' : '故事'}</span>
</div>
{entry.actionText ? (
<p className="m-0 break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{entry.actionText}
</p>
) : null}
{visibleStepTexts.map((text, index) => (
<p
key={`${entry.entryId}-${index}`}
className="m-0 mt-2 break-words text-sm leading-6 text-[var(--platform-text-base)]"
>
{text}
</p>
))}
{canRegenerate ? (
<button
type="button"
disabled={isBusy}
onClick={() => onRegenerateHistoryEntry(entry.entryId)}
className="mt-3 inline-flex min-h-9 items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/86 px-3 text-xs font-black text-[var(--platform-text-strong)] transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
>
<RotateCcw className="h-3.5 w-3.5" />
<span></span>
</button>
) : null}
</article>
);
}
export function VisualNovelHistoryPanel({
run,
allowRegeneration = false,
isBusy = false,
onRegenerateHistoryEntry,
}: VisualNovelHistoryPanelProps) {
return (
<div className="space-y-3">
{run.history.length > 0 ? (
run.history.map((entry) => (
<VisualNovelHistoryEntryCard
key={entry.entryId}
entry={entry}
allowRegeneration={allowRegeneration}
isBusy={isBusy}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
/>
))
) : (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
</div>
);
}
export default VisualNovelHistoryPanel;

View File

@@ -0,0 +1,142 @@
import { BarChart3, Bookmark, History, Settings, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelResultDraft,
VisualNovelRunSnapshot,
} from '../../../packages/shared/src/contracts/visualNovel';
import { VisualNovelAttributePanel } from './VisualNovelAttributePanel';
import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel';
import { VisualNovelSavePanel } from './VisualNovelSavePanel';
import { VisualNovelSettingsPanel } from './VisualNovelSettingsPanel';
export type VisualNovelRuntimePanelKind =
| 'history'
| 'save'
| 'settings'
| 'attributes';
type VisualNovelRuntimePanelProps = {
kind: VisualNovelRuntimePanelKind;
draft: VisualNovelResultDraft;
run: VisualNovelRunSnapshot;
isBusy?: boolean;
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
saveArchives?: ProfileSaveArchiveSummary[];
onClose: () => void;
onRegenerateHistoryEntry?: (entryId: string) => void;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
textModeEnabled?: boolean;
onTextModeChange?: (enabled: boolean) => void;
allowRegeneration?: boolean;
};
const PANEL_META: Record<
VisualNovelRuntimePanelKind,
{ title: string; icon: typeof History }
> = {
history: { title: '历史', icon: History },
save: { title: '存档', icon: Bookmark },
settings: { title: '设置', icon: Settings },
attributes: { title: '属性', icon: BarChart3 },
};
export function VisualNovelRuntimePanel({
kind,
draft,
run,
isBusy = false,
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
saveArchives = [],
onClose,
onRegenerateHistoryEntry,
onSaveRun,
onResumeSaveArchive,
textModeEnabled = false,
onTextModeChange,
allowRegeneration = false,
}: VisualNovelRuntimePanelProps) {
if (typeof document === 'undefined') {
return null;
}
const meta = PANEL_META[kind];
const Icon = meta.icon;
return createPortal(
<div
className="platform-overlay fixed inset-0 z-[150] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label={meta.title}
className="platform-modal-shell platform-remap-surface flex max-h-[min(84vh,40rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
<Icon className="h-4 w-4" />
</span>
<h2 className="truncate text-base font-black text-[var(--platform-text-strong)]">
{meta.title}
</h2>
</div>
<button
type="button"
className="platform-icon-button h-9 w-9"
onClick={onClose}
aria-label="关闭"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{kind === 'history' ? (
<VisualNovelHistoryPanel
run={run}
allowRegeneration={allowRegeneration}
isBusy={isBusy}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
/>
) : null}
{kind === 'save' ? (
<VisualNovelSavePanel
run={run}
saveArchives={saveArchives}
isSaving={isSaving}
isLoadingArchives={isLoadingArchives}
resumingWorldKey={resumingWorldKey}
onSaveRun={onSaveRun}
onResumeSaveArchive={onResumeSaveArchive}
/>
) : null}
{kind === 'settings' ? (
<VisualNovelSettingsPanel
draft={draft}
textModeEnabled={textModeEnabled}
onTextModeChange={onTextModeChange}
/>
) : null}
{kind === 'attributes' ? <VisualNovelAttributePanel run={run} /> : null}
</div>
</section>
</div>,
document.body,
);
}
export default VisualNovelRuntimePanel;

View File

@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from './visualNovelForbiddenCopy';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { VisualNovelRuntimeShell } from './VisualNovelRuntimeShell';
test('visual novel runtime renders mock play surface and opens panels as dialogs', async () => {
const user = userEvent.setup();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
/>,
);
expect(screen.getByText('风雪站台')).toBeTruthy();
expect(screen.getAllByText('林遥').length).toBeGreaterThan(0);
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '历史' }));
const dialog = screen.getByRole('dialog', { name: '历史' });
expect(within(dialog).getByText('#1')).toBeTruthy();
});
test('visual novel runtime submits free text action with client event id', async () => {
const user = userEvent.setup();
const onSubmitAction = vi.fn();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
/>,
);
await user.type(screen.getByLabelText('输入行动'), '检查广播柜');
await user.click(screen.getByRole('button', { name: '发送行动' }));
expect(onSubmitAction).toHaveBeenCalledWith(
expect.objectContaining({
actionKind: 'free_text',
text: '检查广播柜',
}),
);
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
/^vn-free-text-/u,
);
});
test('visual novel runtime submits choice and continue actions', async () => {
const user = userEvent.setup();
const onSubmitAction = vi.fn();
const onContinue = vi.fn();
const runWithoutChoices = {
...mockVisualNovelRun,
availableChoices: [],
};
const { rerender } = render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
/>,
);
await user.click(screen.getByRole('button', { name: '靠近广播柜,确认频段来源。' }));
expect(onSubmitAction).toHaveBeenCalledWith(
expect.objectContaining({
actionKind: 'choice',
choiceId: 'vn-choice-radio',
}),
);
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
/^vn-choice-/u,
);
rerender(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={runWithoutChoices}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
/>,
);
await user.click(screen.getByRole('button', { name: '继续' }));
expect(onContinue).toHaveBeenCalledTimes(1);
});
test('visual novel runtime panels call regeneration and platform archive actions', async () => {
const user = userEvent.setup();
const onRegenerateHistoryEntry = vi.fn();
const onSaveRun = vi.fn();
const onResumeSaveArchive = vi.fn();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
onSaveRun={onSaveRun}
onResumeSaveArchive={onResumeSaveArchive}
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
summaryText: '第 2 回合',
coverImageSrc: null,
lastPlayedAt: '2026-05-05T12:00:00.000Z',
},
]}
/>,
);
await user.click(screen.getByRole('button', { name: '历史' }));
await user.click(screen.getByRole('button', { name: '重生成' }));
expect(onRegenerateHistoryEntry).toHaveBeenCalledWith('vn-history-1');
await user.click(screen.getByRole('button', { name: '关闭' }));
await user.click(screen.getByRole('button', { name: '存档' }));
await user.click(screen.getByRole('button', { name: '保存' }));
expect(onSaveRun).toHaveBeenCalledTimes(1);
await user.click(screen.getByText('雪线电台'));
expect(onResumeSaveArchive).toHaveBeenCalledWith('visual-novel:archive-1');
});
test('visual novel runtime shows raw text only as transient stream text', () => {
const transientText = '这是临时流式文本';
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
streamingText={transientText}
onBack={() => {}}
/>,
);
expect(screen.getByText(transientText)).toBeTruthy();
const textModeBlocks = screen.queryAllByText((content, element) => {
return Boolean(
element?.className.toString().includes('whitespace-pre-line') &&
content.includes(transientText),
);
});
expect(textModeBlocks).toHaveLength(0);
});

View File

@@ -0,0 +1,603 @@
import {
ArrowLeft,
Bookmark,
ChevronRight,
History,
MessageSquareText,
Send,
Settings,
SlidersHorizontal,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelCharacterDraft,
VisualNovelChoiceDraft,
VisualNovelDialogueStep,
VisualNovelNarrationStep,
VisualNovelResultDraft,
VisualNovelRunSnapshot,
VisualNovelRuntimeActionRequest,
VisualNovelRuntimeStep,
VisualNovelSceneChangeStep,
VisualNovelTransitionStep,
} from '../../../packages/shared/src/contracts/visualNovel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import {
VisualNovelRuntimePanel,
type VisualNovelRuntimePanelKind,
} from './VisualNovelRuntimePanels';
type VisualNovelRuntimeShellProps = {
draft?: VisualNovelResultDraft | null;
run?: VisualNovelRunSnapshot | null;
isBusy?: boolean;
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
error?: string | null;
streamedSteps?: VisualNovelRuntimeStep[];
streamingText?: string;
saveArchives?: ProfileSaveArchiveSummary[];
onBack: () => void;
onSubmitAction?: (payload: VisualNovelRuntimeActionRequest) => void;
onContinue?: () => void;
onRegenerateHistoryEntry?: (entryId: string) => void;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
onTextModeChange?: (enabled: boolean) => void;
};
type VisualNovelDisplayState = {
sceneStep: VisualNovelSceneChangeStep | null;
narrationStep: VisualNovelNarrationStep | null;
dialogueStep: VisualNovelDialogueStep | null;
transitionStep: VisualNovelTransitionStep | null;
choiceStep: VisualNovelRuntimeStep | null;
};
function buildClientEventId(kind: string) {
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
function collectRuntimeSteps(
run: VisualNovelRunSnapshot,
streamedSteps: VisualNovelRuntimeStep[],
) {
return [
...run.history.flatMap((entry) => entry.steps),
...streamedSteps,
];
}
function resolveLatestStep<T extends VisualNovelRuntimeStep['type']>(
steps: VisualNovelRuntimeStep[],
type: T,
) {
return [...steps].reverse().find((step) => step.type === type) as
| Extract<VisualNovelRuntimeStep, { type: T }>
| undefined;
}
function resolveDisplayState(
run: VisualNovelRunSnapshot,
streamedSteps: VisualNovelRuntimeStep[],
): VisualNovelDisplayState {
const steps = collectRuntimeSteps(run, streamedSteps);
return {
sceneStep: resolveLatestStep(steps, 'scene_change') ?? null,
narrationStep: resolveLatestStep(steps, 'narration') ?? null,
dialogueStep: resolveLatestStep(steps, 'dialogue') ?? null,
transitionStep: resolveLatestStep(steps, 'transition') ?? null,
choiceStep: resolveLatestStep(steps, 'choice') ?? null,
};
}
function resolveSceneId(
run: VisualNovelRunSnapshot,
sceneStep: VisualNovelSceneChangeStep | null,
) {
return sceneStep?.sceneId ?? run.currentSceneId;
}
function resolveSceneName(draft: VisualNovelResultDraft, sceneId: string | null) {
return draft.scenes.find((scene) => scene.sceneId === sceneId)?.name ?? '';
}
function resolveSceneBackground(
draft: VisualNovelResultDraft,
sceneId: string | null,
sceneStep: VisualNovelSceneChangeStep | null,
) {
return (
sceneStep?.backgroundImageSrc ??
draft.scenes.find((scene) => scene.sceneId === sceneId)?.backgroundImageSrc ??
draft.coverImageSrc
);
}
function resolveVisibleCharacters(
draft: VisualNovelResultDraft,
run: VisualNovelRunSnapshot,
latestDialogue: VisualNovelDialogueStep | null,
) {
const visibleIds = new Set(run.visibleCharacterIds);
if (latestDialogue?.characterId) {
visibleIds.add(latestDialogue.characterId);
}
return Array.from(visibleIds)
.map((characterId) =>
draft.characters.find((character) => character.characterId === characterId),
)
.filter((character): character is VisualNovelCharacterDraft =>
Boolean(character),
)
.slice(0, 3);
}
function resolveCharacterImage(character: VisualNovelCharacterDraft) {
if (character.imageAssets.length === 0) {
return null;
}
return (
character.imageAssets.find(
(asset) => asset.expression === character.defaultExpression,
)?.imageSrc ?? character.imageAssets[0]?.imageSrc ?? null
);
}
function VisualNovelCharacterStandee({
character,
index,
active,
}: {
character: VisualNovelCharacterDraft;
index: number;
active: boolean;
}) {
const imageSrc = resolveCharacterImage(character);
const palette =
index % 2 === 0
? 'from-sky-100/90 via-slate-100/78 to-slate-300/72'
: 'from-rose-100/90 via-zinc-100/78 to-stone-300/72';
return (
<div
className={`flex min-w-0 flex-col items-center transition ${
active ? 'scale-100 opacity-100' : 'scale-[0.96] opacity-78'
}`}
>
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
alt={character.name}
className="h-[min(48dvh,21rem)] w-[9rem] object-contain drop-shadow-[0_26px_54px_rgba(0,0,0,0.42)] sm:w-[12rem]"
/>
) : (
<div
className={`h-[min(44dvh,18rem)] w-[7.5rem] rounded-t-full border border-white/18 bg-gradient-to-b ${palette} shadow-[0_26px_58px_rgba(15,23,42,0.32)] sm:w-[10rem]`}
/>
)}
<div className="mt-2 max-w-[8rem] truncate rounded-full border border-white/16 bg-black/28 px-3 py-1 text-xs font-black text-white backdrop-blur">
{character.name}
</div>
</div>
);
}
function buildTextModeLines(
run: VisualNovelRunSnapshot,
streamedSteps: VisualNovelRuntimeStep[],
) {
return [...run.history.flatMap((entry) => entry.steps), ...streamedSteps]
.filter((step) => step.type === 'narration' || step.type === 'dialogue')
.map((step) =>
step.type === 'dialogue'
? `${step.characterName}: ${step.text}`
: step.text,
)
.join('\n');
}
function resolveChoices(
run: VisualNovelRunSnapshot,
choiceStep: VisualNovelRuntimeStep | null,
) {
if (choiceStep?.type === 'choice' && choiceStep.choices.length > 0) {
return choiceStep.choices;
}
return run.availableChoices;
}
export function VisualNovelRuntimeShell({
draft = mockVisualNovelDraft,
run = mockVisualNovelRun,
isBusy = false,
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
error,
streamedSteps = [],
streamingText = '',
saveArchives,
onBack,
onSubmitAction,
onContinue,
onRegenerateHistoryEntry,
onSaveRun,
onResumeSaveArchive,
onTextModeChange,
}: VisualNovelRuntimeShellProps) {
const [activePanel, setActivePanel] =
useState<VisualNovelRuntimePanelKind | null>(null);
const [freeText, setFreeText] = useState('');
const [localTextModeEnabled, setLocalTextModeEnabled] = useState(
run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false,
);
const displayDraft = draft ?? mockVisualNovelDraft;
const baseRun = run ?? mockVisualNovelRun;
const runtimeController = useVisualNovelRuntimeController({
draft: displayDraft,
initialRun: baseRun,
profileId: displayDraft.profileId,
autoStart: false,
});
const displayRun = runtimeController.run ?? baseRun;
const displayBusy = isBusy || runtimeController.isBusy;
const displaySaving = isSaving || runtimeController.isSaving;
const displayLoadingArchives =
isLoadingArchives || runtimeController.isLoadingArchives;
const displayResumingWorldKey =
resumingWorldKey ?? runtimeController.resumingWorldKey;
const displayError = error ?? runtimeController.error;
const displaySaveArchives = saveArchives ?? runtimeController.saveArchives;
const displayStreamedSteps =
streamedSteps.length > 0 ? streamedSteps : runtimeController.streamedSteps;
const displayStreamingText =
streamingText || runtimeController.streamingText;
const displayState = useMemo(
() => resolveDisplayState(displayRun, displayStreamedSteps),
[displayRun, displayStreamedSteps],
);
const textModeEnabled = localTextModeEnabled;
const sceneId = resolveSceneId(displayRun, displayState.sceneStep);
const sceneName = resolveSceneName(displayDraft, sceneId);
const backgroundImageSrc = resolveSceneBackground(
displayDraft,
sceneId,
displayState.sceneStep,
);
const visibleCharacters = useMemo(
() =>
resolveVisibleCharacters(
displayDraft,
displayRun,
displayState.dialogueStep,
),
[displayDraft, displayRun, displayState.dialogueStep],
);
const choices = resolveChoices(displayRun, displayState.choiceStep);
const canSubmitFreeText =
displayDraft.runtimeConfig.allowFreeTextAction &&
freeText.trim() &&
!displayBusy;
const canShowAttributes =
displayDraft.runtimeConfig.attributePanelMode !== 'off';
const primarySpeaker =
displayState.dialogueStep?.characterName ||
(displayState.narrationStep ? '旁白' : displayDraft.workTitle);
const primaryText =
displayState.dialogueStep?.text ||
displayState.narrationStep?.text ||
displayDraft.opening.narration ||
displayDraft.workDescription;
const textModeLines = buildTextModeLines(displayRun, displayStreamedSteps);
const loadRuntimeSaveArchives = runtimeController.loadSaveArchives;
useEffect(() => {
if (activePanel === 'save' && !saveArchives) {
void loadRuntimeSaveArchives();
}
}, [activePanel, loadRuntimeSaveArchives, saveArchives]);
const updateTextMode = (enabled: boolean) => {
setLocalTextModeEnabled(enabled);
onTextModeChange?.(enabled);
};
const submitChoice = (choice: VisualNovelChoiceDraft) => {
if (displayBusy) {
return;
}
const payload = {
actionKind: 'choice',
choiceId: choice.choiceId,
clientEventId: buildClientEventId('choice'),
} satisfies VisualNovelRuntimeActionRequest;
if (onSubmitAction) {
onSubmitAction(payload);
return;
}
void runtimeController.submitAction(payload);
};
const submitFreeText = () => {
const text = freeText.trim();
if (!text || displayBusy) {
return;
}
const payload = {
actionKind: 'free_text',
text,
clientEventId: buildClientEventId('free-text'),
} satisfies VisualNovelRuntimeActionRequest;
if (onSubmitAction) {
onSubmitAction(payload);
} else {
void runtimeController.submitAction(payload);
}
setFreeText('');
};
const continueRuntime = () => {
if (displayBusy) {
return;
}
if (onContinue) {
onContinue();
return;
}
void runtimeController.continueRun();
};
const regenerateHistoryEntry = (entryId: string) => {
if (onRegenerateHistoryEntry) {
onRegenerateHistoryEntry(entryId);
return;
}
void runtimeController.regenerateFromHistory(entryId);
};
const saveRuntime = () => {
if (onSaveRun) {
onSaveRun();
return;
}
void runtimeController.saveCurrentRun();
};
const resumeArchive = (worldKey: string) => {
if (onResumeSaveArchive) {
onResumeSaveArchive(worldKey);
return;
}
void runtimeController.resumeSaveArchive(worldKey);
};
return (
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#111827] text-white">
{backgroundImageSrc ? (
<ResolvedAssetImage
src={backgroundImageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.10),rgba(15,23,42,0.92)),linear-gradient(135deg,#162235_0%,#334155_46%,#111827_100%)]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.88)),linear-gradient(90deg,rgba(0,0,0,0.32),transparent_36%,rgba(0,0,0,0.38))]" />
<div
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
style={{
boxSizing: 'border-box',
maxWidth: '100vw',
width: 'min(100vw, 64rem)',
}}
>
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft size={20} />
</button>
<div className="min-w-0 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-center text-sm font-black backdrop-blur">
<span className="block max-w-[12rem] truncate sm:max-w-[22rem]">
{sceneName || displayDraft.workTitle}
</span>
</div>
<button
type="button"
className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur ${
textModeEnabled ? 'ring-2 ring-white/40' : ''
}`}
onClick={() => updateTextMode(!textModeEnabled)}
aria-label="文本模式"
title="文本模式"
>
<MessageSquareText size={18} />
</button>
</header>
<section className="relative mt-3 flex min-h-0 flex-1 items-end justify-center overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 px-3 pb-3 pt-6 shadow-[0_20px_54px_rgba(0,0,0,0.28)] backdrop-blur-sm">
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),transparent_34%),linear-gradient(90deg,rgba(15,23,42,0.22),transparent_36%,rgba(15,23,42,0.3))]" />
<div className="relative flex w-full max-w-4xl items-end justify-center gap-3 sm:gap-9">
{visibleCharacters.length > 0 ? (
visibleCharacters.map((character, index) => (
<VisualNovelCharacterStandee
key={character.characterId}
character={character}
index={index}
active={
!displayState.dialogueStep ||
displayState.dialogueStep.characterId ===
character.characterId
}
/>
))
) : (
<div className="h-[min(40dvh,16rem)]" />
)}
</div>
</section>
<section className="relative mt-3 rounded-[1.25rem] border border-white/16 bg-black/46 p-3 shadow-[0_18px_44px_rgba(0,0,0,0.28)] backdrop-blur">
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0 truncate text-sm font-black text-white">
{primarySpeaker}
</div>
<div className="shrink-0 text-[11px] font-bold text-white/58">
{displayRun.mode === 'test' ? 'TEST' : 'PLAY'}
</div>
</div>
<p className="m-0 min-h-[4.75rem] break-words text-base leading-7 text-white/92">
{primaryText}
</p>
{displayState.transitionStep?.text ? (
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm font-semibold text-white/76">
{displayState.transitionStep.text}
</div>
) : null}
{displayStreamingText ? (
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm leading-6 text-white/72">
{displayStreamingText}
</div>
) : null}
{textModeEnabled ? (
<div className="mt-3 max-h-32 overflow-y-auto rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 whitespace-pre-line text-sm leading-6 text-white/78">
{textModeLines || primaryText}
</div>
) : null}
</section>
{choices.length > 0 ? (
<section className="mt-3 grid gap-2 sm:grid-cols-2">
{choices.map((choice) => (
<button
key={choice.choiceId}
type="button"
disabled={displayBusy}
onClick={() => submitChoice(choice)}
className="min-h-12 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 py-2 text-left text-sm font-black leading-5 text-white shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
>
<span className="block break-words">{choice.text}</span>
</button>
))}
</section>
) : (
<section className="mt-3">
<button
type="button"
disabled={displayBusy}
onClick={continueRuntime}
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 text-sm font-black text-white backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
>
<span></span>
<ChevronRight className="h-4 w-4" />
</button>
</section>
)}
<section className="mt-3 flex gap-2">
<input
value={freeText}
disabled={
displayBusy || !displayDraft.runtimeConfig.allowFreeTextAction
}
onChange={(event) => setFreeText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitFreeText();
}
}}
className="min-h-11 min-w-0 flex-1 rounded-full border border-white/16 bg-black/26 px-4 text-sm font-semibold text-white outline-none placeholder:text-white/42"
placeholder="输入行动"
aria-label="输入行动"
/>
<button
type="button"
disabled={!canSubmitFreeText}
onClick={submitFreeText}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/16 bg-white/14 text-white backdrop-blur transition hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-55"
aria-label="发送行动"
title="发送行动"
>
<Send size={18} />
</button>
</section>
{displayError ? (
<div className="mt-3 rounded-[0.95rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
{displayError}
</div>
) : null}
<footer
className={`mt-3 grid gap-2 ${canShowAttributes ? 'grid-cols-4' : 'grid-cols-3'}`}
>
{[
{ kind: 'history' as const, label: '历史', icon: History },
{ kind: 'save' as const, label: '存档', icon: Bookmark },
{ kind: 'settings' as const, label: '设置', icon: Settings },
...(canShowAttributes
? [
{
kind: 'attributes' as const,
label: '属性',
icon: SlidersHorizontal,
},
]
: []),
].map((item) => {
const Icon = item.icon;
return (
<button
key={item.kind}
type="button"
onClick={() => setActivePanel(item.kind)}
className="flex min-h-12 min-w-0 flex-col items-center justify-center gap-1 rounded-[0.95rem] border border-white/14 bg-black/24 px-2 text-xs font-black text-white backdrop-blur transition hover:bg-black/34"
>
<Icon className="h-4 w-4" />
<span className="max-w-full truncate">{item.label}</span>
</button>
);
})}
</footer>
</div>
{activePanel ? (
<VisualNovelRuntimePanel
kind={activePanel}
draft={displayDraft}
run={displayRun}
isBusy={displayBusy}
isSaving={displaySaving}
isLoadingArchives={displayLoadingArchives}
resumingWorldKey={displayResumingWorldKey}
saveArchives={displaySaveArchives}
allowRegeneration={displayDraft.runtimeConfig.allowHistoryRegeneration}
onClose={() => setActivePanel(null)}
onRegenerateHistoryEntry={regenerateHistoryEntry}
onSaveRun={saveRuntime}
onResumeSaveArchive={resumeArchive}
textModeEnabled={textModeEnabled}
onTextModeChange={updateTextMode}
/>
) : null}
</main>
);
}
export default VisualNovelRuntimeShell;

View File

@@ -0,0 +1,102 @@
import { Bookmark, Loader2, Play } from 'lucide-react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelSavePanelProps = {
run: VisualNovelRunSnapshot;
saveArchives?: ProfileSaveArchiveSummary[];
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
};
function formatArchiveTime(value: string) {
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
}
export function VisualNovelSavePanel({
run,
saveArchives = [],
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
onSaveRun,
onResumeSaveArchive,
}: VisualNovelSavePanelProps) {
return (
<div className="space-y-3">
<button
type="button"
disabled={!onSaveRun || isSaving}
onClick={onSaveRun}
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-button-primary-fill)] px-4 text-sm font-black text-white transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Bookmark className="h-4 w-4" />
)}
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
{isLoadingArchives ? (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : saveArchives.length > 0 ? (
<div className="grid gap-3">
{saveArchives.map((entry) => {
const isResuming = resumingWorldKey === entry.worldKey;
return (
<button
key={entry.worldKey}
type="button"
disabled={isResuming || !onResumeSaveArchive}
onClick={() => onResumeSaveArchive?.(entry.worldKey)}
className="flex min-h-20 items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3 text-left transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
>
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-[0.85rem] bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
{isResuming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-black text-[var(--platform-text-strong)]">
{entry.worldName || run.profileId}
</span>
<span className="mt-1 block truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{entry.subtitle || entry.summaryText || run.runId}
</span>
<span className="mt-1 block text-[11px] font-bold text-[var(--platform-text-soft)]">
{formatArchiveTime(entry.lastPlayedAt)}
</span>
</span>
</button>
);
})}
</div>
) : (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
</div>
);
}
export default VisualNovelSavePanel;

View File

@@ -0,0 +1,51 @@
import { MessageSquareText } from 'lucide-react';
import type { VisualNovelResultDraft } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelSettingsPanelProps = {
draft: VisualNovelResultDraft;
textModeEnabled: boolean;
onTextModeChange?: (enabled: boolean) => void;
};
export function VisualNovelSettingsPanel({
draft,
textModeEnabled,
onTextModeChange,
}: VisualNovelSettingsPanelProps) {
return (
<div className="space-y-2">
<button
type="button"
onClick={() => onTextModeChange?.(!textModeEnabled)}
className="flex min-h-12 w-full items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3 text-left transition hover:bg-white"
>
<span className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]">
<MessageSquareText className="h-4 w-4 shrink-0" />
<span></span>
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{textModeEnabled ? '开启' : '关闭'}
</span>
</button>
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{draft.runtimeConfig.allowFreeTextAction ? '开启' : '关闭'}
</span>
</div>
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{draft.runtimeConfig.allowHistoryRegeneration ? '开启' : '关闭'}
</span>
</div>
</div>
);
}
export default VisualNovelSettingsPanel;

View File

@@ -0,0 +1,385 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelResultDraft,
VisualNovelRunMode,
VisualNovelRunSnapshot,
VisualNovelRuntimeActionRequest,
VisualNovelRuntimeStep,
} from '../../../packages/shared/src/contracts/visualNovel';
import {
buildVisualNovelRuntimeCheckpoint,
buildVisualNovelSaveArchiveState,
visualNovelRuntimeClient,
} from '../../services/visual-novel-runtime';
export type UseVisualNovelRuntimeControllerParams = {
draft?: VisualNovelResultDraft | null;
initialRun?: VisualNovelRunSnapshot | null;
profileId?: string | null;
runId?: string | null;
mode?: VisualNovelRunMode;
autoStart?: boolean;
};
function createVisualNovelClientEventId(kind: string) {
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
function resolveVisualNovelErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message.trim()
? error.message.trim()
: fallback;
}
function resolveProfileId(params: UseVisualNovelRuntimeControllerParams) {
return params.profileId?.trim() || params.draft?.profileId?.trim() || null;
}
function resolveRunFromResumeSnapshot(value: unknown) {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
currentStory?: { run?: VisualNovelRunSnapshot | null } | null;
gameState?: { runId?: unknown; archiveState?: unknown } | null;
};
return snapshot.currentStory?.run ?? null;
}
function resolveRunIdFromResumeSnapshot(value: unknown) {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
gameState?: {
runId?: unknown;
archiveState?: { runId?: unknown } | null;
} | null;
};
const directRunId = snapshot.gameState?.runId;
const archiveRunId = snapshot.gameState?.archiveState?.runId;
if (typeof directRunId === 'string' && directRunId.trim()) {
return directRunId.trim();
}
if (typeof archiveRunId === 'string' && archiveRunId.trim()) {
return archiveRunId.trim();
}
return null;
}
export function useVisualNovelRuntimeController(
params: UseVisualNovelRuntimeControllerParams,
) {
const [run, setRun] = useState<VisualNovelRunSnapshot | null>(
params.initialRun ?? null,
);
const [saveArchives, setSaveArchives] = useState<
ProfileSaveArchiveSummary[]
>([]);
const [streamedSteps, setStreamedSteps] = useState<VisualNovelRuntimeStep[]>(
[],
);
const [streamingText, setStreamingText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoadingArchives, setIsLoadingArchives] = useState(false);
const [resumingWorldKey, setResumingWorldKey] = useState<string | null>(null);
const activeActionAbortRef = useRef<AbortController | null>(null);
const profileId = resolveProfileId(params);
useEffect(() => {
setRun(params.initialRun ?? null);
}, [params.initialRun]);
useEffect(
() => () => {
activeActionAbortRef.current?.abort();
},
[],
);
const loadSaveArchives = useCallback(async () => {
if (!profileId) {
setSaveArchives([]);
return [];
}
setIsLoadingArchives(true);
setError(null);
try {
const entries = await visualNovelRuntimeClient.listSaveArchives(profileId);
setSaveArchives(entries);
return entries;
} catch (loadError) {
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说存档失败'));
return [];
} finally {
setIsLoadingArchives(false);
}
}, [profileId]);
const startRun = useCallback(async () => {
if (!profileId || isBusy) {
return null;
}
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const response = await visualNovelRuntimeClient.startRun(profileId, {
profileId,
mode: params.mode ?? 'play',
});
setRun(response.run);
return response.run;
} catch (startError) {
setError(resolveVisualNovelErrorMessage(startError, '启动视觉小说失败'));
return null;
} finally {
setIsBusy(false);
}
}, [isBusy, params.mode, profileId]);
useEffect(() => {
if (run || !params.runId) {
return;
}
let cancelled = false;
setIsBusy(true);
setError(null);
void visualNovelRuntimeClient
.getRun(params.runId)
.then((response) => {
if (!cancelled) {
setRun(response.run);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说失败'));
}
})
.finally(() => {
if (!cancelled) {
setIsBusy(false);
}
});
return () => {
cancelled = true;
};
}, [params.runId, run]);
useEffect(() => {
if (!params.autoStart || run || params.runId) {
return;
}
void startRun();
}, [params.autoStart, params.runId, run, startRun]);
const submitAction = useCallback(
async (payload: VisualNovelRuntimeActionRequest) => {
if (!run || isBusy) {
return null;
}
activeActionAbortRef.current?.abort();
const abortController = new AbortController();
activeActionAbortRef.current = abortController;
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const nextRun = await visualNovelRuntimeClient.streamAction(
run.runId,
payload,
{
signal: abortController.signal,
onEvent: (event) => {
if (event.type === 'raw_text') {
// 中文注释raw_text 只用于流式临场反馈,不进入 history 或最终文本模式。
setStreamingText((currentText) => `${currentText}${event.text}`);
}
if (event.type === 'step') {
setStreamedSteps((currentSteps) => [
...currentSteps,
event.step,
]);
}
if (event.type === 'snapshot' || event.type === 'complete') {
setRun(event.run);
}
},
},
);
setRun(nextRun);
setStreamedSteps([]);
setStreamingText('');
return nextRun;
} catch (submitError) {
setError(resolveVisualNovelErrorMessage(submitError, '推进视觉小说失败'));
return null;
} finally {
if (activeActionAbortRef.current === abortController) {
activeActionAbortRef.current = null;
}
setIsBusy(false);
}
},
[isBusy, run],
);
const continueRun = useCallback(
() =>
submitAction({
actionKind: 'continue',
clientEventId: createVisualNovelClientEventId('continue'),
}),
[submitAction],
);
const regenerateFromHistory = useCallback(
async (historyEntryId: string) => {
if (!run || isBusy) {
return null;
}
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const response = await visualNovelRuntimeClient.regenerateRun(run.runId, {
historyEntryId,
clientEventId: createVisualNovelClientEventId('regenerate'),
});
setRun(response.run);
return response.run;
} catch (regenError) {
setError(
resolveVisualNovelErrorMessage(regenError, '重生成视觉小说历史失败'),
);
return null;
} finally {
setIsBusy(false);
}
},
[isBusy, run],
);
const saveCurrentRun = useCallback(async () => {
if (!run || isSaving) {
return null;
}
setIsSaving(true);
setError(null);
try {
const checkpoint = buildVisualNovelRuntimeCheckpoint({ run });
await visualNovelRuntimeClient.putSnapshot(checkpoint);
await loadSaveArchives();
return buildVisualNovelSaveArchiveState(run);
} catch (saveError) {
setError(resolveVisualNovelErrorMessage(saveError, '保存视觉小说失败'));
return null;
} finally {
setIsSaving(false);
}
}, [isSaving, loadSaveArchives, run]);
const resumeSaveArchive = useCallback(
async (worldKey: string) => {
if (!worldKey.trim() || resumingWorldKey) {
return null;
}
setResumingWorldKey(worldKey);
setError(null);
try {
const response =
await visualNovelRuntimeClient.resumeSaveArchive(worldKey);
setSaveArchives((currentEntries) =>
currentEntries.map((entry) =>
entry.worldKey === response.entry.worldKey ? response.entry : entry,
),
);
const restoredRun = resolveRunFromResumeSnapshot(response.snapshot);
if (restoredRun) {
setRun(restoredRun);
return restoredRun;
}
const restoredRunId = resolveRunIdFromResumeSnapshot(response.snapshot);
if (restoredRunId) {
const runResponse =
await visualNovelRuntimeClient.getRun(restoredRunId);
setRun(runResponse.run);
return runResponse.run;
}
return null;
} catch (resumeError) {
setError(resolveVisualNovelErrorMessage(resumeError, '恢复视觉小说失败'));
return null;
} finally {
setResumingWorldKey(null);
}
},
[resumingWorldKey],
);
const currentArchiveState = useMemo(
() => (run ? buildVisualNovelSaveArchiveState(run) : null),
[run],
);
return {
draft: params.draft ?? null,
run,
setRun,
currentArchiveState,
saveArchives,
streamedSteps,
streamingText,
error,
isBusy,
isSaving,
isLoadingArchives,
resumingWorldKey,
startRun,
submitAction,
continueRun,
regenerateFromHistory,
saveCurrentRun,
loadSaveArchives,
resumeSaveArchive,
};
}
export type VisualNovelRuntimeControllerResult = ReturnType<
typeof useVisualNovelRuntimeController
>;

View File

@@ -0,0 +1,13 @@
const VISUAL_NOVEL_FORBIDDEN_COPY_PARTS = [
['回', '放'],
['分享', '回', '放'],
['录', '制'],
['复', '盘'],
];
export function buildVisualNovelForbiddenCopyPattern() {
return new RegExp(
VISUAL_NOVEL_FORBIDDEN_COPY_PARTS.map((parts) => parts.join('')).join('|'),
'u',
);
}

View File

@@ -0,0 +1,455 @@
import type {
VisualNovelAgentSessionSnapshot,
VisualNovelResultDraft,
VisualNovelRunSnapshot,
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
const MOCK_NOW = '2026-05-05T12:00:00.000Z';
// 中文注释VN-04 只提供 UI 骨架mock 数据必须保持在前端组件域内,避免被误用成业务真相源。
export const mockVisualNovelDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-mock-1',
workTitle: '雪线电台',
workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。',
workTags: ['悬疑', '冬夜', '双人叙事'],
coverImageSrc: null,
sourceMode: 'idea',
sourceAssetIds: [],
world: {
title: '北境终点线',
summary: '终年落雪的边境小城依靠一座旧电台维系列车、灯塔和远方来信。',
background:
'十二年前的雪崩掩埋了山间支线,也留下无法解释的夜间广播。现在,最后一班列车重新驶入废弃站台。',
premise: '玩家需要在日出前找出列车停摆的原因,并决定是否公开电台里的旧录音。',
literaryStyle: '克制、冷光感、对白带有细微试探。',
playerRole: '临时接任的列车广播员',
defaultTone: '安静、紧张、带一点温柔',
},
characters: [
{
characterId: 'vn-char-lin-yao',
name: '林遥',
gender: '女',
role: 'main',
appearance: '深灰长外套,围巾边缘有细小电台徽章。',
personality: '谨慎、敏锐,习惯先观察再回答。',
tone: '短句多,情绪压得很低。',
background: '曾在旧电台做夜班实习生,熟悉失踪列车的第一份报案。',
relationshipToPlayer: '玩家的临时搭档',
imageAssets: [],
defaultExpression: 'calm',
isPlayerVisible: false,
},
{
characterId: 'vn-char-he-shen',
name: '赫慎',
gender: '男',
role: 'supporting',
appearance: '车长制服整洁,袖口有被火星燎过的旧痕。',
personality: '礼貌、固执,对列车时刻表近乎偏执。',
tone: '正式、慢速,偶尔忽然沉默。',
background: '事故后仍守着废弃站台的人。',
relationshipToPlayer: '提供线索,也隐瞒关键事故记录',
imageAssets: [],
defaultExpression: 'reserved',
isPlayerVisible: false,
},
],
scenes: [
{
sceneId: 'vn-scene-platform',
name: '风雪站台',
description: '站灯忽明忽暗,远处铁轨被雪压出发亮的线。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: ['vn-phase-1'],
},
{
sceneId: 'vn-scene-radio-room',
name: '旧电台室',
description: '木桌上堆着线圈、胶带和没来得及寄出的明信片。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'phase_locked',
phaseIds: ['vn-phase-2'],
},
],
storyPhases: [
{
phaseId: 'vn-phase-1',
title: '重启站台',
goal: '确认列车为何停在废弃站台。',
summary: '玩家抵达风雪站台,第一次听见旧电台播出自己的名字。',
entryCondition: '开场进入',
exitCondition: '找到车长日志或解开广播频段',
sceneIds: ['vn-scene-platform'],
characterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
suggestedChoices: ['询问林遥旧电台的来历', '检查站台广播柜', '追问车长日志'],
},
{
phaseId: 'vn-phase-2',
title: '未寄出的录音',
goal: '判断旧录音是否可信。',
summary: '旧电台室中出现十二年前的夜班录音,内容和现实开始互相抵触。',
entryCondition: '完成站台调查',
exitCondition: '选择公开或隐藏录音',
sceneIds: ['vn-scene-radio-room'],
characterIds: ['vn-char-lin-yao'],
suggestedChoices: ['倒回录音最后十秒', '让林遥辨认声音', '检查明信片落款'],
},
],
opening: {
sceneId: 'vn-scene-platform',
narration: '雪落得很慢,像有人把整座车站的时间调低了一格。',
speakerCharacterId: 'vn-char-lin-yao',
firstDialogue: '你听见了吗?电台刚才念出了你的名字。',
initialChoices: [
{
choiceId: 'vn-choice-radio',
text: '靠近广播柜,确认频段来源。',
actionHint: 'investigate_radio',
},
{
choiceId: 'vn-choice-lin',
text: '先问林遥为什么认识这段广播。',
actionHint: 'ask_lin_yao',
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'template_config',
saveArchiveEnabled: true,
},
publishReady: false,
validationIssues: [
{
issueId: 'vn-issue-cover',
code: 'MISSING_COVER_IMAGE',
severity: 'warning',
path: 'coverImageSrc',
message: '封面待补齐。',
},
],
updatedAt: MOCK_NOW,
};
export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-mock-1',
ownerUserId: 'mock-user',
sourceMode: 'idea',
status: 'ready',
messages: [
{
id: 'vn-message-1',
role: 'user',
kind: 'chat',
text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
createdAt: MOCK_NOW,
},
{
id: 'vn-message-2',
role: 'assistant',
kind: 'summary',
text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。',
createdAt: MOCK_NOW,
},
],
draft: mockVisualNovelDraft,
pendingAction: {
actionId: 'vn-action-compile-mock',
kind: 'compile_work_profile',
label: '生成结果页',
},
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
};
function createVisualNovelDraftId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
export function createBlankVisualNovelDraft(
sourceMode: VisualNovelSourceMode = 'blank',
): VisualNovelResultDraft {
const draftId = createVisualNovelDraftId('vn-profile-local');
const sceneId = `${draftId}-scene-opening`;
const characterId = `${draftId}-char-main`;
const phaseId = `${draftId}-phase-opening`;
return {
profileId: draftId,
workTitle: '未命名视觉小说',
workDescription: '',
workTags: [],
coverImageSrc: null,
sourceMode,
sourceAssetIds: [],
world: {
title: '',
summary: '',
background: '',
premise: '',
literaryStyle: '',
playerRole: '',
defaultTone: '',
},
characters: [
{
characterId,
name: '',
gender: null,
role: 'main',
appearance: '',
personality: '',
tone: '',
background: '',
relationshipToPlayer: '',
imageAssets: [],
defaultExpression: null,
isPlayerVisible: false,
},
],
scenes: [
{
sceneId,
name: '',
description: '',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: [phaseId],
},
],
storyPhases: [
{
phaseId,
title: '',
goal: '',
summary: '',
entryCondition: '开场进入',
exitCondition: '',
sceneIds: [sceneId],
characterIds: [characterId],
suggestedChoices: ['', ''],
},
],
opening: {
sceneId,
narration: '',
speakerCharacterId: characterId,
firstDialogue: '',
initialChoices: [
{
choiceId: `${draftId}-choice-1`,
text: '',
actionHint: null,
},
{
choiceId: `${draftId}-choice-2`,
text: '',
actionHint: null,
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'off',
saveArchiveEnabled: true,
},
publishReady: false,
validationIssues: [],
updatedAt: new Date().toISOString(),
};
}
export function createMockVisualNovelSessionFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelAgentSessionSnapshot {
const now = new Date().toISOString();
return {
sessionId: createVisualNovelDraftId('vn-session-local'),
ownerUserId: 'local-user',
sourceMode: draft.sourceMode,
status: 'ready',
messages: [
{
id: createVisualNovelDraftId('vn-message-local'),
role: 'assistant',
kind: 'summary',
text: '已创建可编辑底稿。',
createdAt: now,
},
],
draft,
pendingAction: {
actionId: createVisualNovelDraftId('vn-action-local'),
kind: 'compile_work_profile',
label: '保存草稿',
},
createdAt: now,
updatedAt: now,
};
}
export function createMockVisualNovelRunFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelRunSnapshot {
const now = new Date().toISOString();
const openingSceneId = draft.opening.sceneId ?? draft.scenes[0]?.sceneId ?? null;
const firstPhase = draft.storyPhases.find((phase) =>
openingSceneId ? phase.sceneIds.includes(openingSceneId) : false,
);
const speaker = draft.opening.speakerCharacterId
? draft.characters.find(
(character) =>
character.characterId === draft.opening.speakerCharacterId,
)
: null;
return {
runId: createVisualNovelDraftId('vn-run-local'),
ownerUserId: 'local-user',
profileId: draft.profileId ?? createVisualNovelDraftId('vn-profile-local'),
mode: 'test',
status: 'active',
currentSceneId: openingSceneId,
currentPhaseId: firstPhase?.phaseId ?? draft.storyPhases[0]?.phaseId ?? null,
visibleCharacterIds: draft.characters
.filter((character) => character.role !== 'protagonist')
.map((character) => character.characterId)
.slice(0, 2),
flags: {},
metrics: {},
history: [
{
entryId: createVisualNovelDraftId('vn-history-local'),
runId: 'local-test-run',
turnIndex: 1,
source: 'assistant',
actionText: null,
steps: [
...(openingSceneId
? [
{
type: 'scene_change' as const,
sceneId: openingSceneId,
backgroundImageSrc:
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
?.backgroundImageSrc ?? null,
musicSrc:
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
?.musicSrc ?? null,
},
]
: []),
{
type: 'narration' as const,
text: draft.opening.narration || draft.workDescription || draft.world.summary,
},
...(speaker && draft.opening.firstDialogue
? [
{
type: 'dialogue' as const,
characterId: speaker.characterId,
characterName: speaker.name,
expression: speaker.defaultExpression,
text: draft.opening.firstDialogue,
},
]
: []),
],
snapshotBeforeHash: null,
snapshotAfterHash: 'local-test-snapshot',
createdAt: now,
},
],
availableChoices: draft.opening.initialChoices.filter((choice) =>
choice.text.trim(),
),
textModeEnabled: draft.runtimeConfig.defaultTextMode,
createdAt: now,
updatedAt: now,
};
}
export const mockVisualNovelRun: VisualNovelRunSnapshot = {
runId: 'vn-run-mock-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
mode: 'test',
status: 'active',
currentSceneId: 'vn-scene-platform',
currentPhaseId: 'vn-phase-1',
visibleCharacterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
flags: {
radioUnlocked: false,
conductorTrust: 1,
},
metrics: {
tension: 42,
trust: 18,
},
history: [
{
entryId: 'vn-history-1',
runId: 'vn-run-mock-1',
turnIndex: 1,
source: 'assistant',
actionText: null,
steps: [
{
type: 'scene_change',
sceneId: 'vn-scene-platform',
backgroundImageSrc: null,
musicSrc: null,
},
{
type: 'narration',
text: '雪落得很慢,站台灯把铁轨照成两道苍白的线。',
},
{
type: 'dialogue',
characterId: 'vn-char-lin-yao',
characterName: '林遥',
expression: 'calm',
text: '你听见了吗?电台刚才念出了你的名字。',
},
],
snapshotBeforeHash: null,
snapshotAfterHash: 'mock-hash-1',
createdAt: MOCK_NOW,
},
{
entryId: 'vn-history-2',
runId: 'vn-run-mock-1',
turnIndex: 2,
source: 'player',
actionText:
'靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。',
steps: [],
snapshotBeforeHash: 'mock-hash-1',
snapshotAfterHash: 'mock-hash-2',
createdAt: MOCK_NOW,
},
],
availableChoices: mockVisualNovelDraft.opening.initialChoices,
textModeEnabled: true,
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
};