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
+4 -4
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>
@@ -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[] = []) {
@@ -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;
@@ -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>
);
}
@@ -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;
@@ -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>
);
}
@@ -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,
}),
);
});
@@ -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);
}
@@ -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();
});
@@ -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;
@@ -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);
});
File diff suppressed because it is too large Load Diff
@@ -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();
@@ -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>
);
@@ -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}
@@ -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();
});
@@ -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,
@@ -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
@@ -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';
}
@@ -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']);
});
@@ -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,
}));
@@ -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'
@@ -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,
);
});
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More