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