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[] = []) {
|
||||
|
||||
423
src/components/creative-agent/CreativeAgentHome.tsx
Normal file
423
src/components/creative-agent/CreativeAgentHome.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import {
|
||||
Bell,
|
||||
Bookmark,
|
||||
ChevronRight,
|
||||
Gamepad2,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
Moon,
|
||||
Music,
|
||||
PanelLeftClose,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { CreativeAgentInputPart } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf';
|
||||
import { RpgEntryBrandLogo } from '../rpg-entry/RpgEntryBrandLogo';
|
||||
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
|
||||
import { createCreativeAgentClientMessageId } from './creativeAgentViewModel';
|
||||
|
||||
type CreativeAgentHomePrompt = {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
icon: typeof Sparkles;
|
||||
tone: 'cool' | 'green' | 'warm' | 'purple' | 'rose';
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
export type CreativeAgentHistoryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
groupLabel: string;
|
||||
source: CreationWorkShelfItem;
|
||||
};
|
||||
|
||||
type CreativeAgentHomeProps = {
|
||||
recentItems: CreativeAgentHistoryItem[];
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onStartNewChat: () => void;
|
||||
onOpenHistoryItem: (item: CreationWorkShelfItem) => void;
|
||||
onOpenDrafts: () => void;
|
||||
onOpenAccount: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSubmitMessage: (payload: {
|
||||
clientMessageId: string;
|
||||
content: CreativeAgentInputPart[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const PROMPT_SUGGESTIONS: CreativeAgentHomePrompt[] = [
|
||||
{
|
||||
id: 'identity',
|
||||
label: '你是谁',
|
||||
prompt: '介绍一下你能帮我创作什么。',
|
||||
icon: Sparkles,
|
||||
tone: 'cool',
|
||||
},
|
||||
{
|
||||
id: 'flash-app',
|
||||
label: '一句话生成闪应用',
|
||||
prompt: '帮我把一个灵感做成可互动的小应用。',
|
||||
icon: Moon,
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
id: 'mini-game',
|
||||
label: '捏个小游戏',
|
||||
prompt: '帮我做一个适合马上玩的创意小游戏。',
|
||||
icon: Gamepad2,
|
||||
tone: 'warm',
|
||||
},
|
||||
{
|
||||
id: 'world-model',
|
||||
label: '体验世界模型',
|
||||
prompt: '用一个世界设定帮我生成可体验的互动内容。',
|
||||
icon: Bookmark,
|
||||
tone: 'purple',
|
||||
badge: 'Beta',
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
label: '音乐扭蛋',
|
||||
prompt: '把一段音乐灵感做成互动拼图。',
|
||||
icon: Music,
|
||||
tone: 'rose',
|
||||
},
|
||||
];
|
||||
|
||||
function buildCreativeHomeInputParts(payload: {
|
||||
text: string;
|
||||
image: { imageUrl: string; thumbnailUrl: string } | null;
|
||||
}): CreativeAgentInputPart[] {
|
||||
const content: CreativeAgentInputPart[] = [];
|
||||
if (payload.text) {
|
||||
content.push({
|
||||
type: 'input_text',
|
||||
text: payload.text,
|
||||
});
|
||||
}
|
||||
if (payload.image) {
|
||||
content.push({
|
||||
type: 'input_image',
|
||||
imageUrl: payload.image.imageUrl,
|
||||
thumbnailUrl: payload.image.thumbnailUrl,
|
||||
assetId: null,
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function groupRecentItemsByLabel(items: CreativeAgentHistoryItem[]) {
|
||||
const groups: Array<{ label: string; items: CreativeAgentHistoryItem[] }> = [];
|
||||
for (const item of items) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (lastGroup?.label === item.groupLabel) {
|
||||
lastGroup.items.push(item);
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
label: item.groupLabel,
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CreativeAgentPromptButton({
|
||||
item,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
item: CreativeAgentHomePrompt;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`creative-agent-home__prompt creative-agent-home__prompt--${item.tone}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.badge ? (
|
||||
<span className="creative-agent-home__prompt-badge">{item.badge}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreativeAgentDrawer({
|
||||
open,
|
||||
recentItems,
|
||||
onClose,
|
||||
onStartNewChat,
|
||||
onOpenHistoryItem,
|
||||
onOpenDrafts,
|
||||
onOpenAccount,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
open: boolean;
|
||||
recentItems: CreativeAgentHistoryItem[];
|
||||
onClose: () => void;
|
||||
onStartNewChat: () => void;
|
||||
onOpenHistoryItem: (item: CreationWorkShelfItem) => void;
|
||||
onOpenDrafts: () => void;
|
||||
onOpenAccount: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}) {
|
||||
const groupedItems = useMemo(
|
||||
() => groupRecentItemsByLabel(recentItems),
|
||||
[recentItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`creative-agent-drawer-backdrop ${open ? 'creative-agent-drawer-backdrop--open' : ''}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={`creative-agent-drawer ${open ? 'creative-agent-drawer--open' : ''}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 px-5 pb-5 pt-[max(1.1rem,env(safe-area-inset-top))]">
|
||||
<RpgEntryBrandLogo decorative />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-icon-button"
|
||||
aria-label="关闭侧边栏"
|
||||
title="关闭"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="shrink-0 space-y-3 px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onStartNewChat();
|
||||
onClose();
|
||||
}}
|
||||
className="creative-agent-drawer__new-chat"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<span>开启新对话</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenDrafts();
|
||||
onClose();
|
||||
}}
|
||||
className="creative-agent-drawer__nav-row"
|
||||
>
|
||||
<Bookmark className="h-5 w-5" />
|
||||
<span>我的创作</span>
|
||||
<ChevronRight className="ml-auto h-4 w-4 opacity-55" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
||||
{groupedItems.length > 0 ? (
|
||||
groupedItems.map((group) => (
|
||||
<section key={group.label} className="mb-6">
|
||||
<div className="creative-agent-drawer__group-label">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenHistoryItem(item.source);
|
||||
onClose();
|
||||
}}
|
||||
className="creative-agent-drawer__history-item"
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="creative-agent-drawer__empty">暂无创作记录</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 px-5 py-5 pb-[max(1.15rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenAccount();
|
||||
onClose();
|
||||
}}
|
||||
className="creative-agent-drawer__avatar"
|
||||
aria-label="账号"
|
||||
>
|
||||
<UserRound className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenSettings();
|
||||
onClose();
|
||||
}}
|
||||
className="platform-icon-button"
|
||||
aria-label="外观"
|
||||
title="外观"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenSettings();
|
||||
onClose();
|
||||
}}
|
||||
className="platform-icon-button"
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreativeAgentHome({
|
||||
recentItems,
|
||||
isBusy,
|
||||
error,
|
||||
onStartNewChat,
|
||||
onOpenHistoryItem,
|
||||
onOpenDrafts,
|
||||
onOpenAccount,
|
||||
onOpenSettings,
|
||||
onSubmitMessage,
|
||||
}: CreativeAgentHomeProps) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const submitText = (text: string) => {
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText || isBusy) {
|
||||
return;
|
||||
}
|
||||
onSubmitMessage({
|
||||
clientMessageId: createCreativeAgentClientMessageId(),
|
||||
content: [
|
||||
{
|
||||
type: 'input_text',
|
||||
text: trimmedText,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="creative-agent-home platform-remap-surface">
|
||||
<div className="creative-agent-home__backdrop" />
|
||||
<header className="creative-agent-home__topbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="creative-agent-home__topbar-button"
|
||||
aria-label="打开侧边栏"
|
||||
title="菜单"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<RpgEntryBrandLogo className="creative-agent-home__brand" decorative />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAccount}
|
||||
className="creative-agent-home__topbar-button"
|
||||
aria-label="通知与账户"
|
||||
title="通知与账户"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="creative-agent-home__main">
|
||||
<div className="creative-agent-home__hero">
|
||||
<h1>Hi, 朋友</h1>
|
||||
<p>让复杂,变简单</p>
|
||||
</div>
|
||||
|
||||
<div className="creative-agent-home__prompt-grid">
|
||||
{PROMPT_SUGGESTIONS.map((item) => (
|
||||
<CreativeAgentPromptButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => submitText(item.prompt)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="creative-agent-home__reward"
|
||||
disabled={isBusy}
|
||||
onClick={() => submitText('帮我做一个能马上分享的创意拼图。')}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
<span>搓闪应用 分1亿激励</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="creative-agent-home__error">{error}</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<div className="creative-agent-home__composer">
|
||||
<CreativeAgentInputComposer
|
||||
variant="floating"
|
||||
isBusy={isBusy}
|
||||
placeholder="问一问百梦"
|
||||
onSubmit={(payload) => {
|
||||
const content = buildCreativeHomeInputParts(payload);
|
||||
if (content.length === 0) {
|
||||
return;
|
||||
}
|
||||
onSubmitMessage({
|
||||
clientMessageId: createCreativeAgentClientMessageId(),
|
||||
content,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreativeAgentDrawer
|
||||
open={drawerOpen}
|
||||
recentItems={recentItems}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onStartNewChat={onStartNewChat}
|
||||
onOpenHistoryItem={onOpenHistoryItem}
|
||||
onOpenDrafts={onOpenDrafts}
|
||||
onOpenAccount={onOpenAccount}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeAgentHome;
|
||||
160
src/components/creative-agent/CreativeAgentInputComposer.tsx
Normal file
160
src/components/creative-agent/CreativeAgentInputComposer.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ArrowUp, ImagePlus, Loader2, Plus, X } from 'lucide-react';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
|
||||
export type CreativeAgentComposerImage = {
|
||||
imageUrl: string;
|
||||
thumbnailUrl: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type CreativeAgentInputComposerProps = {
|
||||
isBusy: boolean;
|
||||
variant?: 'panel' | 'floating';
|
||||
placeholder?: string;
|
||||
onSubmit: (payload: {
|
||||
text: string;
|
||||
image: CreativeAgentComposerImage | null;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function CreativeAgentInputComposer({
|
||||
isBusy,
|
||||
variant = 'panel',
|
||||
placeholder = '想做成什么拼图?',
|
||||
onSubmit,
|
||||
}: CreativeAgentInputComposerProps) {
|
||||
const [text, setText] = useState('');
|
||||
const [image, setImage] = useState<CreativeAgentComposerImage | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const canSubmit = !isBusy && Boolean(text.trim() || image);
|
||||
|
||||
const handleImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
setImage({
|
||||
imageUrl: dataUrl,
|
||||
thumbnailUrl: dataUrl,
|
||||
label: file.name.trim() || '参考图',
|
||||
});
|
||||
setImageError(null);
|
||||
} catch (error) {
|
||||
setImageError(
|
||||
error instanceof Error ? error.message : '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
text: text.trim(),
|
||||
image,
|
||||
});
|
||||
setText('');
|
||||
setImage(null);
|
||||
setImageError(null);
|
||||
};
|
||||
|
||||
const floating = variant === 'floating';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
floating
|
||||
? 'creative-agent-composer creative-agent-composer--floating'
|
||||
: 'platform-subpanel rounded-[1.35rem] p-3 sm:p-4'
|
||||
}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<label
|
||||
className={`platform-icon-button h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''} ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
title={image ? '更换参考图' : '添加参考图'}
|
||||
>
|
||||
{floating ? (
|
||||
<Plus className="h-5 w-5" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{image ? '更换参考图' : '添加参考图'}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
void handleImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
disabled={isBusy}
|
||||
rows={2}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder={placeholder}
|
||||
aria-label="智能创作输入"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
className="platform-icon-button h-11 w-11 shrink-0"
|
||||
aria-label="发送"
|
||||
title="发送"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{image ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-2">
|
||||
<img
|
||||
src={image.thumbnailUrl}
|
||||
alt="创作参考图"
|
||||
className="h-12 w-12 rounded-[0.8rem] object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{image.label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setImage(null)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{imageError ? (
|
||||
<div className="mt-2 text-xs leading-5 text-red-600">{imageError}</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
119
src/components/creative-agent/CreativeAgentProcessPanel.tsx
Normal file
119
src/components/creative-agent/CreativeAgentProcessPanel.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock3,
|
||||
Loader2,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { CreativeAgentProcessItem } from './creativeAgentViewModel';
|
||||
|
||||
type CreativeAgentProcessPanelProps = {
|
||||
items: CreativeAgentProcessItem[];
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const PROCESS_TONE_CLASS: Record<CreativeAgentProcessItem['tone'], string> = {
|
||||
active: 'border-[rgba(255,105,145,0.38)] bg-white/82',
|
||||
done: 'border-emerald-200/80 bg-emerald-50/82',
|
||||
info: 'border-[var(--platform-subpanel-border)] bg-white/68',
|
||||
warning: 'border-amber-200/80 bg-amber-50/82',
|
||||
danger: 'border-red-200/80 bg-red-50/86',
|
||||
};
|
||||
|
||||
function ProcessIcon({ item }: { item: CreativeAgentProcessItem }) {
|
||||
if (item.tone === 'active') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin" />;
|
||||
}
|
||||
if (item.tone === 'done') {
|
||||
return <CheckCircle2 className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (item.tone === 'warning' || item.tone === 'danger') {
|
||||
return <TriangleAlert className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return <CircleDot className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
export function CreativeAgentProcessPanel({
|
||||
items,
|
||||
isStreaming,
|
||||
}: CreativeAgentProcessPanelProps) {
|
||||
const visibleItems = items.slice(-12).reverse();
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
过程
|
||||
</div>
|
||||
<Clock3 className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-base)]">
|
||||
等待新的创作输入
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
过程
|
||||
</div>
|
||||
<div className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/62 px-2.5 py-1 text-[11px] font-bold text-[var(--platform-text-base)]">
|
||||
{items.length} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[22rem] space-y-2 overflow-y-auto pr-1">
|
||||
{visibleItems.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-[1rem] border px-3 py-3 ${PROCESS_TONE_CLASS[item.tone]}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/82 text-[var(--platform-text-strong)] shadow-sm">
|
||||
<ProcessIcon item={item} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-white/72 px-2 py-0.5 text-[11px] font-black text-[var(--platform-text-soft)]">
|
||||
{item.meta}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-sm font-black leading-5 text-[var(--platform-text-strong)]">
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
{item.detail ? (
|
||||
<div className="mt-1 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{item.detail}
|
||||
</div>
|
||||
) : null}
|
||||
{item.detailLines.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.detailLines.map((line, index) => (
|
||||
<div
|
||||
key={`${item.id}-line-${index}`}
|
||||
className="truncate rounded-[0.7rem] bg-white/58 px-2 py-1 text-[11px] font-semibold leading-4 text-[var(--platform-text-base)]"
|
||||
title={line}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeAgentProcessPanel;
|
||||
62
src/components/creative-agent/CreativeAgentStageTimeline.tsx
Normal file
62
src/components/creative-agent/CreativeAgentStageTimeline.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { CreativeAgentStage } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import {
|
||||
CREATIVE_AGENT_TIMELINE,
|
||||
getCreativeAgentStageDisplayLabel,
|
||||
} from './creativeAgentViewModel';
|
||||
|
||||
type CreativeAgentStageTimelineProps = {
|
||||
stage: CreativeAgentStage;
|
||||
};
|
||||
|
||||
export function CreativeAgentStageTimeline({
|
||||
stage,
|
||||
}: CreativeAgentStageTimelineProps) {
|
||||
const activeIndex = CREATIVE_AGENT_TIMELINE.indexOf(stage);
|
||||
const safeActiveIndex =
|
||||
activeIndex >= 0
|
||||
? activeIndex
|
||||
: stage === 'waiting_template_confirmation'
|
||||
? CREATIVE_AGENT_TIMELINE.indexOf('selecting_puzzle_template') + 1
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-3 gap-2 sm:grid-cols-5 xl:grid-cols-9"
|
||||
aria-label="智能创作阶段"
|
||||
>
|
||||
{CREATIVE_AGENT_TIMELINE.map((item, index) => {
|
||||
const isActive = item === stage && stage !== 'target_ready';
|
||||
const isDone = safeActiveIndex > index || stage === 'target_ready';
|
||||
const label = getCreativeAgentStageDisplayLabel(
|
||||
item,
|
||||
isActive ? 'active' : isDone ? 'done' : 'idle',
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item}
|
||||
className={`flex min-h-[3.5rem] items-center gap-2 rounded-[1rem] border px-3 py-2 text-xs font-bold ${
|
||||
isActive
|
||||
? 'border-[var(--platform-button-primary-border)] bg-white/90 text-[var(--platform-text-strong)] shadow-sm'
|
||||
: isDone
|
||||
? 'border-emerald-200/70 bg-emerald-50/82 text-emerald-700'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/46 text-[var(--platform-text-soft)]'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/82">
|
||||
{isActive ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : isDone ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="leading-4">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
249
src/components/creative-agent/CreativeAgentWorkspace.test.tsx
Normal file
249
src/components/creative-agent/CreativeAgentWorkspace.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CreativeAgentSessionSnapshot,
|
||||
CreativeAgentSseEvent,
|
||||
CreativeAgentStage,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { PuzzleCreativeTemplateProtocol } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
|
||||
import { resolveCreativeAgentTargetSelectionStage } from './creativeAgentViewModel';
|
||||
import { CreativeAgentWorkspace } from './CreativeAgentWorkspace';
|
||||
|
||||
function createTemplate(
|
||||
overrides: Partial<PuzzleCreativeTemplateProtocol> = {},
|
||||
): PuzzleCreativeTemplateProtocol {
|
||||
return {
|
||||
templateId: 'puzzle.default-creative',
|
||||
title: '创意拼图',
|
||||
summary: '把图文灵感做成拼图。',
|
||||
previewImageSrc: null,
|
||||
supportedLevelMode: 'single_or_multi',
|
||||
minLevelCount: 1,
|
||||
maxLevelCount: 6,
|
||||
defaultLevelCount: 1,
|
||||
costRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
requiredDraftFields: ['workTitle'],
|
||||
imagePolicy: {
|
||||
allowUploadedImageDirectly: true,
|
||||
allowGeneratedImages: true,
|
||||
allowPerLevelReferenceImage: true,
|
||||
defaultCandidateCountPerLevel: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<CreativeAgentSessionSnapshot> = {},
|
||||
): CreativeAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'target_ready',
|
||||
inputSummary: {
|
||||
text: '做一个生日拼图',
|
||||
entryContext: 'creation_home',
|
||||
images: [],
|
||||
materialSummary: '做一个生日拼图',
|
||||
unsupportedCapabilities: [
|
||||
{
|
||||
playType: 'rpg',
|
||||
title: 'RPG',
|
||||
status: 'unsupported',
|
||||
reason: 'Phase 1 暂不开放',
|
||||
},
|
||||
],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '拼图草稿已准备好。',
|
||||
createdAt: '2026-05-05T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
puzzleTemplateCatalog: [],
|
||||
puzzleTemplateSelection: null,
|
||||
puzzleImageGenerationPlan: null,
|
||||
targetBinding: {
|
||||
playType: 'puzzle',
|
||||
targetSessionId: 'puzzle-session-1',
|
||||
targetStage: 'puzzle-result',
|
||||
resultProfileId: 'puzzle-profile-1',
|
||||
},
|
||||
updatedAt: '2026-05-05T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('target ready session exposes the puzzle result entry action', () => {
|
||||
const onOpenTarget = vi.fn();
|
||||
const eventLog: CreativeAgentSseEvent[] = [
|
||||
{
|
||||
event: 'puzzle_cost_range',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
costRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<CreativeAgentWorkspace
|
||||
session={createSession()}
|
||||
isBusy={false}
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
eventLog={eventLog}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onConfirmTemplate={() => {}}
|
||||
onCancelTemplate={() => {}}
|
||||
onOpenTarget={onOpenTarget}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('拼图草稿已就绪')).toBeTruthy();
|
||||
expect(screen.getByText('可以进入结果页继续编辑')).toBeTruthy();
|
||||
expect(screen.getByText('预计 2-12 光点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开草稿' }));
|
||||
|
||||
expect(onOpenTarget).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('waiting confirmation shows template catalog before template config dialog', () => {
|
||||
const onConfirmTemplate = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeAgentWorkspace
|
||||
session={createSession({
|
||||
stage: 'waiting_template_confirmation',
|
||||
targetBinding: null,
|
||||
puzzleTemplateCatalog: [
|
||||
createTemplate(),
|
||||
createTemplate({
|
||||
templateId: 'puzzle.travel-memory',
|
||||
title: '旅行记忆拼图',
|
||||
summary: '把一次出行拆成地点、风景和故事节点拼图。',
|
||||
defaultLevelCount: 3,
|
||||
costRange: {
|
||||
minPoints: 4,
|
||||
maxPoints: 16,
|
||||
pricingUnit: 'point',
|
||||
reason: '按旅行节点估算',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})}
|
||||
isBusy={false}
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
eventLog={[]}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onConfirmTemplate={onConfirmTemplate}
|
||||
onOpenTarget={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /创意拼图/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /旅行记忆拼图/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /旅行记忆拼图/u }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
|
||||
expect(screen.getByText('预计 4 到 16 光点')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /确认/u }));
|
||||
|
||||
expect(onConfirmTemplate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
templateId: 'puzzle.travel-memory',
|
||||
selectedLevelMode: 'multi_level',
|
||||
plannedLevelCount: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('switching creative session clears pending template config dialog', () => {
|
||||
const firstSession = createSession({
|
||||
sessionId: 'creative-session-first',
|
||||
stage: 'waiting_template_confirmation',
|
||||
targetBinding: null,
|
||||
puzzleTemplateCatalog: [createTemplate()],
|
||||
});
|
||||
const secondSession = createSession({
|
||||
sessionId: 'creative-session-second',
|
||||
stage: 'waiting_template_confirmation',
|
||||
targetBinding: null,
|
||||
puzzleTemplateCatalog: [],
|
||||
});
|
||||
const { rerender } = render(
|
||||
<CreativeAgentWorkspace
|
||||
session={firstSession}
|
||||
isBusy={false}
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
eventLog={[]}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onConfirmTemplate={() => {}}
|
||||
onOpenTarget={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /创意拼图/u }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<CreativeAgentWorkspace
|
||||
session={secondSession}
|
||||
isBusy={false}
|
||||
isStreaming={false}
|
||||
error={null}
|
||||
eventLog={[]}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onConfirmTemplate={() => {}}
|
||||
onOpenTarget={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
|
||||
});
|
||||
|
||||
test('target ready puzzle result binding resolves to puzzle-result stage', () => {
|
||||
expect(resolveCreativeAgentTargetSelectionStage('puzzle-result')).toBe(
|
||||
'puzzle-result',
|
||||
);
|
||||
expect(
|
||||
resolveCreativeAgentTargetSelectionStage('puzzle-agent-workspace'),
|
||||
).toBe('puzzle-agent-workspace');
|
||||
});
|
||||
|
||||
test('target ready timeline renders completed labels instead of active labels', () => {
|
||||
render(<CreativeAgentStageTimeline stage={'target_ready' as CreativeAgentStage} />);
|
||||
|
||||
expect(screen.getByText('素材已理解')).toBeTruthy();
|
||||
expect(screen.getByText('构思已完成')).toBeTruthy();
|
||||
expect(screen.getByText('草稿已生成')).toBeTruthy();
|
||||
expect(screen.queryByText('正在理解素材')).toBeNull();
|
||||
expect(screen.queryByText('正在构思')).toBeNull();
|
||||
});
|
||||
313
src/components/creative-agent/CreativeAgentWorkspace.tsx
Normal file
313
src/components/creative-agent/CreativeAgentWorkspace.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { ArrowLeft, CheckCircle2, Puzzle } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CreativeAgentInputPart,
|
||||
CreativeAgentSessionSnapshot,
|
||||
CreativeAgentSseEvent,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
PuzzleCreativeTemplateProtocol,
|
||||
PuzzleCreativeTemplateSelection,
|
||||
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
|
||||
import { CreativeAgentProcessPanel } from './CreativeAgentProcessPanel';
|
||||
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
|
||||
import { CreativeAgentTemplateConfirmPanel } from './CreativeAgentTemplateConfirmPanel';
|
||||
import {
|
||||
buildCreativeAgentProcessItems,
|
||||
buildPuzzleTemplateSelectionFromProtocol,
|
||||
createCreativeAgentClientMessageId,
|
||||
CREATIVE_AGENT_STAGE_LABEL,
|
||||
} from './creativeAgentViewModel';
|
||||
|
||||
type CreativeAgentWorkspaceProps = {
|
||||
session: CreativeAgentSessionSnapshot | null;
|
||||
isBusy: boolean;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
eventLog: CreativeAgentSseEvent[];
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: {
|
||||
clientMessageId: string;
|
||||
content: CreativeAgentInputPart[];
|
||||
}) => void;
|
||||
onConfirmTemplate: (selection: PuzzleCreativeTemplateSelection) => void;
|
||||
onCancelTemplate?: () => void;
|
||||
onOpenTarget: () => void;
|
||||
};
|
||||
|
||||
type CreativeAgentTemplateCatalogPanelProps = {
|
||||
templates: PuzzleCreativeTemplateProtocol[];
|
||||
isBusy: boolean;
|
||||
onSelect: (template: PuzzleCreativeTemplateProtocol) => void;
|
||||
};
|
||||
|
||||
function CreativeAgentTemplateCatalogPanel({
|
||||
templates,
|
||||
isBusy,
|
||||
onSelect,
|
||||
}: CreativeAgentTemplateCatalogPanelProps) {
|
||||
if (templates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.templateId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onSelect(template)}
|
||||
className="group min-h-[10.5rem] rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/88 disabled:opacity-55"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[0.95rem] border border-white/70 bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
|
||||
<div className="flex aspect-[16/9] items-center justify-center">
|
||||
{template.previewImageSrc ? (
|
||||
<img
|
||||
src={template.previewImageSrc}
|
||||
alt={template.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Puzzle className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{template.title}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{template.summary}
|
||||
</div>
|
||||
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 光点`}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreativeAgentWorkspace({
|
||||
session,
|
||||
isBusy,
|
||||
isStreaming,
|
||||
error,
|
||||
eventLog,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onConfirmTemplate,
|
||||
onCancelTemplate,
|
||||
onOpenTarget,
|
||||
}: CreativeAgentWorkspaceProps) {
|
||||
const stage = session?.stage ?? 'idle';
|
||||
const messages = session?.messages ?? [];
|
||||
const selection = session?.puzzleTemplateSelection ?? null;
|
||||
const templateCatalog = session?.puzzleTemplateCatalog ?? [];
|
||||
const targetBinding = session?.targetBinding ?? null;
|
||||
const [pendingSelection, setPendingSelection] =
|
||||
useState<PuzzleCreativeTemplateSelection | null>(null);
|
||||
useEffect(() => {
|
||||
// 中文注释:会话切换时清掉本地待确认模板,避免上一轮选择残留到新会话。
|
||||
setPendingSelection(null);
|
||||
}, [session?.sessionId]);
|
||||
const processItems = useMemo(
|
||||
() => buildCreativeAgentProcessItems(eventLog, session),
|
||||
[eventLog, session],
|
||||
);
|
||||
const visibleSelection = targetBinding ? null : (selection ?? pendingSelection);
|
||||
const shouldShowTemplateCatalog =
|
||||
!targetBinding &&
|
||||
!selection &&
|
||||
templateCatalog.length > 0 &&
|
||||
stage === 'waiting_template_confirmation';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)] xl:px-1">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
<div className="platform-pill platform-pill--cool px-3 text-[11px]">
|
||||
{CREATIVE_AGENT_STAGE_LABEL[stage]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="space-y-4 pb-4">
|
||||
<section className="platform-surface platform-surface--hero relative overflow-hidden rounded-[1.6rem] px-4 py-5 sm:px-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-2xl font-black leading-tight text-white sm:text-3xl">
|
||||
智能创作
|
||||
</div>
|
||||
<div className="mt-2 max-w-xl text-sm font-semibold leading-6 text-zinc-100/86">
|
||||
当前生成拼图草稿
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/18 text-white sm:inline-flex">
|
||||
<Puzzle className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreativeAgentStageTimeline stage={stage} />
|
||||
|
||||
{targetBinding ? (
|
||||
<section className="platform-subpanel flex flex-col gap-3 rounded-[1.35rem] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||||
拼图草稿已就绪
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
{targetBinding.targetStage === 'puzzle-result'
|
||||
? '可以进入结果页继续编辑'
|
||||
: '可以进入拼图工作区继续处理'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onOpenTarget}
|
||||
className="platform-button platform-button--primary"
|
||||
>
|
||||
打开草稿
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{messages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`max-w-[86%] rounded-[1.15rem] px-4 py-3 text-sm leading-6 ${
|
||||
message.role === 'user'
|
||||
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
|
||||
: 'platform-subpanel text-[var(--platform-text-base)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-subpanel rounded-[1.35rem] p-4 text-sm font-semibold text-[var(--platform-text-base)]">
|
||||
发一句想法,或加一张参考图。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreativeAgentProcessPanel
|
||||
items={processItems}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
{shouldShowTemplateCatalog ? (
|
||||
<CreativeAgentTemplateCatalogPanel
|
||||
templates={templateCatalog}
|
||||
isBusy={isBusy || isStreaming}
|
||||
onSelect={(template) => {
|
||||
setPendingSelection(
|
||||
buildPuzzleTemplateSelectionFromProtocol(template),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{session?.puzzleImageGenerationPlan ? (
|
||||
<div className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
关卡计划
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{session.puzzleImageGenerationPlan.levels.map((level) => (
|
||||
<div
|
||||
key={level.levelId}
|
||||
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 px-3 py-3"
|
||||
>
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{level.levelName}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{level.pictureDescription}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<CreativeAgentInputComposer
|
||||
isBusy={isBusy || isStreaming}
|
||||
onSubmit={({ text, image }) => {
|
||||
const content: CreativeAgentInputPart[] = [];
|
||||
if (text) {
|
||||
content.push({
|
||||
type: 'input_text',
|
||||
text,
|
||||
});
|
||||
}
|
||||
if (image) {
|
||||
content.push({
|
||||
type: 'input_image',
|
||||
imageUrl: image.imageUrl,
|
||||
thumbnailUrl: image.thumbnailUrl,
|
||||
assetId: null,
|
||||
});
|
||||
}
|
||||
onSubmitMessage({
|
||||
clientMessageId: createCreativeAgentClientMessageId(),
|
||||
content,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visibleSelection && visibleSelection.requiresUserConfirmation ? (
|
||||
<CreativeAgentTemplateConfirmPanel
|
||||
selection={visibleSelection}
|
||||
isBusy={isBusy || isStreaming}
|
||||
onConfirm={(nextSelection) => {
|
||||
setPendingSelection(null);
|
||||
onConfirmTemplate(nextSelection);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setPendingSelection(null);
|
||||
onCancelTemplate?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeAgentWorkspace;
|
||||
320
src/components/creative-agent/creativeAgentViewModel.test.ts
Normal file
320
src/components/creative-agent/creativeAgentViewModel.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
CreativeAgentSessionSnapshot,
|
||||
CreativeAgentSseEvent,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import { buildCreativeAgentProcessItems } from './creativeAgentViewModel';
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<CreativeAgentSessionSnapshot> = {},
|
||||
): CreativeAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'target_ready',
|
||||
inputSummary: {
|
||||
text: '做一个生日拼图',
|
||||
entryContext: 'creation_home',
|
||||
images: [],
|
||||
materialSummary: '做一个生日拼图',
|
||||
unsupportedCapabilities: [],
|
||||
},
|
||||
messages: [],
|
||||
puzzleTemplateCatalog: [],
|
||||
puzzleTemplateSelection: null,
|
||||
puzzleImageGenerationPlan: null,
|
||||
targetBinding: {
|
||||
playType: 'puzzle',
|
||||
targetSessionId: 'puzzle-session-1',
|
||||
targetStage: 'puzzle-result',
|
||||
resultProfileId: 'puzzle-profile-1',
|
||||
},
|
||||
updatedAt: '2026-05-05T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildCreativeAgentProcessItems expands creative agent sse details', () => {
|
||||
const eventLog: CreativeAgentSseEvent[] = [
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'perceiving',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'thought_summary_delta',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
thoughtId: 'thought-1',
|
||||
textDelta: '正在理解生日素材,',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'thought_summary_delta',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
thoughtId: 'thought-1',
|
||||
textDelta: '并准备转成拼图关卡。',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'tool_started',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'retrieve_puzzle_template_catalog',
|
||||
summary: '读取拼图模板',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'tool_completed',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'retrieve_puzzle_template_catalog',
|
||||
summary: '已读取拼图模板',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'puzzle_template_catalog',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
templates: [
|
||||
{
|
||||
templateId: 'puzzle.default-creative',
|
||||
title: '创意拼图',
|
||||
summary: '把图文灵感做成拼图。',
|
||||
previewImageSrc: null,
|
||||
supportedLevelMode: 'single_or_multi',
|
||||
minLevelCount: 1,
|
||||
maxLevelCount: 6,
|
||||
defaultLevelCount: 1,
|
||||
costRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
requiredDraftFields: ['workTitle'],
|
||||
imagePolicy: {
|
||||
allowUploadedImageDirectly: true,
|
||||
allowGeneratedImages: true,
|
||||
allowPerLevelReferenceImage: true,
|
||||
defaultCandidateCountPerLevel: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'puzzle_template_selection',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
selection: {
|
||||
templateId: 'puzzle.default-creative',
|
||||
title: '创意拼图',
|
||||
reason: '适合把生日素材做成拼图。',
|
||||
costRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
supportedLevelMode: 'single_or_multi',
|
||||
selectedLevelMode: 'multi_level',
|
||||
plannedLevelCount: 3,
|
||||
requiresUserConfirmation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'puzzle_level_plan',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
plan: {
|
||||
mode: 'multi_level',
|
||||
templateId: 'puzzle.default-creative',
|
||||
estimatedCostRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '生日开场',
|
||||
pictureDescription: '蛋糕和礼物',
|
||||
imagePrompt: '蛋糕和礼物',
|
||||
pictureReference: null,
|
||||
candidateCount: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'done',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildCreativeAgentProcessItems(eventLog, createSession());
|
||||
|
||||
expect(items.map((item) => item.title)).toContain('素材已理解');
|
||||
expect(items.map((item) => item.title)).toContain('思考摘要');
|
||||
expect(items.find((item) => item.title === '思考摘要')?.detail).toBe(
|
||||
'正在理解生日素材,并准备转成拼图关卡。',
|
||||
);
|
||||
expect(items.find((item) => item.title === '思考摘要')?.tone).toBe('done');
|
||||
expect(items.map((item) => item.title)).toContain('开始:读取拼图模板');
|
||||
expect(items.find((item) => item.title === '开始:读取拼图模板')?.tone).toBe(
|
||||
'done',
|
||||
);
|
||||
expect(items.map((item) => item.title)).toContain('读取 1 个模板');
|
||||
expect(items.map((item) => item.title)).toContain('选择 创意拼图');
|
||||
expect(items.map((item) => item.title)).toContain('规划 1 个关卡');
|
||||
expect(items.at(-1)?.detailLines).toContain('目标会话:puzzle-session-1');
|
||||
expect(items.some((item) => item.tone === 'active')).toBe(false);
|
||||
});
|
||||
|
||||
test('buildCreativeAgentProcessItems only keeps current running stage active', () => {
|
||||
const eventLog: CreativeAgentSseEvent[] = [
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'perceiving',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'thinking',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildCreativeAgentProcessItems(
|
||||
eventLog,
|
||||
createSession({
|
||||
stage: 'thinking',
|
||||
targetBinding: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.title === '素材已理解')?.tone).toBe('done');
|
||||
expect(items.find((item) => item.title === '正在构思')?.tone).toBe('active');
|
||||
expect(items.filter((item) => item.tone === 'active')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('buildCreativeAgentProcessItems stops spinners after waiting confirmation', () => {
|
||||
const eventLog: CreativeAgentSseEvent[] = [
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'perceiving',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'selecting_puzzle_template',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'thought_summary_delta',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
thoughtId: 'thought-1',
|
||||
textDelta: '已经选择合适模板。',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'tool_started',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
toolCallId: 'tool-start',
|
||||
toolName: 'retrieve_puzzle_template_catalog',
|
||||
summary: '读取拼图模板',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'tool_completed',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
toolCallId: 'tool-done',
|
||||
toolName: 'retrieve_puzzle_template_catalog',
|
||||
summary: '已读取拼图模板',
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'stage',
|
||||
data: {
|
||||
sessionId: 'creative-session-1',
|
||||
stage: 'waiting_template_confirmation',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildCreativeAgentProcessItems(
|
||||
eventLog,
|
||||
createSession({
|
||||
stage: 'waiting_template_confirmation',
|
||||
targetBinding: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(items.find((item) => item.title === '思考摘要')?.tone).toBe('done');
|
||||
expect(items.find((item) => item.title === '开始:读取拼图模板')?.tone).toBe(
|
||||
'done',
|
||||
);
|
||||
expect(items.find((item) => item.title === '等待确认')?.tone).toBe('warning');
|
||||
expect(items.some((item) => item.tone === 'active')).toBe(false);
|
||||
});
|
||||
|
||||
test('buildCreativeAgentProcessItems falls back to session snapshots', () => {
|
||||
const session = createSession({
|
||||
puzzleTemplateSelection: {
|
||||
templateId: 'puzzle.default-creative',
|
||||
title: '创意拼图',
|
||||
reason: '适合拼图创作。',
|
||||
costRange: {
|
||||
minPoints: 2,
|
||||
maxPoints: 12,
|
||||
pricingUnit: 'point',
|
||||
reason: '按关卡数估算',
|
||||
},
|
||||
supportedLevelMode: 'single_or_multi',
|
||||
selectedLevelMode: 'single_level',
|
||||
plannedLevelCount: 1,
|
||||
requiresUserConfirmation: true,
|
||||
},
|
||||
});
|
||||
|
||||
const items = buildCreativeAgentProcessItems([], session);
|
||||
|
||||
expect(items.map((item) => item.title)).toContain('选择 创意拼图');
|
||||
expect(items.map((item) => item.title)).toContain('拼图草稿已绑定');
|
||||
});
|
||||
|
||||
test('buildCreativeAgentProcessItems renders waiting session fallback as static', () => {
|
||||
const items = buildCreativeAgentProcessItems(
|
||||
[],
|
||||
createSession({
|
||||
stage: 'waiting_template_confirmation',
|
||||
targetBinding: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.title)).toContain('等待确认');
|
||||
expect(items.find((item) => item.title === '等待确认')?.tone).toBe('warning');
|
||||
expect(items.some((item) => item.tone === 'active')).toBe(false);
|
||||
});
|
||||
582
src/components/creative-agent/creativeAgentViewModel.ts
Normal file
582
src/components/creative-agent/creativeAgentViewModel.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type {
|
||||
CreativeAgentSessionSnapshot,
|
||||
CreativeAgentSseEvent,
|
||||
CreativeAgentStage,
|
||||
CreativeTargetSessionBinding,
|
||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
PuzzleCreativeTemplateProtocol,
|
||||
PuzzleCreativeTemplateSelection,
|
||||
PuzzleLevelGenerationMode,
|
||||
PuzzleSupportedLevelMode,
|
||||
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||
|
||||
export const CREATIVE_AGENT_STAGE_LABEL: Record<CreativeAgentStage, string> = {
|
||||
idle: '等待输入',
|
||||
perceiving: '正在理解素材',
|
||||
thinking: '正在构思',
|
||||
remembering: '正在整理上下文',
|
||||
selecting_puzzle_template: '正在选择拼图模板',
|
||||
waiting_template_confirmation: '等待确认',
|
||||
planning_puzzle_levels: '正在规划关卡',
|
||||
acting: '正在生成草稿',
|
||||
reflecting: '正在检查结果',
|
||||
collaborating: '正在协作收口',
|
||||
target_ready: '草稿已就绪',
|
||||
waiting_user: '等待补充',
|
||||
failed: '生成失败',
|
||||
};
|
||||
|
||||
const CREATIVE_AGENT_STAGE_DONE_LABEL: Partial<
|
||||
Record<CreativeAgentStage, string>
|
||||
> = {
|
||||
perceiving: '素材已理解',
|
||||
thinking: '构思已完成',
|
||||
remembering: '上下文已整理',
|
||||
selecting_puzzle_template: '模板已选择',
|
||||
planning_puzzle_levels: '关卡已规划',
|
||||
acting: '草稿已生成',
|
||||
reflecting: '结果已检查',
|
||||
collaborating: '协作已收口',
|
||||
target_ready: '草稿已就绪',
|
||||
};
|
||||
|
||||
const CREATIVE_AGENT_STAGE_WAITING_LABEL: Partial<
|
||||
Record<CreativeAgentStage, string>
|
||||
> = {
|
||||
idle: '等待输入',
|
||||
waiting_template_confirmation: '等待确认',
|
||||
waiting_user: '等待补充',
|
||||
failed: '生成失败',
|
||||
};
|
||||
|
||||
const CREATIVE_AGENT_STAGE_IDLE_LABEL: Record<CreativeAgentStage, string> = {
|
||||
idle: '等待输入',
|
||||
perceiving: '理解素材',
|
||||
thinking: '构思方向',
|
||||
remembering: '整理上下文',
|
||||
selecting_puzzle_template: '选择拼图模板',
|
||||
waiting_template_confirmation: '等待确认',
|
||||
planning_puzzle_levels: '规划关卡',
|
||||
acting: '生成草稿',
|
||||
reflecting: '检查结果',
|
||||
collaborating: '协作收口',
|
||||
target_ready: '草稿就绪',
|
||||
waiting_user: '等待补充',
|
||||
failed: '生成失败',
|
||||
};
|
||||
|
||||
export const CREATIVE_AGENT_TIMELINE: CreativeAgentStage[] = [
|
||||
'perceiving',
|
||||
'thinking',
|
||||
'remembering',
|
||||
'selecting_puzzle_template',
|
||||
'planning_puzzle_levels',
|
||||
'acting',
|
||||
'reflecting',
|
||||
'collaborating',
|
||||
'target_ready',
|
||||
];
|
||||
|
||||
export type CreativeAgentTargetSelectionStage =
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-result';
|
||||
|
||||
export function resolveCreativeAgentTargetSelectionStage(
|
||||
targetStage: CreativeTargetSessionBinding['targetStage'],
|
||||
): CreativeAgentTargetSelectionStage {
|
||||
return targetStage === 'puzzle-agent-workspace'
|
||||
? 'puzzle-agent-workspace'
|
||||
: 'puzzle-result';
|
||||
}
|
||||
|
||||
export function createCreativeAgentClientMessageId(prefix = 'creative-agent') {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function resolveTemplateLevelMode(
|
||||
supportedLevelMode: PuzzleSupportedLevelMode,
|
||||
defaultLevelCount: number,
|
||||
): PuzzleLevelGenerationMode {
|
||||
if (supportedLevelMode === 'single') {
|
||||
return 'single_level';
|
||||
}
|
||||
|
||||
if (supportedLevelMode === 'multi') {
|
||||
return 'multi_level';
|
||||
}
|
||||
|
||||
return defaultLevelCount > 1 ? 'multi_level' : 'single_level';
|
||||
}
|
||||
|
||||
function resolveTemplatePlannedLevelCount(
|
||||
supportedLevelMode: PuzzleSupportedLevelMode,
|
||||
defaultLevelCount: number,
|
||||
) {
|
||||
if (supportedLevelMode === 'single') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (supportedLevelMode === 'multi') {
|
||||
return Math.max(2, defaultLevelCount);
|
||||
}
|
||||
|
||||
return Math.max(1, defaultLevelCount);
|
||||
}
|
||||
|
||||
export function buildPuzzleTemplateSelectionFromProtocol(
|
||||
template: PuzzleCreativeTemplateProtocol,
|
||||
): PuzzleCreativeTemplateSelection {
|
||||
const plannedLevelCount = resolveTemplatePlannedLevelCount(
|
||||
template.supportedLevelMode,
|
||||
template.defaultLevelCount,
|
||||
);
|
||||
|
||||
return {
|
||||
templateId: template.templateId,
|
||||
title: template.title,
|
||||
reason: template.summary,
|
||||
costRange: template.costRange,
|
||||
supportedLevelMode: template.supportedLevelMode,
|
||||
selectedLevelMode: resolveTemplateLevelMode(
|
||||
template.supportedLevelMode,
|
||||
plannedLevelCount,
|
||||
),
|
||||
plannedLevelCount,
|
||||
requiresUserConfirmation: true,
|
||||
};
|
||||
}
|
||||
|
||||
export type CreativeAgentStageDisplayStatus =
|
||||
| 'active'
|
||||
| 'done'
|
||||
| 'waiting'
|
||||
| 'idle';
|
||||
|
||||
export function getCreativeAgentStageDisplayLabel(
|
||||
stage: CreativeAgentStage,
|
||||
status: CreativeAgentStageDisplayStatus,
|
||||
) {
|
||||
if (status === 'done') {
|
||||
return CREATIVE_AGENT_STAGE_DONE_LABEL[stage] ?? CREATIVE_AGENT_STAGE_LABEL[stage];
|
||||
}
|
||||
if (status === 'waiting') {
|
||||
return CREATIVE_AGENT_STAGE_WAITING_LABEL[stage] ?? CREATIVE_AGENT_STAGE_LABEL[stage];
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return CREATIVE_AGENT_STAGE_IDLE_LABEL[stage];
|
||||
}
|
||||
return CREATIVE_AGENT_STAGE_LABEL[stage];
|
||||
}
|
||||
|
||||
export type CreativeAgentProcessTone =
|
||||
| 'active'
|
||||
| 'done'
|
||||
| 'info'
|
||||
| 'warning'
|
||||
| 'danger';
|
||||
|
||||
export type CreativeAgentProcessItem = {
|
||||
id: string;
|
||||
meta: string;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
detailLines: string[];
|
||||
tone: CreativeAgentProcessTone;
|
||||
};
|
||||
|
||||
const CREATIVE_AGENT_TOOL_LABEL: Record<string, string> = {
|
||||
retrieve_puzzle_template_catalog: '读取拼图模板',
|
||||
select_puzzle_template: '选择拼图模板',
|
||||
confirm_puzzle_template: '确认模板',
|
||||
create_puzzle_agent_session: '创建拼图草稿',
|
||||
compile_puzzle_creative_draft: '编译拼图草稿',
|
||||
plan_puzzle_level_images: '规划关卡图片',
|
||||
generate_puzzle_level_images: '生成关卡图片',
|
||||
apply_puzzle_draft_natural_language_edit: '写回草稿修改',
|
||||
validate_puzzle_result_preview: '校验草稿预览',
|
||||
start_puzzle_draft_test_run: '启动拼图试玩',
|
||||
};
|
||||
|
||||
type ProcessBuildContext = {
|
||||
activeStageEventIndex: number;
|
||||
eventLog: CreativeAgentSseEvent[];
|
||||
isComplete: boolean;
|
||||
};
|
||||
|
||||
function formatPuzzleLevelMode(mode: PuzzleLevelGenerationMode) {
|
||||
return mode === 'single_level' ? '单关卡' : '多关卡';
|
||||
}
|
||||
|
||||
function formatTargetStage(stage: CreativeTargetSessionBinding['targetStage']) {
|
||||
return stage === 'puzzle-agent-workspace'
|
||||
? '拼图工作区'
|
||||
: stage === 'puzzle-runtime'
|
||||
? '拼图运行态'
|
||||
: '拼图结果页';
|
||||
}
|
||||
|
||||
function resolveToolLabel(toolName: string) {
|
||||
return CREATIVE_AGENT_TOOL_LABEL[toolName] ?? toolName;
|
||||
}
|
||||
|
||||
function buildStageProcessItem(
|
||||
event: Extract<CreativeAgentSseEvent, { event: 'stage' }>,
|
||||
index: number,
|
||||
context: ProcessBuildContext,
|
||||
): CreativeAgentProcessItem {
|
||||
const stage = event.data.stage;
|
||||
const isWaiting =
|
||||
stage === 'waiting_template_confirmation' || stage === 'waiting_user';
|
||||
const isActive =
|
||||
index === context.activeStageEventIndex &&
|
||||
!context.isComplete &&
|
||||
!isWaiting &&
|
||||
stage !== 'target_ready' &&
|
||||
stage !== 'failed';
|
||||
const tone: CreativeAgentProcessTone =
|
||||
stage === 'failed'
|
||||
? 'danger'
|
||||
: isWaiting
|
||||
? 'warning'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'done';
|
||||
return {
|
||||
id: `${index}-stage-${stage}`,
|
||||
meta: '阶段',
|
||||
title: getCreativeAgentStageDisplayLabel(
|
||||
stage,
|
||||
isActive ? 'active' : isWaiting ? 'waiting' : 'done',
|
||||
),
|
||||
detail: '阶段切换',
|
||||
detailLines: [],
|
||||
tone,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventProcessItem(
|
||||
event: CreativeAgentSseEvent,
|
||||
index: number,
|
||||
context: ProcessBuildContext,
|
||||
): CreativeAgentProcessItem | null {
|
||||
switch (event.event) {
|
||||
case 'stage':
|
||||
return buildStageProcessItem(event, index, context);
|
||||
case 'tool_started': {
|
||||
const toolLabel = resolveToolLabel(event.data.toolName);
|
||||
const isToolResolved =
|
||||
context.isComplete ||
|
||||
context.eventLog.slice(index + 1).some(
|
||||
(nextEvent) =>
|
||||
nextEvent.event === 'tool_completed' &&
|
||||
(nextEvent.data.toolCallId === event.data.toolCallId ||
|
||||
nextEvent.data.toolName === event.data.toolName),
|
||||
);
|
||||
return {
|
||||
id: `${index}-tool-started-${event.data.toolCallId}`,
|
||||
meta: '工具调用',
|
||||
title: `开始:${toolLabel}`,
|
||||
detail: event.data.summary ?? event.data.toolName,
|
||||
detailLines: [],
|
||||
tone: isToolResolved ? 'done' : 'active',
|
||||
};
|
||||
}
|
||||
case 'tool_completed': {
|
||||
const toolLabel = resolveToolLabel(event.data.toolName);
|
||||
return {
|
||||
id: `${index}-tool-completed-${event.data.toolCallId}`,
|
||||
meta: '工具完成',
|
||||
title: `完成:${toolLabel}`,
|
||||
detail: event.data.summary ?? event.data.toolName,
|
||||
detailLines: [],
|
||||
tone: 'done',
|
||||
};
|
||||
}
|
||||
case 'puzzle_template_selection':
|
||||
return {
|
||||
id: `${index}-template-${event.data.selection.templateId}`,
|
||||
meta: '模板',
|
||||
title: `选择 ${event.data.selection.title}`,
|
||||
detail: event.data.selection.reason,
|
||||
detailLines: [
|
||||
`${formatPuzzleLevelMode(event.data.selection.selectedLevelMode)} · ${event.data.selection.plannedLevelCount} 关`,
|
||||
],
|
||||
tone: 'info',
|
||||
};
|
||||
case 'puzzle_template_catalog':
|
||||
return {
|
||||
id: `${index}-template-catalog`,
|
||||
meta: '模板',
|
||||
title: `读取 ${event.data.templates.length} 个模板`,
|
||||
detail:
|
||||
event.data.templates
|
||||
.slice(0, 3)
|
||||
.map((template) => template.title)
|
||||
.join('、') || null,
|
||||
detailLines: [],
|
||||
tone: 'info',
|
||||
};
|
||||
case 'puzzle_cost_range':
|
||||
return {
|
||||
id: `${index}-cost-${event.data.costRange.minPoints}-${event.data.costRange.maxPoints}`,
|
||||
meta: '消耗',
|
||||
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} 光点`,
|
||||
detail: event.data.costRange.reason,
|
||||
detailLines: [],
|
||||
tone: 'info',
|
||||
};
|
||||
case 'puzzle_level_plan': {
|
||||
const candidateCount = event.data.plan.levels.reduce(
|
||||
(total, level) => total + level.candidateCount,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
id: `${index}-level-plan-${event.data.plan.templateId}`,
|
||||
meta: '关卡',
|
||||
title: `规划 ${event.data.plan.levels.length} 个关卡`,
|
||||
detail: `${formatPuzzleLevelMode(event.data.plan.mode)} · ${candidateCount} 张候选图`,
|
||||
detailLines: event.data.plan.levels.slice(0, 4).map((level) =>
|
||||
[
|
||||
level.levelName,
|
||||
level.pictureDescription,
|
||||
level.pictureReference ? `参考图:${level.pictureReference}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
),
|
||||
tone: 'info',
|
||||
};
|
||||
}
|
||||
case 'reflection':
|
||||
return {
|
||||
id: `${index}-reflection`,
|
||||
meta: '检查',
|
||||
title: event.data.pass ? '检查通过' : '需要调整',
|
||||
detail: event.data.summary,
|
||||
detailLines: event.data.warnings,
|
||||
tone: event.data.pass ? 'done' : 'warning',
|
||||
};
|
||||
case 'target_session':
|
||||
return {
|
||||
id: `${index}-target-${event.data.binding.targetSessionId}`,
|
||||
meta: '目标',
|
||||
title: '拼图草稿已绑定',
|
||||
detail: formatTargetStage(event.data.binding.targetStage),
|
||||
detailLines: [`目标会话:${event.data.binding.targetSessionId}`],
|
||||
tone: 'done',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
id: `${index}-error-${event.data.code}`,
|
||||
meta: '异常',
|
||||
title: event.data.message,
|
||||
detail: event.data.code,
|
||||
detailLines: [],
|
||||
tone: 'danger',
|
||||
};
|
||||
case 'done':
|
||||
return {
|
||||
id: `${index}-done`,
|
||||
meta: '完成',
|
||||
title: '本轮完成',
|
||||
detail: null,
|
||||
detailLines: [],
|
||||
tone: 'done',
|
||||
};
|
||||
case 'agent_message_delta':
|
||||
case 'thought_summary_delta':
|
||||
case 'session':
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildThoughtSummaryItems(
|
||||
eventLog: CreativeAgentSseEvent[],
|
||||
isComplete: boolean,
|
||||
): CreativeAgentProcessItem[] {
|
||||
const thoughtMap = new Map<string, string>();
|
||||
let latestThoughtId: string | null = null;
|
||||
|
||||
for (const event of eventLog) {
|
||||
if (event.event !== 'thought_summary_delta') {
|
||||
continue;
|
||||
}
|
||||
const currentText = thoughtMap.get(event.data.thoughtId) ?? '';
|
||||
thoughtMap.set(event.data.thoughtId, `${currentText}${event.data.textDelta}`);
|
||||
latestThoughtId = event.data.thoughtId;
|
||||
}
|
||||
|
||||
const items: CreativeAgentProcessItem[] = [];
|
||||
for (const [thoughtId, text] of thoughtMap.entries()) {
|
||||
const detail = text.trim();
|
||||
if (!detail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `thought-${thoughtId}`,
|
||||
meta: '思考',
|
||||
title: '思考摘要',
|
||||
detail,
|
||||
detailLines: [],
|
||||
tone: !isComplete && thoughtId === latestThoughtId ? 'active' : 'done',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildSessionFallbackItems(
|
||||
session: CreativeAgentSessionSnapshot | null,
|
||||
eventLog: CreativeAgentSseEvent[],
|
||||
): CreativeAgentProcessItem[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: CreativeAgentProcessItem[] = [];
|
||||
if (
|
||||
session.puzzleTemplateCatalog.length > 0 &&
|
||||
!eventLog.some((event) => event.event === 'puzzle_template_catalog')
|
||||
) {
|
||||
items.push({
|
||||
id: `session-template-catalog-${session.puzzleTemplateCatalog.length}`,
|
||||
meta: '模板',
|
||||
title: `读取 ${session.puzzleTemplateCatalog.length} 个模板`,
|
||||
detail: session.puzzleTemplateCatalog
|
||||
.slice(0, 3)
|
||||
.map((template) => template.title)
|
||||
.join('、'),
|
||||
detailLines: [],
|
||||
tone: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
session.puzzleTemplateSelection &&
|
||||
!eventLog.some((event) => event.event === 'puzzle_template_selection')
|
||||
) {
|
||||
items.push({
|
||||
id: `session-template-${session.puzzleTemplateSelection.templateId}`,
|
||||
meta: '模板',
|
||||
title: `选择 ${session.puzzleTemplateSelection.title}`,
|
||||
detail: session.puzzleTemplateSelection.reason,
|
||||
detailLines: [
|
||||
`${formatPuzzleLevelMode(session.puzzleTemplateSelection.selectedLevelMode)} · ${session.puzzleTemplateSelection.plannedLevelCount} 关`,
|
||||
],
|
||||
tone: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
session.puzzleImageGenerationPlan &&
|
||||
!eventLog.some((event) => event.event === 'puzzle_level_plan')
|
||||
) {
|
||||
const plan = session.puzzleImageGenerationPlan;
|
||||
items.push({
|
||||
id: `session-level-plan-${plan.templateId}`,
|
||||
meta: '关卡',
|
||||
title: `规划 ${plan.levels.length} 个关卡`,
|
||||
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} 光点`,
|
||||
detailLines: plan.levels.slice(0, 4).map((level) =>
|
||||
[
|
||||
level.levelName,
|
||||
level.pictureDescription,
|
||||
level.pictureReference ? `参考图:${level.pictureReference}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
),
|
||||
tone: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
session.targetBinding &&
|
||||
!eventLog.some((event) => event.event === 'target_session')
|
||||
) {
|
||||
items.push({
|
||||
id: `session-target-${session.targetBinding.targetSessionId}`,
|
||||
meta: '目标',
|
||||
title: '拼图草稿已绑定',
|
||||
detail: formatTargetStage(session.targetBinding.targetStage),
|
||||
detailLines: [`目标会话:${session.targetBinding.targetSessionId}`],
|
||||
tone: 'done',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
items.length === 0 &&
|
||||
session.stage !== 'idle' &&
|
||||
!eventLog.some((event) => event.event === 'stage')
|
||||
) {
|
||||
const isWaiting =
|
||||
session.stage === 'waiting_template_confirmation' ||
|
||||
session.stage === 'waiting_user';
|
||||
const isDone = session.stage === 'target_ready';
|
||||
const tone: CreativeAgentProcessTone =
|
||||
session.stage === 'failed'
|
||||
? 'danger'
|
||||
: isWaiting
|
||||
? 'warning'
|
||||
: isDone
|
||||
? 'done'
|
||||
: 'active';
|
||||
items.push({
|
||||
id: `session-stage-${session.stage}`,
|
||||
meta: '阶段',
|
||||
title: getCreativeAgentStageDisplayLabel(
|
||||
session.stage,
|
||||
tone === 'active' ? 'active' : tone === 'done' ? 'done' : 'waiting',
|
||||
),
|
||||
detail: '当前状态',
|
||||
detailLines: [],
|
||||
tone,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildCreativeAgentProcessItems(
|
||||
eventLog: CreativeAgentSseEvent[],
|
||||
session: CreativeAgentSessionSnapshot | null,
|
||||
) {
|
||||
const terminalStageSeen = eventLog.some(
|
||||
(event) =>
|
||||
event.event === 'stage' &&
|
||||
(event.data.stage === 'waiting_template_confirmation' ||
|
||||
event.data.stage === 'target_ready' ||
|
||||
event.data.stage === 'waiting_user' ||
|
||||
event.data.stage === 'failed'),
|
||||
);
|
||||
const isComplete =
|
||||
eventLog.some((event) => event.event === 'done') ||
|
||||
terminalStageSeen ||
|
||||
session?.stage === 'waiting_template_confirmation' ||
|
||||
session?.stage === 'target_ready' ||
|
||||
session?.stage === 'waiting_user' ||
|
||||
session?.stage === 'failed';
|
||||
const activeStageEventIndex = eventLog.reduce(
|
||||
(latestIndex, event, index) => (event.event === 'stage' ? index : latestIndex),
|
||||
-1,
|
||||
);
|
||||
const context: ProcessBuildContext = {
|
||||
activeStageEventIndex,
|
||||
eventLog,
|
||||
isComplete,
|
||||
};
|
||||
|
||||
return [
|
||||
...buildThoughtSummaryItems(eventLog, isComplete),
|
||||
...eventLog
|
||||
.map((event, index) => buildEventProcessItem(event, index, context))
|
||||
.filter((item): item is CreativeAgentProcessItem => Boolean(item)),
|
||||
...buildSessionFallbackItems(session, eventLog),
|
||||
].slice(-24);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
47
src/components/custom-world-home/creationWorkShelf.test.ts
Normal file
47
src/components/custom-world-home/creationWorkShelf.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
|
||||
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
visualNovelItems: [
|
||||
{
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'vn-profile-demo-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
title: '雨夜终章',
|
||||
description: '失踪列车上的选择。',
|
||||
coverImageSrc: '/vn-cover.png',
|
||||
tags: ['悬疑', '列车'],
|
||||
publishStatus: 'published',
|
||||
publishReady: true,
|
||||
playCount: 12,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishedAt: '2026-05-07T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'vn-profile-draft-00000001',
|
||||
ownerUserId: 'user-1',
|
||||
title: '',
|
||||
description: '',
|
||||
coverImageSrc: null,
|
||||
tags: [],
|
||||
publishStatus: 'draft',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items[0]?.kind).toBe('visual-novel');
|
||||
expect(items[0]?.publicWorkCode).toBe('VN-12345678');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=VN-12345678');
|
||||
expect(items[1]?.status).toBe('draft');
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
@@ -7,8 +14,11 @@ import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
|
||||
import {
|
||||
cropPuzzleReferenceImageDataUrl,
|
||||
isPuzzleReferenceImageSquare,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -26,6 +36,8 @@ type PuzzleAgentWorkspaceProps = {
|
||||
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
|
||||
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
|
||||
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type PuzzleFormState = {
|
||||
@@ -33,6 +45,7 @@ type PuzzleFormState = {
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
imageModel: PuzzleImageModelId;
|
||||
aiRedraw: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
@@ -40,21 +53,42 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
};
|
||||
|
||||
type PuzzleImageCropState = {
|
||||
source: string;
|
||||
label: string;
|
||||
imageSize: { width: number; height: number };
|
||||
cropX: number;
|
||||
cropY: number;
|
||||
scale: number;
|
||||
error: string | null;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
|
||||
): PuzzleFormState {
|
||||
const shouldTreatEmptyPayloadAsFreshForm =
|
||||
!session &&
|
||||
Boolean(initialFormPayload) &&
|
||||
Object.keys(initialFormPayload ?? {}).length === 0;
|
||||
if (shouldTreatEmptyPayloadAsFreshForm) {
|
||||
return EMPTY_FORM_STATE;
|
||||
}
|
||||
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
if (formDraft) {
|
||||
return {
|
||||
pictureDescription: formDraft.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
|
||||
aiRedraw: initialFormPayload?.aiRedraw ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,9 +100,10 @@ function resolveInitialFormState(
|
||||
'',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
|
||||
aiRedraw: initialFormPayload.aiRedraw ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,9 +121,212 @@ function resolveInitialFormState(
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
};
|
||||
}
|
||||
|
||||
function clampPuzzleImageCrop(
|
||||
imageSize: { width: number; height: number },
|
||||
scale: number,
|
||||
crop: { x: number; y: number },
|
||||
) {
|
||||
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
|
||||
const maxCropX = Math.max(0, imageSize.width - cropSize);
|
||||
const maxCropY = Math.max(0, imageSize.height - cropSize);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(maxCropX, crop.x)),
|
||||
y: Math.max(0, Math.min(maxCropY, crop.y)),
|
||||
};
|
||||
}
|
||||
|
||||
function PuzzleImageCropModal({
|
||||
state,
|
||||
onScaleChange,
|
||||
onCropChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: PuzzleImageCropState;
|
||||
onScaleChange: (value: number) => void;
|
||||
onCropChange: (nextCrop: { x: number; y: number }) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
cropX: number;
|
||||
cropY: number;
|
||||
} | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const cropSize = Math.min(state.imageSize.width, state.imageSize.height) /
|
||||
state.scale;
|
||||
const maxCropX = Math.max(0, state.imageSize.width - cropSize);
|
||||
const maxCropY = Math.max(0, state.imageSize.height - cropSize);
|
||||
const backgroundSize = `${(state.imageSize.width / cropSize) * 100}% ${(state.imageSize.height / cropSize) * 100}%`;
|
||||
const backgroundPosition = `${maxCropX > 0 ? (state.cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (state.cropY / maxCropY) * 100 : 50}%`;
|
||||
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
const preview = previewRef.current;
|
||||
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = preview.getBoundingClientRect();
|
||||
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
|
||||
onCropChange({
|
||||
x:
|
||||
dragStart.cropX -
|
||||
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
|
||||
y:
|
||||
dragStart.cropY -
|
||||
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
|
||||
});
|
||||
};
|
||||
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragStartRef.current?.pointerId === event.pointerId) {
|
||||
dragStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-image-crop-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div id="puzzle-image-crop-title" className="text-base font-black">
|
||||
裁剪拼图图片
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭拼图图片裁剪"
|
||||
onClick={onClose}
|
||||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-5">
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.2rem] border border-white/12 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url("${state.source}")`,
|
||||
backgroundSize,
|
||||
backgroundPosition,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
role="img"
|
||||
aria-label="拼图图片裁剪预览"
|
||||
onPointerDown={(event) => {
|
||||
dragStartRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
cropX: state.cropX,
|
||||
cropY: state.cropY,
|
||||
};
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={updateDragCrop}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
/>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
缩放
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.01"
|
||||
value={state.scale}
|
||||
onChange={(event) => onScaleChange(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
横向
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxCropX}
|
||||
step="1"
|
||||
value={Math.min(state.cropX, maxCropX)}
|
||||
onChange={(event) =>
|
||||
onCropChange({
|
||||
x: Number(event.target.value),
|
||||
y: state.cropY,
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
纵向
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxCropY}
|
||||
step="1"
|
||||
value={Math.min(state.cropY, maxCropY)}
|
||||
onChange={(event) =>
|
||||
onCropChange({
|
||||
x: state.cropX,
|
||||
y: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{state.error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
|
||||
{state.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={state.isSaving}
|
||||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{state.isSaving ? '裁剪中' : '应用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图创作入口已从 Agent 对话改为填表式。
|
||||
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
|
||||
@@ -102,6 +340,8 @@ export function PuzzleAgentWorkspace({
|
||||
onCreateFromForm,
|
||||
onAutoSaveForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
}: PuzzleAgentWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<PuzzleFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
@@ -109,9 +349,7 @@ export function PuzzleAgentWorkspace({
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(
|
||||
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -139,18 +377,23 @@ export function PuzzleAgentWorkspace({
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
const canSubmit = Boolean(pictureDescription) && !isBusy;
|
||||
const canSubmit = formState.aiRedraw
|
||||
? Boolean(pictureDescription) && !isBusy
|
||||
: Boolean(formState.referenceImageSrc) && !isBusy;
|
||||
const autosavePayload = useMemo(
|
||||
() => ({
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
}),
|
||||
[
|
||||
formState.aiRedraw,
|
||||
formState.referenceImageSrc,
|
||||
formState.imageModel,
|
||||
pictureDescription,
|
||||
@@ -158,6 +401,8 @@ export function PuzzleAgentWorkspace({
|
||||
);
|
||||
const autosaveSignature = JSON.stringify([
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.referenceImageSrc,
|
||||
autosavePayload.aiRedraw,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
const lastAutosaveSignatureRef = useRef(autosaveSignature);
|
||||
@@ -209,35 +454,119 @@ export function PuzzleAgentWorkspace({
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
const uploadImage = await readPuzzleReferenceImageForUpload(file);
|
||||
if (!isPuzzleReferenceImageSquare(uploadImage)) {
|
||||
const cropSize = Math.min(uploadImage.width, uploadImage.height);
|
||||
setCropState({
|
||||
source: uploadImage.dataUrl,
|
||||
label: file.name.trim() || '本地拼图图片',
|
||||
imageSize: {
|
||||
width: uploadImage.width,
|
||||
height: uploadImage.height,
|
||||
},
|
||||
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
|
||||
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
|
||||
scale: 1,
|
||||
error: null,
|
||||
isSaving: false,
|
||||
});
|
||||
setReferenceImageError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: dataUrl,
|
||||
referenceImageLabel: file.name.trim() || '本地参考图',
|
||||
referenceImageSrc: uploadImage.dataUrl,
|
||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
} catch (uploadError) {
|
||||
setReferenceImageError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
: '拼图图片读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTemplatePrompt = (templateId: string) => {
|
||||
const template = PUZZLE_CREATION_TEMPLATES.find(
|
||||
(item) => item.id === templateId,
|
||||
);
|
||||
if (!template) {
|
||||
const updateCropState = (nextCrop: { x: number; y: number }) => {
|
||||
setCropState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const clamped = clampPuzzleImageCrop(
|
||||
current.imageSize,
|
||||
current.scale,
|
||||
nextCrop,
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
cropX: clamped.x,
|
||||
cropY: clamped.y,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateCropScale = (nextScale: number) => {
|
||||
setCropState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const scale = Math.max(1, Math.min(3, nextScale || 1));
|
||||
const clamped = clampPuzzleImageCrop(current.imageSize, scale, {
|
||||
x: current.cropX,
|
||||
y: current.cropY,
|
||||
});
|
||||
return {
|
||||
...current,
|
||||
scale,
|
||||
cropX: clamped.x,
|
||||
cropY: clamped.y,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applyCropState = async () => {
|
||||
const currentCropState = cropState;
|
||||
if (!currentCropState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTemplateId(template.id);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: template.prompt,
|
||||
}));
|
||||
setCropState({
|
||||
...currentCropState,
|
||||
isSaving: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const cropSize =
|
||||
Math.min(
|
||||
currentCropState.imageSize.width,
|
||||
currentCropState.imageSize.height,
|
||||
) / currentCropState.scale;
|
||||
const dataUrl = await cropPuzzleReferenceImageDataUrl({
|
||||
source: currentCropState.source,
|
||||
cropX: currentCropState.cropX,
|
||||
cropY: currentCropState.cropY,
|
||||
cropSize,
|
||||
});
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: dataUrl,
|
||||
referenceImageLabel: currentCropState.label,
|
||||
}));
|
||||
setCropState(null);
|
||||
setReferenceImageError(null);
|
||||
} catch (cropError) {
|
||||
setCropState({
|
||||
...currentCropState,
|
||||
isSaving: false,
|
||||
error:
|
||||
cropError instanceof Error
|
||||
? cropError.message
|
||||
: '拼图图片裁剪失败,请重试。',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
@@ -245,11 +574,15 @@ export function PuzzleAgentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPictureDescription = formState.aiRedraw
|
||||
? pictureDescription
|
||||
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
|
||||
const payload = {
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
seedText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
|
||||
if (!session && onCreateFromForm) {
|
||||
@@ -259,161 +592,181 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
pictureDescription,
|
||||
promptText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
const pictureDescriptionLabel = formState.referenceImageSrc
|
||||
? '画面AI重绘要求(提示词)'
|
||||
: '画面描述';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{showBackButton ? (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
创建拼图
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
|
||||
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
|
||||
<span className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||
Template
|
||||
</span>
|
||||
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
|
||||
{PUZZLE_CREATION_TEMPLATES.find(
|
||||
(item) => item.id === selectedTemplateId,
|
||||
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
|
||||
{title ? (
|
||||
<div className="mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-4xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 overflow-x-auto pb-2"
|
||||
aria-label="拼图创作模板"
|
||||
>
|
||||
{PUZZLE_CREATION_TEMPLATES.map((template) => {
|
||||
const selected = template.id === selectedTemplateId;
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => applyTemplatePrompt(template.id)}
|
||||
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
|
||||
selected
|
||||
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${template.title}模板`}
|
||||
>
|
||||
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<img
|
||||
src={template.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
|
||||
{template.title}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
|
||||
已选择
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<label
|
||||
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span>
|
||||
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="sr-only">画面描述</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formState.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={10}
|
||||
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[20rem]"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
value={formState.imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={(imageModel) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
imageModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<section className="overflow-visible">
|
||||
<div
|
||||
className={`grid gap-3 sm:gap-4 ${
|
||||
formState.aiRedraw
|
||||
? 'lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
||||
: 'lg:grid-cols-1'
|
||||
}`}
|
||||
>
|
||||
<div className={`min-w-0 ${isBusy ? 'opacity-55' : ''}`}>
|
||||
<div className="mb-2 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
拼图画面
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formState.referenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="puzzle-image-upload-card relative aspect-square w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_16px_34px_rgba(222,82,124,0.12)] transition">
|
||||
<input
|
||||
id="puzzle-image-upload-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label="上传拼图图片"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
formState.referenceImageSrc
|
||||
? '更换拼图图片'
|
||||
: '上传拼图图片'
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{formState.referenceImageSrc
|
||||
? '更换拼图图片'
|
||||
: '上传拼图图片'}
|
||||
</span>
|
||||
</label>
|
||||
{formState.referenceImageSrc ? (
|
||||
<img
|
||||
src={formState.referenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
alt="拼图图片"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
|
||||
<ImagePlus className="h-7 w-7 sm:h-8 sm:w-8" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
|
||||
{formState.referenceImageSrc ? (
|
||||
<label className="absolute right-3 top-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={formState.aiRedraw}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
aiRedraw: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label="AI重绘"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative h-5 w-9 rounded-full transition ${
|
||||
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`mt-2 block text-center text-sm font-black text-[var(--platform-text-strong)] transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formState.aiRedraw ? (
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{pictureDescriptionLabel}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formState.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={2}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[clamp(5rem,15svh,7rem)] w-full resize-none rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[8.5rem] lg:min-h-[10.5rem]"
|
||||
aria-label={pictureDescriptionLabel}
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
value={formState.imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={(imageModel) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
imageModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{formState.referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{formState.referenceImageSrc ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
@@ -422,14 +775,18 @@ export function PuzzleAgentWorkspace({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
aiRedraw: true,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-xs"
|
||||
aria-label="移除拼图图片"
|
||||
title="移除拼图图片"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
移除拼图图片
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -459,12 +816,25 @@ export function PuzzleAgentWorkspace({
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>生成草稿</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗2光点
|
||||
</span>
|
||||
{formState.aiRedraw ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗2光点
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{cropState ? (
|
||||
<PuzzleImageCropModal
|
||||
state={cropState}
|
||||
onScaleChange={updateCropScale}
|
||||
onCropChange={updateCropState}
|
||||
onClose={() => setCropState(null)}
|
||||
onSubmit={() => {
|
||||
void applyCropState();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -589,4 +589,40 @@ describe('PuzzleResultView', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
creativeDraftEdit={{
|
||||
isBusy: false,
|
||||
error: null,
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
|
||||
target: { value: '把标题改得轻松一点' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
instruction: '把标题改得轻松一点',
|
||||
currentDraft: expect.objectContaining({
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
pictureReference: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ImagePlus,
|
||||
Images,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Play,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleDraftLevel,
|
||||
@@ -41,6 +43,14 @@ type PuzzleResultViewProps = {
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
onStartTestRun?: (draft: PuzzleResultDraft) => void;
|
||||
creativeDraftEdit?: {
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onSubmit: (payload: {
|
||||
instruction: string;
|
||||
currentDraft: PuzzleResultDraft;
|
||||
}) => Promise<CreativeDraftEditResult | null> | void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
@@ -113,6 +123,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
|
||||
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
|
||||
levelName: level.levelName?.trim() || '',
|
||||
pictureDescription: level.pictureDescription?.trim() || draft.summary,
|
||||
pictureReference: level.pictureReference ?? null,
|
||||
candidates: level.candidates ?? [],
|
||||
selectedCandidateId: level.selectedCandidateId ?? null,
|
||||
coverImageSrc: level.coverImageSrc ?? null,
|
||||
@@ -160,6 +171,7 @@ function createBlankPuzzleLevel(
|
||||
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
|
||||
levelName: '',
|
||||
pictureDescription: '',
|
||||
pictureReference: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -835,6 +847,20 @@ function PuzzleLevelDetailDialog({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={level.pictureReference ?? ''}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureReference: event.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-3 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="参考图链接或资产ID"
|
||||
aria-label="图面参考"
|
||||
/>
|
||||
|
||||
{referenceImageSrc ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
||||
@@ -1112,6 +1138,78 @@ function PuzzlePublishDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleCreativeDraftEditBar({
|
||||
currentDraft,
|
||||
error,
|
||||
isBusy,
|
||||
onSubmit,
|
||||
}: {
|
||||
currentDraft: PuzzleResultDraft;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
onSubmit: (payload: {
|
||||
instruction: string;
|
||||
currentDraft: PuzzleResultDraft;
|
||||
}) => Promise<CreativeDraftEditResult | null> | void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState('');
|
||||
const trimmedInstruction = instruction.trim();
|
||||
const canSubmit = Boolean(trimmedInstruction) && !isBusy;
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
void onSubmit({
|
||||
instruction: trimmedInstruction,
|
||||
currentDraft,
|
||||
});
|
||||
setInstruction('');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel mb-3 rounded-[1.35rem] p-3 sm:p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="mb-1 hidden h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/72 text-[var(--platform-text-base)] sm:inline-flex">
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<textarea
|
||||
value={instruction}
|
||||
disabled={isBusy}
|
||||
rows={2}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="让 Agent 调整标题、标签或关卡描述"
|
||||
aria-label="智能修订拼图草稿"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
className="platform-button platform-button--secondary min-h-11 px-4 py-2 text-sm"
|
||||
>
|
||||
{isBusy ? '修改中' : '修改'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-[1rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleLevelListTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
@@ -1316,6 +1414,7 @@ export function PuzzleResultView({
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
onStartTestRun,
|
||||
creativeDraftEdit = null,
|
||||
}: PuzzleResultViewProps) {
|
||||
const draft = session.draft;
|
||||
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
|
||||
@@ -1491,6 +1590,15 @@ export function PuzzleResultView({
|
||||
|
||||
<PuzzleResultTabs activeTab={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{creativeDraftEdit ? (
|
||||
<PuzzleCreativeDraftEditBar
|
||||
currentDraft={syncedDraft}
|
||||
error={creativeDraftEdit.error}
|
||||
isBusy={creativeDraftEdit.isBusy}
|
||||
onSubmit={creativeDraftEdit.onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{activeTab === 'levels' ? (
|
||||
<PuzzleLevelListTab
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
@@ -43,6 +44,13 @@ import {
|
||||
submitBigFishInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
cancelCreativeAgentSession,
|
||||
confirmCreativePuzzleTemplate,
|
||||
createCreativeAgentSession,
|
||||
streamCreativeAgentMessage,
|
||||
streamCreativeDraftEdit,
|
||||
} from '../../services/creative-agent';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
@@ -109,6 +117,20 @@ import {
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { squareHoleCreationClient } from '../../services/square-hole-creation';
|
||||
import {
|
||||
dropSquareHoleShape,
|
||||
finishSquareHoleTimeUp,
|
||||
restartSquareHoleRun,
|
||||
startSquareHoleRun,
|
||||
stopSquareHoleRun,
|
||||
} from '../../services/square-hole-runtime';
|
||||
import {
|
||||
deleteSquareHoleWork,
|
||||
getSquareHoleWorkDetail,
|
||||
listSquareHoleGallery,
|
||||
listSquareHoleWorks,
|
||||
} from '../../services/square-hole-works';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
@@ -136,18 +158,51 @@ async function clickFirstAsyncButtonByName(
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
|
||||
expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '草稿');
|
||||
const panel = getPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(
|
||||
await within(panel).findByRole('button', { name: /全部/u }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '发现');
|
||||
const panel = getPlatformTabPanel('category');
|
||||
await waitFor(() => {
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(
|
||||
await within(panel).findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
),
|
||||
).toBeTruthy();
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openProfilePlayedWorks(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '我的');
|
||||
await user.click(await screen.findByRole('button', { name: /玩过/u }));
|
||||
expect(await screen.findByText('可继续')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openExistingRpgDraft(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
actionName: string | RegExp = /继续(?:完善|创作)/u,
|
||||
) {
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: actionName }));
|
||||
}
|
||||
|
||||
@@ -289,6 +344,40 @@ vi.mock('../../services/match3d-runtime', () => ({
|
||||
stopMatch3DRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/square-hole-creation', () => ({
|
||||
squareHoleCreationClient: {
|
||||
createSession: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/square-hole-runtime', () => ({
|
||||
dropSquareHoleShape: vi.fn(),
|
||||
finishSquareHoleTimeUp: vi.fn(),
|
||||
getSquareHoleRun: vi.fn(),
|
||||
restartSquareHoleRun: vi.fn(),
|
||||
startSquareHoleRun: vi.fn(),
|
||||
stopSquareHoleRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/square-hole-works', () => ({
|
||||
deleteSquareHoleWork: vi.fn(),
|
||||
getSquareHoleWorkDetail: vi.fn(),
|
||||
listSquareHoleGallery: vi.fn(),
|
||||
listSquareHoleWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/creative-agent', () => ({
|
||||
cancelCreativeAgentSession: vi.fn(),
|
||||
confirmCreativePuzzleTemplate: vi.fn(),
|
||||
createCreativeAgentSession: vi.fn(),
|
||||
streamCreativeAgentMessage: vi.fn(),
|
||||
streamCreativeDraftEdit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
|
||||
@@ -573,6 +662,168 @@ const mockAuthUser: AuthUser = {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function buildMockCreativeAgentSession(
|
||||
overrides: Partial<CreativeAgentSessionSnapshot> = {},
|
||||
): CreativeAgentSessionSnapshot {
|
||||
const sessionId = overrides.sessionId ?? 'creative-agent-session-1';
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
stage: 'waiting_user',
|
||||
inputSummary: {
|
||||
text: null,
|
||||
entryContext: 'creation_home',
|
||||
images: [],
|
||||
materialSummary: null,
|
||||
unsupportedCapabilities: [],
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'creative-agent-message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '说一个灵感,我来帮你做成互动内容。',
|
||||
createdAt: '2026-05-05T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
puzzleTemplateCatalog: [],
|
||||
puzzleTemplateSelection: null,
|
||||
puzzleImageGenerationPlan: null,
|
||||
targetBinding: null,
|
||||
updatedAt: '2026-05-05T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSquareHoleAgentSession(
|
||||
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
|
||||
) {
|
||||
return buildMockSquareHoleAgentSessionImpl(overrides);
|
||||
}
|
||||
|
||||
function buildMockSquareHoleAgentSessionImpl(
|
||||
overrides: Partial<{
|
||||
sessionId: string;
|
||||
stage: string;
|
||||
messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>;
|
||||
updatedAt: string;
|
||||
}> = {},
|
||||
) {
|
||||
const sessionId = overrides.sessionId ?? 'square-hole-session-1';
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
currentTurn: 0,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '题材主题',
|
||||
value: '霓虹形状',
|
||||
status: 'confirmed',
|
||||
},
|
||||
twistRule: {
|
||||
key: 'twistRule',
|
||||
label: '反直觉规则',
|
||||
value: '颜色会误导洞口',
|
||||
status: 'confirmed',
|
||||
},
|
||||
shapeCount: {
|
||||
key: 'shapeCount',
|
||||
label: '形状数量',
|
||||
value: '12',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '5',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '霓虹形状',
|
||||
twistRule: '颜色会误导洞口',
|
||||
shapeCount: 12,
|
||||
difficulty: 5,
|
||||
shapeOptions: [
|
||||
{
|
||||
optionId: 'shape-square',
|
||||
shapeKind: 'square',
|
||||
label: '方块',
|
||||
targetHoleId: 'hole-square',
|
||||
imagePrompt: '霓虹方块',
|
||||
imageSrc: null,
|
||||
},
|
||||
],
|
||||
holeOptions: [
|
||||
{
|
||||
holeId: 'hole-square',
|
||||
holeKind: 'square',
|
||||
label: '方洞',
|
||||
imagePrompt: '发光方洞',
|
||||
imageSrc: null,
|
||||
},
|
||||
],
|
||||
backgroundPrompt: '霓虹街机背景',
|
||||
coverImageSrc: null,
|
||||
backgroundImageSrc: null,
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'square-hole-message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先确定方洞挑战的题材和反直觉规则。',
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '先确定方洞挑战的题材和反直觉规则。',
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-05-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSquareHoleRun(profileId: string) {
|
||||
return {
|
||||
runId: `square-hole-run-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: 'user-2',
|
||||
status: 'running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1_000,
|
||||
durationLimitMs: 600_000,
|
||||
remainingMs: 600_000,
|
||||
totalShapeCount: 12,
|
||||
completedShapeCount: 0,
|
||||
combo: 0,
|
||||
bestCombo: 0,
|
||||
score: 0,
|
||||
ruleLabel: '颜色会误导洞口',
|
||||
currentShape: {
|
||||
shapeId: 'shape-1',
|
||||
shapeKind: 'square',
|
||||
label: '方块',
|
||||
targetHoleId: 'hole-square',
|
||||
color: '#ff5f7e',
|
||||
imageSrc: null,
|
||||
},
|
||||
holes: [
|
||||
{
|
||||
holeId: 'hole-square',
|
||||
holeKind: 'square',
|
||||
label: '方洞',
|
||||
x: 0.2,
|
||||
y: 0.5,
|
||||
},
|
||||
],
|
||||
lastFeedback: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockPuzzleRun(
|
||||
profileId: string,
|
||||
levelName: string,
|
||||
@@ -1779,6 +2030,50 @@ beforeEach(() => {
|
||||
vi.mocked(stopMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
vi.mocked(squareHoleCreationClient.createSession).mockResolvedValue({
|
||||
session: buildMockSquareHoleAgentSession(),
|
||||
});
|
||||
vi.mocked(squareHoleCreationClient.getSession).mockResolvedValue({
|
||||
session: buildMockSquareHoleAgentSession(),
|
||||
});
|
||||
vi.mocked(squareHoleCreationClient.streamMessage).mockResolvedValue(
|
||||
buildMockSquareHoleAgentSession(),
|
||||
);
|
||||
vi.mocked(squareHoleCreationClient.executeAction).mockResolvedValue({
|
||||
session: buildMockSquareHoleAgentSession(),
|
||||
});
|
||||
vi.mocked(listSquareHoleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(listSquareHoleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(getSquareHoleWorkDetail).mockRejectedValue(
|
||||
new Error('未找到方洞挑战作品'),
|
||||
);
|
||||
vi.mocked(deleteSquareHoleWork).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startSquareHoleRun).mockResolvedValue({
|
||||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||||
});
|
||||
vi.mocked(dropSquareHoleShape).mockResolvedValue({
|
||||
feedback: {
|
||||
accepted: true,
|
||||
rejectReason: null,
|
||||
message: '投入成功',
|
||||
},
|
||||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||||
});
|
||||
vi.mocked(restartSquareHoleRun).mockResolvedValue({
|
||||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||||
});
|
||||
vi.mocked(finishSquareHoleTimeUp).mockResolvedValue({
|
||||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||||
});
|
||||
vi.mocked(stopSquareHoleRun).mockResolvedValue({
|
||||
run: buildMockSquareHoleRun('square-hole-profile-1'),
|
||||
});
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
@@ -1871,44 +2166,119 @@ beforeEach(() => {
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||
vi.mocked(createCreativeAgentSession).mockResolvedValue({
|
||||
session: buildMockCreativeAgentSession(),
|
||||
});
|
||||
vi.mocked(streamCreativeAgentMessage).mockImplementation(
|
||||
async (sessionId, payload) =>
|
||||
buildMockCreativeAgentSession({
|
||||
sessionId,
|
||||
stage: 'collaborating',
|
||||
messages: [
|
||||
{
|
||||
id: 'creative-agent-message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '说一个灵感,我来帮你做成互动内容。',
|
||||
createdAt: '2026-05-05T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.content
|
||||
.map((part) =>
|
||||
part.type === 'input_text' ? part.text.trim() : '参考图',
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
createdAt: '2026-05-05T10:01:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'creative-agent-message-2',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '收到,我先帮你整理成可创作方案。',
|
||||
createdAt: '2026-05-05T10:01:01.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.mocked(cancelCreativeAgentSession).mockResolvedValue({
|
||||
session: buildMockCreativeAgentSession({ stage: 'failed' }),
|
||||
});
|
||||
vi.mocked(confirmCreativePuzzleTemplate).mockResolvedValue({
|
||||
session: buildMockCreativeAgentSession(),
|
||||
});
|
||||
vi.mocked(streamCreativeDraftEdit).mockResolvedValue(
|
||||
buildMockCreativeAgentSession(),
|
||||
);
|
||||
});
|
||||
|
||||
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
|
||||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openCreateTemplateHub(user);
|
||||
|
||||
const match3dButton = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||
const visualNovelButton = screen.getByRole('button', {
|
||||
name: /视觉小说/u,
|
||||
});
|
||||
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/square-hole.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/visual-novel.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('tab', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openCreateTemplateHub(user);
|
||||
const generateButton = await screen.findByRole('button', {
|
||||
name: /生成草稿/u,
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(listBigFishWorks).not.toHaveBeenCalled();
|
||||
expect(listBigFishGallery).not.toHaveBeenCalled();
|
||||
await user.click(generateButton);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
|
||||
@@ -2002,7 +2372,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
|
||||
@@ -2056,7 +2426,7 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
|
||||
@@ -2112,7 +2482,7 @@ test('create tab resumes agent workspace when session has no draft profile even
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
@@ -2122,7 +2492,7 @@ test('create tab resumes agent workspace when session has no draft profile even
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
|
||||
test('opening a compiled draft with a missing agent session falls back to draft hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks)
|
||||
@@ -2166,20 +2536,22 @@ test('opening a compiled draft with a missing agent session falls back to create
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
const fallbackDraftPanel = getPlatformTabPanel('saves');
|
||||
await waitFor(() => {
|
||||
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
within(fallbackDraftPanel).getByText(
|
||||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(window.location.search).toBe('');
|
||||
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText('还没有作品')).toBeTruthy();
|
||||
expect(within(fallbackDraftPanel).getByText('还没有作品')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('Agent工作区:custom-world-agent-session-missing'),
|
||||
).toBeNull();
|
||||
@@ -2374,6 +2746,7 @@ test('logged out public detail gates big fish start before local runtime', async
|
||||
/>,
|
||||
);
|
||||
|
||||
await openDiscoverHub(user);
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
@@ -2469,23 +2842,22 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
|
||||
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const createPanel = getPlatformTabPanel('create');
|
||||
await openDraftHub(user);
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRpgCreationWorks).toHaveBeenCalled();
|
||||
});
|
||||
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
|
||||
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||||
expect(await within(draftPanel).findByText('拼图退出缓存作品')).toBeTruthy();
|
||||
expect(within(draftPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(within(draftPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||||
|
||||
rerender(<TestWrapper authValue={loggedOutAuth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
|
||||
expect(screen.queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(screen.queryByText('拼图退出缓存作品')).toBeNull();
|
||||
});
|
||||
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published puzzle works appear on home and mobile game category channel', async () => {
|
||||
@@ -2522,12 +2894,15 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await clickFirstButtonByName(user, '发现');
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const homePanel = getPlatformTabPanel('home');
|
||||
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
const discoverPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(homePanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
within(discoverPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(discoverPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
@@ -2564,12 +2939,13 @@ test('published big fish works stay hidden from platform home and game category
|
||||
});
|
||||
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await clickFirstButtonByName(user, '发现');
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const homePanel = getPlatformTabPanel('home');
|
||||
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
const discoverPanel = getPlatformTabPanel('category');
|
||||
expect(within(discoverPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
expect(
|
||||
within(homePanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||||
within(discoverPanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
@@ -2603,6 +2979,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '发现');
|
||||
await user.click(await screen.findByRole('button', { name: '排行' }));
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
|
||||
@@ -2647,32 +3024,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openCreationHub(user);
|
||||
const puzzleButton = await screen.findByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
});
|
||||
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
@@ -2779,7 +3130,7 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||
@@ -2792,36 +3143,35 @@ test('new puzzle creation entry maps raw bearer token errors to user-facing auth
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const puzzleButton = screen.getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
});
|
||||
await openCreateTemplateHub(user);
|
||||
const generateButton = screen.getByRole('button', { name: /生成草稿/u });
|
||||
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(generateButton);
|
||||
|
||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await within(getPlatformTabPanel('create')).findByText(
|
||||
await screen.findByText(
|
||||
'当前登录状态已失效,请重新登录后继续。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden big fish creation entry does not render in platform create hub', async () => {
|
||||
test('create tab does not render legacy gameplay creation entries', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openCreateTemplateHub(user);
|
||||
|
||||
expect(screen.queryByText('选择创作类型')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(createBigFishCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
|
||||
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||
@@ -2832,9 +3182,9 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openCreateTemplateHub(user);
|
||||
|
||||
const button = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
const button = screen.getByRole('button', { name: /生成草稿/u });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2845,12 +3195,12 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
|
||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dSession = buildMockMatch3DAgentSession();
|
||||
|
||||
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
|
||||
new Error('读取作品广场失败'),
|
||||
@@ -2858,23 +3208,16 @@ test('visible match3d creation card opens workspace even when public galleries f
|
||||
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
|
||||
new Error('读取抓大鹅广场失败'),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({
|
||||
session: match3dSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openCreateTemplateHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle draft result back button returns to creation hub', async () => {
|
||||
@@ -2905,7 +3248,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
@@ -2919,11 +3262,9 @@ test('puzzle draft result back button returns to creation hub', async () => {
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2955,7 +3296,7 @@ test('published puzzle work card restores its source session for editing', async
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
@@ -3089,6 +3430,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
item: puzzleWork,
|
||||
});
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3274,6 +3616,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3375,6 +3718,7 @@ test('first puzzle runtime back click can open remix result page', async () => {
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3428,6 +3772,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3471,6 +3816,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3523,6 +3869,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -3672,7 +4019,7 @@ test('failed draft work continues on generation progress view instead of agent w
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
|
||||
@@ -4193,7 +4540,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
@@ -4370,9 +4717,7 @@ test('agent draft result back button returns to creation hub without syncing res
|
||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('10分钟创作一个精品互动玩法')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -4625,7 +4970,9 @@ test('agent draft result can open from server result preview without embedded le
|
||||
);
|
||||
});
|
||||
|
||||
test('authenticated users with save archives default into the saves tab', async () => {
|
||||
test('authenticated users can open save archives from the profile played panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
@@ -4642,15 +4989,18 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
|
||||
await openProfilePlayedWorks(user);
|
||||
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('旧灯塔与失控航路').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('最近存档')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle save archive highlights work title and level subtitle', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'puzzle:puzzle-profile-1',
|
||||
@@ -4667,6 +5017,8 @@ test('puzzle save archive highlights work title and level subtitle', async () =>
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openProfilePlayedWorks(user);
|
||||
|
||||
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
@@ -4689,16 +5041,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
}),
|
||||
within(getPlatformTabPanel('create')).getByText(
|
||||
'10分钟创作一个精品互动玩法',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -4750,6 +5102,8 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await openProfilePlayedWorks(user);
|
||||
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -4827,7 +5181,7 @@ test('creation hub published work can open detail view before deleting from deta
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
@@ -4902,7 +5256,7 @@ test('creation hub published work enters existing detail view', async () => {
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
@@ -4976,7 +5330,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
|
||||
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await openDraftHub(user);
|
||||
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
@@ -5060,7 +5414,7 @@ test('creation hub published work card keeps delete action guarded by detail flo
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '删除' }));
|
||||
|
||||
@@ -939,6 +939,23 @@ test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logged out bottom nav keeps creation centered with recommend icon', () => {
|
||||
const { container } = renderLoggedOutHomeView(vi.fn());
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
'推荐',
|
||||
'创作',
|
||||
'发现',
|
||||
]);
|
||||
expect(buttons[0]?.querySelector('.lucide-gamepad-2')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile home search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Archive,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
BookOpen,
|
||||
@@ -8,20 +7,20 @@ import {
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
Ticket,
|
||||
Trophy,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
@@ -83,6 +82,7 @@ import {
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorldCoverImage,
|
||||
@@ -136,6 +136,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
}
|
||||
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
@@ -161,7 +162,7 @@ const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
||||
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type MobileHomeChannel = 'recommend' | 'today' | 'category';
|
||||
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
|
||||
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
||||
|
||||
const COMMUNITY_QR_CODES = [
|
||||
@@ -177,13 +178,14 @@ const COMMUNITY_QR_CODES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MOBILE_HOME_CHANNELS: Array<{
|
||||
id: MobileHomeChannel;
|
||||
const DISCOVER_CHANNELS: Array<{
|
||||
id: DiscoverChannel;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'recommend', label: '推荐' },
|
||||
{ id: 'today', label: '今日游戏' },
|
||||
{ id: 'category', label: '游戏分类' },
|
||||
{ id: 'today', label: '今日' },
|
||||
{ id: 'category', label: '分类' },
|
||||
{ id: 'ranking', label: '排行' },
|
||||
];
|
||||
|
||||
const PLATFORM_RANKING_TABS: Array<{
|
||||
@@ -377,6 +379,7 @@ function WorldCard({
|
||||
feedCardKey,
|
||||
enableCoverCarousel = false,
|
||||
isCoverCarouselActive = false,
|
||||
variant = 'standard',
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
onClick: () => void;
|
||||
@@ -385,6 +388,7 @@ function WorldCard({
|
||||
feedCardKey?: string;
|
||||
enableCoverCarousel?: boolean;
|
||||
isCoverCarouselActive?: boolean;
|
||||
variant?: 'standard' | 'immersive';
|
||||
}) {
|
||||
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const coverSlides = useMemo(() => {
|
||||
@@ -465,7 +469,7 @@ function WorldCard({
|
||||
onClick={onClick}
|
||||
aria-label={cardLabel}
|
||||
data-mobile-feed-card-key={feedCardKey}
|
||||
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||||
className={`platform-public-work-card ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||||
>
|
||||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||||
{coverImage ? (
|
||||
@@ -724,6 +728,7 @@ function PlatformTabButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
@@ -874,7 +879,10 @@ function PlatformRankingItem({
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||||
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
|
||||
const actionLabel =
|
||||
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
|
||||
? '试玩'
|
||||
: '进入';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -936,7 +944,10 @@ function PlatformCategoryGameItem({
|
||||
describePublicGalleryCardKind(entry),
|
||||
...tags.filter((tag) => tag !== categoryTag),
|
||||
].slice(0, 3);
|
||||
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
|
||||
const actionLabel =
|
||||
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
|
||||
? '试玩'
|
||||
: '进入';
|
||||
const summaryText =
|
||||
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
|
||||
|
||||
@@ -1139,6 +1150,8 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
@@ -1249,6 +1262,8 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '抓鹅'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
@@ -2549,20 +2564,30 @@ function ProfilePlayedWorksModal({
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
saveEntries,
|
||||
saveError,
|
||||
isResumingSaveWorldKey,
|
||||
onClose,
|
||||
onOpenWork,
|
||||
onResumeSave,
|
||||
}: {
|
||||
stats: ProfilePlayStatsResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
saveEntries: ProfileSaveArchiveSummary[];
|
||||
saveError: string | null;
|
||||
isResumingSaveWorldKey: string | null;
|
||||
onClose: () => void;
|
||||
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||||
}) {
|
||||
const playedWorks = stats?.playedWorks ?? [];
|
||||
const hasArchiveEntries = saveEntries.length > 0;
|
||||
const hasPlayedWorks = playedWorks.length > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -2590,6 +2615,11 @@ function ProfilePlayedWorksModal({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
@@ -2600,43 +2630,73 @@ function ProfilePlayedWorksModal({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : playedWorks.length > 0 ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{playedWorks.map((work) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${work.worldKey}:${work.lastPlayedAt}`}
|
||||
onClick={() => onOpenWork?.(work)}
|
||||
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-black text-zinc-950">
|
||||
{work.worldTitle}
|
||||
</div>
|
||||
{work.worldSubtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{work.worldSubtitle}
|
||||
) : hasArchiveEntries || hasPlayedWorks ? (
|
||||
<div className="mt-5 space-y-5">
|
||||
{hasArchiveEntries ? (
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-black text-zinc-500">
|
||||
可继续
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{saveEntries.map((entry) => (
|
||||
<SaveArchiveCard
|
||||
key={`${entry.worldKey}:played-archive`}
|
||||
entry={entry}
|
||||
loading={isResumingSaveWorldKey === entry.worldKey}
|
||||
onClick={() => onResumeSave(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasPlayedWorks ? (
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-black text-zinc-500">
|
||||
玩过
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{playedWorks.map((work) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${work.worldKey}:${work.lastPlayedAt}`}
|
||||
onClick={() => onOpenWork?.(work)}
|
||||
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-black text-zinc-950">
|
||||
{work.worldTitle}
|
||||
</div>
|
||||
{work.worldSubtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{work.worldSubtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</span>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
<span className="truncate">
|
||||
作品号 {formatPlayedWorkId(work)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
最近 {formatSnapshotTime(work.lastPlayedAt)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长{' '}
|
||||
{formatCompactPlayTime(
|
||||
work.lastObservedPlayTimeMs,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
<span className="truncate">
|
||||
作品号 {formatPlayedWorkId(work)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
最近 {formatSnapshotTime(work.lastPlayedAt)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
@@ -2681,6 +2741,7 @@ export function RpgEntryHomeView({
|
||||
onOpenPlayedWork,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
@@ -2733,9 +2794,10 @@ export function RpgEntryHomeView({
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [mobileHomeChannel, setMobileHomeChannel] =
|
||||
useState<MobileHomeChannel>('recommend');
|
||||
const mobileFeedRef = useRef<HTMLElement | null>(null);
|
||||
const [discoverChannel, setDiscoverChannel] =
|
||||
useState<DiscoverChannel>('recommend');
|
||||
const mobileRecommendFeedRef = useRef<HTMLElement | null>(null);
|
||||
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
|
||||
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -2842,18 +2904,29 @@ export function RpgEntryHomeView({
|
||||
isReferralCenterInitialized &&
|
||||
Boolean(referralCenter) &&
|
||||
referralCenter?.hasRedeemedCode !== true;
|
||||
const tabIcons = {
|
||||
home: House,
|
||||
category: Trophy,
|
||||
create: Sparkles,
|
||||
saves: Archive,
|
||||
profile: UserRound,
|
||||
} as const;
|
||||
const tabIcons: Record<
|
||||
PlatformHomeTab,
|
||||
ComponentType<{ className?: string }>
|
||||
> = isAuthenticated
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Plus,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
: {
|
||||
home: Gamepad2,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
};
|
||||
const tabLabels = {
|
||||
home: '首页',
|
||||
category: '排行',
|
||||
home: '推荐',
|
||||
category: '发现',
|
||||
create: '创作',
|
||||
saves: '存档',
|
||||
saves: '草稿',
|
||||
profile: '我的',
|
||||
} as const;
|
||||
|
||||
@@ -3398,11 +3471,19 @@ export function RpgEntryHomeView({
|
||||
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
|
||||
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
const mobileFeedEntries = useMemo(() => {
|
||||
const recommendedFeedEntries = useMemo(() => {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
[...featuredShelf, ...latestEntries].forEach((entry) => {
|
||||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values());
|
||||
}, [featuredShelf, latestEntries]);
|
||||
const discoverFeedEntries = useMemo(() => {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
const sourceEntries =
|
||||
mobileHomeChannel === 'recommend'
|
||||
? [...featuredShelf, ...latestEntries]
|
||||
discoverChannel === 'recommend'
|
||||
? recommendedFeedEntries
|
||||
: filterTodayPublishedEntries(latestEntries);
|
||||
|
||||
sourceEntries.forEach((entry) => {
|
||||
@@ -3410,16 +3491,22 @@ export function RpgEntryHomeView({
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values());
|
||||
}, [featuredShelf, latestEntries, mobileHomeChannel]);
|
||||
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
|
||||
const mobileFeedCarouselEnabled =
|
||||
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
|
||||
!isDesktopLayout &&
|
||||
((activeTab === 'home' && recommendedFeedEntries.length > 0) ||
|
||||
(activeTab === 'category' &&
|
||||
(discoverChannel === 'recommend' || discoverChannel === 'today')));
|
||||
useEffect(() => {
|
||||
if (!mobileFeedCarouselEnabled) {
|
||||
setMobileCenteredCardKey(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const feedElement = mobileFeedRef.current;
|
||||
const feedElement =
|
||||
activeTab === 'home'
|
||||
? mobileRecommendFeedRef.current
|
||||
: mobileDiscoverFeedRef.current;
|
||||
const scrollElement = feedElement?.closest('.platform-tab-panel');
|
||||
if (!feedElement || !scrollElement) {
|
||||
setMobileCenteredCardKey(null);
|
||||
@@ -3490,7 +3577,13 @@ export function RpgEntryHomeView({
|
||||
scrollElement.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
|
||||
}, [
|
||||
discoverChannel,
|
||||
discoverFeedEntries,
|
||||
activeTab,
|
||||
mobileFeedCarouselEnabled,
|
||||
recommendedFeedEntries,
|
||||
]);
|
||||
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
|
||||
(tab) => tab.id === activeRankingTab,
|
||||
) as (typeof PLATFORM_RANKING_TABS)[number];
|
||||
@@ -3499,9 +3592,6 @@ export function RpgEntryHomeView({
|
||||
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
|
||||
[activeRankingTab, publicEntries],
|
||||
);
|
||||
const categoryPageClass = isDesktopLayout
|
||||
? DESKTOP_PAGE_STAGE_CLASS
|
||||
: MOBILE_PAGE_STAGE_CLASS;
|
||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -3512,7 +3602,101 @@ export function RpgEntryHomeView({
|
||||
onTabChange('category');
|
||||
};
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
const mobileRankingPanel: ReactNode = (
|
||||
<section
|
||||
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
||||
>
|
||||
<div
|
||||
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
||||
role="tablist"
|
||||
aria-label="作品排行"
|
||||
>
|
||||
{PLATFORM_RANKING_TABS.map((tab) => {
|
||||
const active = tab.id === activeRankingTab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setActiveRankingTab(tab.id)}
|
||||
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : rankingEntries.length > 0 ? (
|
||||
<div className="mt-3 grid min-w-0 gap-2.5">
|
||||
{rankingEntries.map((entry, index) => (
|
||||
<PlatformRankingItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
metricLabel={activeRankingConfig.metricLabel}
|
||||
metricValue={getPlatformRankingMetricValue(
|
||||
entry,
|
||||
activeRankingTab,
|
||||
)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text={activeRankingConfig.emptyText} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const mobileRecommendContent: ReactNode = (
|
||||
<div
|
||||
className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
||||
>
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
ref={mobileRecommendFeedRef}
|
||||
className="platform-mobile-home-feed platform-mobile-recommend-feed"
|
||||
>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : recommendedFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{recommendedFeedEntries.map((entry) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-recommend`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
feedCardKey={cardKey}
|
||||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||||
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
|
||||
variant="immersive"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mobileDiscoverContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
<PublicCodeSearchBar
|
||||
value={mobileSearchKeyword}
|
||||
@@ -3531,13 +3715,13 @@ export function RpgEntryHomeView({
|
||||
) : (
|
||||
<>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{MOBILE_HOME_CHANNELS.map((channel) => {
|
||||
const active = mobileHomeChannel === channel.id;
|
||||
{DISCOVER_CHANNELS.map((channel) => {
|
||||
const active = discoverChannel === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setMobileHomeChannel(channel.id)}
|
||||
onClick={() => setDiscoverChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
@@ -3552,7 +3736,9 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mobileHomeChannel === 'category' ? (
|
||||
{discoverChannel === 'ranking' ? (
|
||||
mobileRankingPanel
|
||||
) : discoverChannel === 'category' ? (
|
||||
<section className="platform-category-list-panel">
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
@@ -3609,17 +3795,20 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
|
||||
<section
|
||||
ref={mobileDiscoverFeedRef}
|
||||
className="platform-mobile-home-feed"
|
||||
>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : mobileFeedEntries.length > 0 ? (
|
||||
) : discoverFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
|
||||
{discoverFeedEntries.map((entry: PlatformPublicGalleryCard) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
|
||||
key={`${cardKey}:mobile-feed:${discoverChannel}`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
@@ -3641,60 +3830,13 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
);
|
||||
|
||||
const categoryContent: ReactNode = (
|
||||
<div className={categoryPageClass}>
|
||||
<section
|
||||
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
||||
>
|
||||
<div
|
||||
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
||||
role="tablist"
|
||||
aria-label="作品排行"
|
||||
>
|
||||
{PLATFORM_RANKING_TABS.map((tab) => {
|
||||
const active = tab.id === activeRankingTab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setActiveRankingTab(tab.id)}
|
||||
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : rankingEntries.length > 0 ? (
|
||||
<div className="mt-3 grid min-w-0 gap-2.5">
|
||||
{rankingEntries.map((entry, index) => (
|
||||
<PlatformRankingItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
metricLabel={activeRankingConfig.metricLabel}
|
||||
metricValue={getPlatformRankingMetricValue(
|
||||
entry,
|
||||
activeRankingTab,
|
||||
)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text={activeRankingConfig.emptyText} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
const categoryContent: ReactNode = isDesktopLayout ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>{mobileRankingPanel}</div>
|
||||
) : (
|
||||
mobileDiscoverContent
|
||||
);
|
||||
|
||||
const createContent: ReactNode = createTabContent ?? (
|
||||
const fallbackCreateStartContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3720,7 +3862,11 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fallbackDraftContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
@@ -3756,53 +3902,10 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
);
|
||||
|
||||
const savesContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
{saveError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{saveError}
|
||||
</div>
|
||||
) : null}
|
||||
const createContent: ReactNode = createTabContent ?? fallbackCreateStartContent;
|
||||
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
<SectionHeader title="全部存档" detail="最近更新时间排序" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取存档..." />
|
||||
) : saveEntries.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{saveEntries.map((entry) => (
|
||||
<SaveArchiveCard
|
||||
key={entry.worldKey}
|
||||
entry={entry}
|
||||
loading={isResumingSaveWorldKey === entry.worldKey}
|
||||
onClick={() => onResumeSave(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有可恢复的存档,去首页开始一段新的游玩吧。" />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
尚未登录
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi?.openLoginModal()}
|
||||
className="platform-button platform-button--primary mt-4"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
const savesContent: ReactNode = (
|
||||
draftTabContent ?? fallbackDraftContent
|
||||
);
|
||||
|
||||
const profileContent: ReactNode = (
|
||||
@@ -4326,7 +4429,7 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
|
||||
const tabContentById = {
|
||||
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
|
||||
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
|
||||
category: categoryContent,
|
||||
create: createContent,
|
||||
saves: savesContent,
|
||||
@@ -4399,7 +4502,7 @@ export function RpgEntryHomeView({
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
@@ -4410,19 +4513,23 @@ export function RpgEntryHomeView({
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button platform-mobile-topbar__action shrink-0"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
|
||||
style={{
|
||||
borderColor: 'var(--platform-line-soft)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
@@ -4472,8 +4579,12 @@ export function RpgEntryHomeView({
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
onResumeSave={onResumeSave}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
@@ -4606,8 +4717,12 @@ export function RpgEntryHomeView({
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
onResumeSave={onResumeSave}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
|
||||
if (isTimeoutError(error)) {
|
||||
if (/智能创作/u.test(fallback)) {
|
||||
return '开启智能创作工作区超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
if (/拼图/u.test(fallback)) {
|
||||
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleWorkCoverSlides,
|
||||
buildPlatformWorldDisplayTags,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isVisualNovelGalleryEntry,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
|
||||
@@ -106,3 +110,25 @@ test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps visual novel work to platform gallery card with VN public code', () => {
|
||||
const card = mapVisualNovelWorkToPlatformGalleryCard({
|
||||
runtimeKind: 'visual-novel',
|
||||
profileId: 'vn-profile-demo-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
title: '雨夜终章',
|
||||
description: '失踪列车上的选择。',
|
||||
coverImageSrc: '/vn-cover.png',
|
||||
tags: ['悬疑', '列车'],
|
||||
publishStatus: 'published',
|
||||
publishReady: true,
|
||||
playCount: 7,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishedAt: '2026-05-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(isVisualNovelGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('VN-12345678');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -30,7 +32,8 @@ export type PlatformWorldCardLike =
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
@@ -132,12 +135,35 @@ export type PlatformSquareHoleGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformVisualNovelGalleryCard = {
|
||||
sourceType: 'visual-novel';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
@@ -169,6 +195,12 @@ export function isSquareHoleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'square-hole';
|
||||
}
|
||||
|
||||
export function isVisualNovelGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformVisualNovelGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -280,6 +312,32 @@ export function mapBigFishWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVisualNovelWorkToPlatformGalleryCard(
|
||||
work: VisualNovelWorkSummary,
|
||||
): PlatformVisualNovelGalleryCard {
|
||||
return {
|
||||
sourceType: 'visual-novel',
|
||||
workId: work.profileId,
|
||||
profileId: work.profileId,
|
||||
sourceSessionId: null,
|
||||
publicWorkCode: buildVisualNovelPublicWorkCode(work.profileId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: '玩家',
|
||||
worldName: work.title,
|
||||
subtitle: '视觉小说模板',
|
||||
summaryText: work.description,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: work.tags.length > 0 ? work.tags : ['视觉小说'],
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
return {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
@@ -452,6 +510,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['方洞'];
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['视觉小说'];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -534,6 +598,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationOperation,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import { type CustomWorldProfile,WorldType } from '../../types';
|
||||
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||||
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||||
|
||||
@@ -173,6 +173,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
setCustomWorldResultViewSource,
|
||||
setSelectionStage,
|
||||
setPlatformTabToCreate: vi.fn(),
|
||||
setPlatformTabToDraft: vi.fn(),
|
||||
setPlatformError: vi.fn(),
|
||||
appendBrowseHistoryEntry: vi.fn(async () => {}),
|
||||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||||
|
||||
@@ -329,14 +329,8 @@ export function useRpgEntryBootstrap(
|
||||
!hasInitialAgentSession &&
|
||||
!hasExplicitPlatformTabSelectionRef.current
|
||||
) {
|
||||
setPlatformTabState(
|
||||
isAuthenticated &&
|
||||
canReadProtectedData &&
|
||||
saveArchivesResult.status === 'fulfilled' &&
|
||||
saveArchivesResult.value.length > 0
|
||||
? 'saves'
|
||||
: 'home',
|
||||
);
|
||||
// 中文注释:saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页。
|
||||
setPlatformTabState('home');
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
|
||||
@@ -54,6 +54,7 @@ type UseRpgEntryLibraryDetailParams = {
|
||||
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
setPlatformTabToCreate: () => void;
|
||||
setPlatformTabToDraft: () => void;
|
||||
setPlatformError: (error: string | null) => void;
|
||||
appendBrowseHistoryEntry: (
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
@@ -106,6 +107,7 @@ export function useRpgEntryLibraryDetail(
|
||||
setCustomWorldResultViewSource,
|
||||
setSelectionStage,
|
||||
setPlatformTabToCreate,
|
||||
setPlatformTabToDraft,
|
||||
setPlatformError,
|
||||
appendBrowseHistoryEntry,
|
||||
refreshCustomWorldWorks,
|
||||
@@ -347,7 +349,7 @@ export function useRpgEntryLibraryDetail(
|
||||
setCustomWorldResultViewSource(null);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setPlatformError(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||||
);
|
||||
} else {
|
||||
setPlatformError(
|
||||
@@ -355,7 +357,7 @@ export function useRpgEntryLibraryDetail(
|
||||
);
|
||||
}
|
||||
|
||||
setPlatformTabToCreate();
|
||||
setPlatformTabToDraft();
|
||||
setSelectionStage('platform');
|
||||
return;
|
||||
}
|
||||
@@ -405,6 +407,7 @@ export function useRpgEntryLibraryDetail(
|
||||
setGeneratedCustomWorldProfile,
|
||||
setPlatformError,
|
||||
setPlatformTabToCreate,
|
||||
setPlatformTabToDraft,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectionStage,
|
||||
suppressAgentDraftResultAutoOpen,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
|
||||
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
|
||||
parseCreationAgentDocumentInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedParseCreationAgentDocumentInput = vi.mocked(
|
||||
await import('../../services/creation-agent/creationAgentDocumentInput'),
|
||||
);
|
||||
const mockedVisualNovelAssetClient = vi.mocked(
|
||||
await import('../../services/visual-novel-creation'),
|
||||
);
|
||||
|
||||
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={
|
||||
{
|
||||
user: { id: 'user-1' },
|
||||
platformTheme: 'light',
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={mockVisualNovelSession}
|
||||
onBack={() => {}}
|
||||
{...ui}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
|
||||
renderWorkspace({ onOpenResult });
|
||||
|
||||
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入结果页' }));
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
|
||||
});
|
||||
|
||||
test('visual novel workspace opens editable blank draft from blank source', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenResult = vi.fn();
|
||||
|
||||
renderWorkspace({ session: null, onOpenResult });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '空白' }));
|
||||
const openResultButtons = screen.getAllByRole('button', {
|
||||
name: '进入结果页',
|
||||
});
|
||||
await user.click(openResultButtons[0]!);
|
||||
|
||||
expect(onOpenResult).toHaveBeenCalledTimes(1);
|
||||
const session = onOpenResult.mock.calls[0]?.[0];
|
||||
expect(session?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.sourceMode).toBe('blank');
|
||||
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateSession = vi.fn();
|
||||
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
|
||||
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
|
||||
|
||||
parseMock.mockResolvedValue({
|
||||
document: {
|
||||
fileName: '世界设定.docx',
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
sizeBytes: 128,
|
||||
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
|
||||
sourceAssetId: 'asset-doc-source',
|
||||
},
|
||||
});
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-doc-1',
|
||||
assetKind: 'visual_novel_document',
|
||||
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
|
||||
});
|
||||
|
||||
renderWorkspace({ session: null, onCreateSession });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '文档' }));
|
||||
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
|
||||
const file = new File(['first chapter'], '世界设定.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
await user.upload(fileInput, file);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成底稿' }));
|
||||
|
||||
expect(parseMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadMock).toHaveBeenCalledTimes(1);
|
||||
expect(onCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceMode: 'document',
|
||||
sourceAssetIds: ['asset-doc-1'],
|
||||
seedText: expect.stringContaining('第一章'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Loader2,
|
||||
PenLine,
|
||||
Upload,
|
||||
Send,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CreateVisualNovelSessionRequest,
|
||||
ExecuteVisualNovelAgentActionRequest,
|
||||
SendVisualNovelMessageRequest,
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelSourceMode,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { parseCreationAgentDocumentInput } from '../../services/creation-agent/creationAgentDocumentInput';
|
||||
import { uploadVisualNovelAsset } from '../../services/visual-novel-creation';
|
||||
import {
|
||||
createBlankVisualNovelDraft,
|
||||
createMockVisualNovelSessionFromDraft,
|
||||
mockVisualNovelSession,
|
||||
} from '../visual-novel-runtime/visualNovelMockData';
|
||||
|
||||
type VisualNovelAgentWorkspaceProps = {
|
||||
session?: VisualNovelAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
streamingReplyText?: string;
|
||||
onBack: () => void;
|
||||
onCreateSession?: (payload: CreateVisualNovelSessionRequest) => void;
|
||||
onSubmitMessage?: (payload: SendVisualNovelMessageRequest) => void;
|
||||
onExecuteAction?: (payload: ExecuteVisualNovelAgentActionRequest) => void;
|
||||
onOpenResult?: (session: VisualNovelAgentSessionSnapshot) => void;
|
||||
};
|
||||
|
||||
const SOURCE_OPTIONS: Array<{
|
||||
id: VisualNovelSourceMode;
|
||||
label: string;
|
||||
icon: typeof PenLine;
|
||||
}> = [
|
||||
{ id: 'idea', label: '一句话', icon: Sparkles },
|
||||
{ id: 'document', label: '文档', icon: FileText },
|
||||
{ id: 'blank', label: '空白', icon: PenLine },
|
||||
];
|
||||
|
||||
function buildClientMessageId() {
|
||||
return `vn-message-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
function clampDocumentSeedText(value: string) {
|
||||
return value.trim().replace(/\s+/gu, ' ').slice(0, 4000);
|
||||
}
|
||||
|
||||
function VisualNovelSourceButton({
|
||||
active,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
icon: typeof PenLine;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`flex min-h-16 min-w-0 items-center gap-3 rounded-[1.1rem] border px-3 text-left transition ${
|
||||
active
|
||||
? 'border-[var(--platform-button-primary-border)] bg-[var(--platform-nav-active-fill)] text-[var(--platform-text-strong)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)] hover:bg-white'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-white/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualNovelAgentWorkspace({
|
||||
session = mockVisualNovelSession,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
streamingReplyText = '',
|
||||
onBack,
|
||||
onCreateSession,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
onOpenResult,
|
||||
}: VisualNovelAgentWorkspaceProps) {
|
||||
const [sourceMode, setSourceMode] = useState<VisualNovelSourceMode>(
|
||||
session?.sourceMode ?? 'idea',
|
||||
);
|
||||
const [seedText, setSeedText] = useState(
|
||||
session?.messages.find((message) => message.role === 'user')?.text ?? '',
|
||||
);
|
||||
const [documentAssetId, setDocumentAssetId] = useState(
|
||||
session?.draft?.sourceAssetIds[0] ?? '',
|
||||
);
|
||||
const [documentAssetLabel, setDocumentAssetLabel] = useState('');
|
||||
const [documentUploadError, setDocumentUploadError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isDocumentUploading, setIsDocumentUploading] = useState(false);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const displaySession = session ?? mockVisualNovelSession;
|
||||
const draft = displaySession.draft;
|
||||
const authUi = useAuthUi();
|
||||
const documentFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canStart =
|
||||
!isBusy &&
|
||||
((sourceMode === 'blank') ||
|
||||
(sourceMode === 'idea' && Boolean(seedText.trim())) ||
|
||||
(sourceMode === 'document' && Boolean(documentAssetId.trim())));
|
||||
const canSend = !isBusy && messageText.trim();
|
||||
const pendingAction = displaySession.pendingAction;
|
||||
const progressItems = useMemo(
|
||||
() => [
|
||||
{ label: '世界观', value: draft?.world.title || '-' },
|
||||
{ label: '角色', value: `${draft?.characters.length ?? 0}` },
|
||||
{ label: '场景', value: `${draft?.scenes.length ?? 0}` },
|
||||
{ label: '阶段', value: `${draft?.storyPhases.length ?? 0}` },
|
||||
],
|
||||
[draft],
|
||||
);
|
||||
|
||||
const startDraft = () => {
|
||||
if (!canStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceMode === 'blank') {
|
||||
const blankSession = createMockVisualNovelSessionFromDraft(
|
||||
createBlankVisualNovelDraft('blank'),
|
||||
);
|
||||
onOpenResult?.(blankSession);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateSession?.({
|
||||
sourceMode,
|
||||
seedText: seedText.trim() || null,
|
||||
sourceAssetIds:
|
||||
sourceMode === 'document' && documentAssetId.trim()
|
||||
? [documentAssetId.trim()]
|
||||
: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDocumentUploading(true);
|
||||
setDocumentUploadError(null);
|
||||
try {
|
||||
const parsed = await parseCreationAgentDocumentInput(file);
|
||||
const uploadedAsset = await uploadVisualNovelAsset({
|
||||
kind: 'document',
|
||||
file,
|
||||
ownerUserId: authUi?.user?.id ?? null,
|
||||
});
|
||||
setDocumentAssetId(uploadedAsset.assetObjectId);
|
||||
setDocumentAssetLabel(file.name.trim() || parsed.document.fileName);
|
||||
setSeedText(clampDocumentSeedText(parsed.document.text));
|
||||
} catch (uploadError) {
|
||||
setDocumentUploadError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '文档上传失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsDocumentUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitMessage = () => {
|
||||
const text = messageText.trim();
|
||||
if (!text || isBusy) {
|
||||
return;
|
||||
}
|
||||
onSubmitMessage?.({
|
||||
clientMessageId: buildClientMessageId(),
|
||||
text,
|
||||
});
|
||||
setMessageText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(20rem,0.55fr)]">
|
||||
<section className="platform-subpanel rounded-[1.45rem] p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h1 className="m-0 text-3xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
视觉小说
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<VisualNovelSourceButton
|
||||
key={option.id}
|
||||
active={sourceMode === option.id}
|
||||
disabled={isBusy}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => setSourceMode(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceMode === 'document' ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isDocumentUploading}
|
||||
onClick={() => documentFileInputRef.current?.click()}
|
||||
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
{isDocumentUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{documentAssetId ? '重新上传文档' : '上传平台文档'}
|
||||
</button>
|
||||
{documentAssetId ? (
|
||||
<span className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2 text-xs font-semibold text-[var(--platform-text-base)]">
|
||||
{documentAssetLabel || '已绑定平台文档'}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
ref={documentFileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.markdown,.docx,.csv,.json"
|
||||
disabled={isBusy || isDocumentUploading}
|
||||
onChange={(event) => {
|
||||
void handleDocumentFileChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
aria-label="上传文档"
|
||||
/>
|
||||
</div>
|
||||
{documentAssetId ? (
|
||||
<div className="truncate rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
资产 ID:{documentAssetId}
|
||||
</div>
|
||||
) : null}
|
||||
{documentUploadError ? (
|
||||
<div className="rounded-[1rem] border border-rose-200/50 bg-rose-500/10 px-4 py-2 text-sm leading-6 text-rose-700">
|
||||
{documentUploadError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="sr-only">创作想法</span>
|
||||
<textarea
|
||||
value={seedText}
|
||||
disabled={isBusy || sourceMode === 'blank'}
|
||||
rows={8}
|
||||
onChange={(event) => setSeedText(event.target.value)}
|
||||
placeholder={
|
||||
sourceMode === 'document'
|
||||
? '粘贴文档摘要或选择平台文档资产'
|
||||
: sourceMode === 'blank'
|
||||
? '空白创建将直接进入结果页'
|
||||
: '雪夜列车、旧电台、失踪乘客'
|
||||
}
|
||||
className="w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/88 px-4 py-4 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 disabled:opacity-60"
|
||||
aria-label="创作想法"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canStart}
|
||||
onClick={startDraft}
|
||||
className="platform-button platform-button--primary min-h-11 px-5 py-3"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{sourceMode === 'blank' ? '进入结果页' : '生成底稿'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="platform-subpanel flex min-h-[22rem] flex-col rounded-[1.45rem] p-4 sm:p-5">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{progressItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-0 rounded-[0.9rem] bg-white/72 px-2 py-2 text-center"
|
||||
>
|
||||
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
{displaySession.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`max-w-[88%] rounded-[1.1rem] px-3 py-2 text-sm leading-6 ${
|
||||
message.role === 'user'
|
||||
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
|
||||
: 'bg-white/78 text-[var(--platform-text-strong)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
))}
|
||||
{streamingReplyText ? (
|
||||
<div className="max-w-[88%] rounded-[1.1rem] bg-white/78 px-3 py-2 text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{streamingReplyText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pendingAction ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onExecuteAction?.({
|
||||
actionId: pendingAction.actionId,
|
||||
kind: pendingAction.kind,
|
||||
targetId: pendingAction.targetId ?? null,
|
||||
payload: pendingAction.payload,
|
||||
})
|
||||
}
|
||||
className="platform-button platform-button--secondary mt-4 min-h-11 justify-center px-4 py-3"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{pendingAction.label || '执行操作'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input
|
||||
value={messageText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setMessageText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitMessage();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-4 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="补充设定"
|
||||
aria-label="补充设定"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSend}
|
||||
onClick={submitMessage}
|
||||
className="platform-icon-button h-11 w-11"
|
||||
aria-label="发送"
|
||||
title="发送"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !displaySession.draft}
|
||||
onClick={() => onOpenResult?.(displaySession)}
|
||||
className="platform-button platform-button--primary min-h-11 px-5 py-3"
|
||||
>
|
||||
进入结果页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelAgentWorkspace;
|
||||
@@ -0,0 +1,130 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { VisualNovelResultView } from './VisualNovelResultView';
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
resolveAssetReadUrl: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
|
||||
test('visual novel result opens complex editors as a dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<VisualNovelResultView draft={mockVisualNovelDraft} onBack={() => {}} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '角色' }));
|
||||
await user.click(screen.getByRole('button', { name: /林遥/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '林遥' });
|
||||
expect(within(dialog).getByDisplayValue('林遥')).toBeTruthy();
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
});
|
||||
|
||||
test('visual novel result exposes test run action with current draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<VisualNovelResultView
|
||||
draft={mockVisualNovelDraft}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
expect(onStartTestRun.mock.calls[0]?.[0].workTitle).toBe('雪线电台');
|
||||
});
|
||||
|
||||
test('visual novel result sends edited character draft to save and test run', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<VisualNovelResultView
|
||||
draft={mockVisualNovelDraft}
|
||||
onBack={() => {}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '角色' }));
|
||||
await user.click(screen.getByRole('button', { name: /林遥/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '林遥' });
|
||||
const nameInput = within(dialog).getByDisplayValue('林遥');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, '林遥改');
|
||||
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const saveButtons = screen.getAllByRole('button', { name: '保存草稿' });
|
||||
await user.click(saveButtons[1]!);
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onSaveDraft.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
|
||||
expect(onStartTestRun.mock.calls[0]?.[0].characters[0]?.name).toBe('林遥改');
|
||||
});
|
||||
|
||||
test('visual novel result uploads scene and character assets into platform references', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
const uploadMock = vi.mocked(
|
||||
await import('../../services/visual-novel-creation'),
|
||||
).uploadVisualNovelAsset;
|
||||
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-scene-1',
|
||||
assetKind: 'scene_image',
|
||||
objectKey: 'generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
});
|
||||
|
||||
render(
|
||||
<VisualNovelResultView
|
||||
draft={mockVisualNovelDraft}
|
||||
onBack={() => {}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景' }));
|
||||
await user.click(screen.getByRole('button', { name: /风雪站台/u }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '风雪站台' });
|
||||
const backgroundButtons = within(dialog).getAllByRole('button', {
|
||||
name: '背景图',
|
||||
});
|
||||
await user.click(backgroundButtons[0]!);
|
||||
|
||||
const fileInput = within(screen.getByRole('dialog', { name: '背景图' })).getByLabelText(
|
||||
'上传背景图文件',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(
|
||||
fileInput,
|
||||
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
|
||||
);
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '关闭' }));
|
||||
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalled();
|
||||
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toContain(
|
||||
'/generated-custom-world-scenes/',
|
||||
);
|
||||
});
|
||||
1780
src/components/visual-novel-result/VisualNovelResultView.tsx
Normal file
1780
src/components/visual-novel-result/VisualNovelResultView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
type VisualNovelAttributePanelProps = {
|
||||
run: VisualNovelRunSnapshot;
|
||||
};
|
||||
|
||||
export function VisualNovelAttributePanel({ run }: VisualNovelAttributePanelProps) {
|
||||
const metrics = Object.entries(run.metrics);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无属性
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{metrics.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
<span>{key}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--platform-track-fill)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--platform-button-primary-fill)]"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelAttributePanel;
|
||||
112
src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx
Normal file
112
src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
VisualNovelHistoryEntry,
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeStep,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
type VisualNovelHistoryPanelProps = {
|
||||
run: VisualNovelRunSnapshot;
|
||||
allowRegeneration?: boolean;
|
||||
isBusy?: boolean;
|
||||
onRegenerateHistoryEntry?: (entryId: string) => void;
|
||||
};
|
||||
|
||||
function renderStepText(step: VisualNovelRuntimeStep) {
|
||||
if (step.type === 'narration') {
|
||||
return step.text;
|
||||
}
|
||||
|
||||
if (step.type === 'dialogue') {
|
||||
return `${step.characterName}: ${step.text}`;
|
||||
}
|
||||
|
||||
if (step.type === 'transition') {
|
||||
return step.text ?? '';
|
||||
}
|
||||
|
||||
if (step.type === 'choice') {
|
||||
return step.choices.map((choice) => choice.text).join(' / ');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function VisualNovelHistoryEntryCard({
|
||||
entry,
|
||||
allowRegeneration,
|
||||
isBusy,
|
||||
onRegenerateHistoryEntry,
|
||||
}: {
|
||||
entry: VisualNovelHistoryEntry;
|
||||
allowRegeneration: boolean;
|
||||
isBusy: boolean;
|
||||
onRegenerateHistoryEntry?: (entryId: string) => void;
|
||||
}) {
|
||||
const visibleStepTexts = entry.steps.map(renderStepText).filter(Boolean);
|
||||
const canRegenerate =
|
||||
allowRegeneration && entry.source === 'assistant' && onRegenerateHistoryEntry;
|
||||
|
||||
return (
|
||||
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
<span>#{entry.turnIndex}</span>
|
||||
<span>{entry.source === 'player' ? '玩家' : '故事'}</span>
|
||||
</div>
|
||||
{entry.actionText ? (
|
||||
<p className="m-0 break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{entry.actionText}
|
||||
</p>
|
||||
) : null}
|
||||
{visibleStepTexts.map((text, index) => (
|
||||
<p
|
||||
key={`${entry.entryId}-${index}`}
|
||||
className="m-0 mt-2 break-words text-sm leading-6 text-[var(--platform-text-base)]"
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
{canRegenerate ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onRegenerateHistoryEntry(entry.entryId)}
|
||||
className="mt-3 inline-flex min-h-9 items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/86 px-3 text-xs font-black text-[var(--platform-text-strong)] transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>重生成</span>
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisualNovelHistoryPanel({
|
||||
run,
|
||||
allowRegeneration = false,
|
||||
isBusy = false,
|
||||
onRegenerateHistoryEntry,
|
||||
}: VisualNovelHistoryPanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{run.history.length > 0 ? (
|
||||
run.history.map((entry) => (
|
||||
<VisualNovelHistoryEntryCard
|
||||
key={entry.entryId}
|
||||
entry={entry}
|
||||
allowRegeneration={allowRegeneration}
|
||||
isBusy={isBusy}
|
||||
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无历史
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelHistoryPanel;
|
||||
142
src/components/visual-novel-runtime/VisualNovelRuntimePanels.tsx
Normal file
142
src/components/visual-novel-runtime/VisualNovelRuntimePanels.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { BarChart3, Bookmark, History, Settings, X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { VisualNovelAttributePanel } from './VisualNovelAttributePanel';
|
||||
import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel';
|
||||
import { VisualNovelSavePanel } from './VisualNovelSavePanel';
|
||||
import { VisualNovelSettingsPanel } from './VisualNovelSettingsPanel';
|
||||
|
||||
export type VisualNovelRuntimePanelKind =
|
||||
| 'history'
|
||||
| 'save'
|
||||
| 'settings'
|
||||
| 'attributes';
|
||||
|
||||
type VisualNovelRuntimePanelProps = {
|
||||
kind: VisualNovelRuntimePanelKind;
|
||||
draft: VisualNovelResultDraft;
|
||||
run: VisualNovelRunSnapshot;
|
||||
isBusy?: boolean;
|
||||
isSaving?: boolean;
|
||||
isLoadingArchives?: boolean;
|
||||
resumingWorldKey?: string | null;
|
||||
saveArchives?: ProfileSaveArchiveSummary[];
|
||||
onClose: () => void;
|
||||
onRegenerateHistoryEntry?: (entryId: string) => void;
|
||||
onSaveRun?: () => void;
|
||||
onResumeSaveArchive?: (worldKey: string) => void;
|
||||
textModeEnabled?: boolean;
|
||||
onTextModeChange?: (enabled: boolean) => void;
|
||||
allowRegeneration?: boolean;
|
||||
};
|
||||
|
||||
const PANEL_META: Record<
|
||||
VisualNovelRuntimePanelKind,
|
||||
{ title: string; icon: typeof History }
|
||||
> = {
|
||||
history: { title: '历史', icon: History },
|
||||
save: { title: '存档', icon: Bookmark },
|
||||
settings: { title: '设置', icon: Settings },
|
||||
attributes: { title: '属性', icon: BarChart3 },
|
||||
};
|
||||
|
||||
export function VisualNovelRuntimePanel({
|
||||
kind,
|
||||
draft,
|
||||
run,
|
||||
isBusy = false,
|
||||
isSaving = false,
|
||||
isLoadingArchives = false,
|
||||
resumingWorldKey = null,
|
||||
saveArchives = [],
|
||||
onClose,
|
||||
onRegenerateHistoryEntry,
|
||||
onSaveRun,
|
||||
onResumeSaveArchive,
|
||||
textModeEnabled = false,
|
||||
onTextModeChange,
|
||||
allowRegeneration = false,
|
||||
}: VisualNovelRuntimePanelProps) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = PANEL_META[kind];
|
||||
const Icon = meta.icon;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="platform-overlay fixed inset-0 z-[150] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={meta.title}
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(84vh,40rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<h2 className="truncate text-base font-black text-[var(--platform-text-strong)]">
|
||||
{meta.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-icon-button h-9 w-9"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{kind === 'history' ? (
|
||||
<VisualNovelHistoryPanel
|
||||
run={run}
|
||||
allowRegeneration={allowRegeneration}
|
||||
isBusy={isBusy}
|
||||
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'save' ? (
|
||||
<VisualNovelSavePanel
|
||||
run={run}
|
||||
saveArchives={saveArchives}
|
||||
isSaving={isSaving}
|
||||
isLoadingArchives={isLoadingArchives}
|
||||
resumingWorldKey={resumingWorldKey}
|
||||
onSaveRun={onSaveRun}
|
||||
onResumeSaveArchive={onResumeSaveArchive}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'settings' ? (
|
||||
<VisualNovelSettingsPanel
|
||||
draft={draft}
|
||||
textModeEnabled={textModeEnabled}
|
||||
onTextModeChange={onTextModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'attributes' ? <VisualNovelAttributePanel run={run} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelRuntimePanel;
|
||||
@@ -0,0 +1,165 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from './visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
|
||||
import { VisualNovelRuntimeShell } from './VisualNovelRuntimeShell';
|
||||
|
||||
test('visual novel runtime renders mock play surface and opens panels as dialogs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={mockVisualNovelRun}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('风雪站台')).toBeTruthy();
|
||||
expect(screen.getAllByText('林遥').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '历史' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '历史' });
|
||||
expect(within(dialog).getByText('#1')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('visual novel runtime submits free text action with client event id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitAction = vi.fn();
|
||||
|
||||
render(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={mockVisualNovelRun}
|
||||
onBack={() => {}}
|
||||
onSubmitAction={onSubmitAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText('输入行动'), '检查广播柜');
|
||||
await user.click(screen.getByRole('button', { name: '发送行动' }));
|
||||
|
||||
expect(onSubmitAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionKind: 'free_text',
|
||||
text: '检查广播柜',
|
||||
}),
|
||||
);
|
||||
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
|
||||
/^vn-free-text-/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('visual novel runtime submits choice and continue actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitAction = vi.fn();
|
||||
const onContinue = vi.fn();
|
||||
const runWithoutChoices = {
|
||||
...mockVisualNovelRun,
|
||||
availableChoices: [],
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={mockVisualNovelRun}
|
||||
onBack={() => {}}
|
||||
onSubmitAction={onSubmitAction}
|
||||
onContinue={onContinue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '靠近广播柜,确认频段来源。' }));
|
||||
expect(onSubmitAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionKind: 'choice',
|
||||
choiceId: 'vn-choice-radio',
|
||||
}),
|
||||
);
|
||||
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
|
||||
/^vn-choice-/u,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={runWithoutChoices}
|
||||
onBack={() => {}}
|
||||
onSubmitAction={onSubmitAction}
|
||||
onContinue={onContinue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '继续' }));
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('visual novel runtime panels call regeneration and platform archive actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRegenerateHistoryEntry = vi.fn();
|
||||
const onSaveRun = vi.fn();
|
||||
const onResumeSaveArchive = vi.fn();
|
||||
|
||||
render(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={mockVisualNovelRun}
|
||||
onBack={() => {}}
|
||||
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
|
||||
onSaveRun={onSaveRun}
|
||||
onResumeSaveArchive={onResumeSaveArchive}
|
||||
saveArchives={[
|
||||
{
|
||||
worldKey: 'visual-novel:archive-1',
|
||||
ownerUserId: 'mock-user',
|
||||
profileId: 'vn-profile-mock-1',
|
||||
worldType: 'visual-novel',
|
||||
worldName: '雪线电台',
|
||||
subtitle: '风雪站台',
|
||||
summaryText: '第 2 回合',
|
||||
coverImageSrc: null,
|
||||
lastPlayedAt: '2026-05-05T12:00:00.000Z',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '历史' }));
|
||||
await user.click(screen.getByRole('button', { name: '重生成' }));
|
||||
expect(onRegenerateHistoryEntry).toHaveBeenCalledWith('vn-history-1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
await user.click(screen.getByRole('button', { name: '存档' }));
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
expect(onSaveRun).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByText('雪线电台'));
|
||||
expect(onResumeSaveArchive).toHaveBeenCalledWith('visual-novel:archive-1');
|
||||
});
|
||||
|
||||
test('visual novel runtime shows raw text only as transient stream text', () => {
|
||||
const transientText = '这是临时流式文本';
|
||||
|
||||
render(
|
||||
<VisualNovelRuntimeShell
|
||||
draft={mockVisualNovelDraft}
|
||||
run={mockVisualNovelRun}
|
||||
streamingText={transientText}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(transientText)).toBeTruthy();
|
||||
const textModeBlocks = screen.queryAllByText((content, element) => {
|
||||
return Boolean(
|
||||
element?.className.toString().includes('whitespace-pre-line') &&
|
||||
content.includes(transientText),
|
||||
);
|
||||
});
|
||||
expect(textModeBlocks).toHaveLength(0);
|
||||
});
|
||||
603
src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx
Normal file
603
src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
VisualNovelCharacterDraft,
|
||||
VisualNovelChoiceDraft,
|
||||
VisualNovelDialogueStep,
|
||||
VisualNovelNarrationStep,
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeActionRequest,
|
||||
VisualNovelRuntimeStep,
|
||||
VisualNovelSceneChangeStep,
|
||||
VisualNovelTransitionStep,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController';
|
||||
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
|
||||
import {
|
||||
VisualNovelRuntimePanel,
|
||||
type VisualNovelRuntimePanelKind,
|
||||
} from './VisualNovelRuntimePanels';
|
||||
|
||||
type VisualNovelRuntimeShellProps = {
|
||||
draft?: VisualNovelResultDraft | null;
|
||||
run?: VisualNovelRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
isSaving?: boolean;
|
||||
isLoadingArchives?: boolean;
|
||||
resumingWorldKey?: string | null;
|
||||
error?: string | null;
|
||||
streamedSteps?: VisualNovelRuntimeStep[];
|
||||
streamingText?: string;
|
||||
saveArchives?: ProfileSaveArchiveSummary[];
|
||||
onBack: () => void;
|
||||
onSubmitAction?: (payload: VisualNovelRuntimeActionRequest) => void;
|
||||
onContinue?: () => void;
|
||||
onRegenerateHistoryEntry?: (entryId: string) => void;
|
||||
onSaveRun?: () => void;
|
||||
onResumeSaveArchive?: (worldKey: string) => void;
|
||||
onTextModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type VisualNovelDisplayState = {
|
||||
sceneStep: VisualNovelSceneChangeStep | null;
|
||||
narrationStep: VisualNovelNarrationStep | null;
|
||||
dialogueStep: VisualNovelDialogueStep | null;
|
||||
transitionStep: VisualNovelTransitionStep | null;
|
||||
choiceStep: VisualNovelRuntimeStep | null;
|
||||
};
|
||||
|
||||
function buildClientEventId(kind: string) {
|
||||
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
function collectRuntimeSteps(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
) {
|
||||
return [
|
||||
...run.history.flatMap((entry) => entry.steps),
|
||||
...streamedSteps,
|
||||
];
|
||||
}
|
||||
|
||||
function resolveLatestStep<T extends VisualNovelRuntimeStep['type']>(
|
||||
steps: VisualNovelRuntimeStep[],
|
||||
type: T,
|
||||
) {
|
||||
return [...steps].reverse().find((step) => step.type === type) as
|
||||
| Extract<VisualNovelRuntimeStep, { type: T }>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function resolveDisplayState(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
): VisualNovelDisplayState {
|
||||
const steps = collectRuntimeSteps(run, streamedSteps);
|
||||
|
||||
return {
|
||||
sceneStep: resolveLatestStep(steps, 'scene_change') ?? null,
|
||||
narrationStep: resolveLatestStep(steps, 'narration') ?? null,
|
||||
dialogueStep: resolveLatestStep(steps, 'dialogue') ?? null,
|
||||
transitionStep: resolveLatestStep(steps, 'transition') ?? null,
|
||||
choiceStep: resolveLatestStep(steps, 'choice') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSceneId(
|
||||
run: VisualNovelRunSnapshot,
|
||||
sceneStep: VisualNovelSceneChangeStep | null,
|
||||
) {
|
||||
return sceneStep?.sceneId ?? run.currentSceneId;
|
||||
}
|
||||
|
||||
function resolveSceneName(draft: VisualNovelResultDraft, sceneId: string | null) {
|
||||
return draft.scenes.find((scene) => scene.sceneId === sceneId)?.name ?? '';
|
||||
}
|
||||
|
||||
function resolveSceneBackground(
|
||||
draft: VisualNovelResultDraft,
|
||||
sceneId: string | null,
|
||||
sceneStep: VisualNovelSceneChangeStep | null,
|
||||
) {
|
||||
return (
|
||||
sceneStep?.backgroundImageSrc ??
|
||||
draft.scenes.find((scene) => scene.sceneId === sceneId)?.backgroundImageSrc ??
|
||||
draft.coverImageSrc
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVisibleCharacters(
|
||||
draft: VisualNovelResultDraft,
|
||||
run: VisualNovelRunSnapshot,
|
||||
latestDialogue: VisualNovelDialogueStep | null,
|
||||
) {
|
||||
const visibleIds = new Set(run.visibleCharacterIds);
|
||||
if (latestDialogue?.characterId) {
|
||||
visibleIds.add(latestDialogue.characterId);
|
||||
}
|
||||
|
||||
return Array.from(visibleIds)
|
||||
.map((characterId) =>
|
||||
draft.characters.find((character) => character.characterId === characterId),
|
||||
)
|
||||
.filter((character): character is VisualNovelCharacterDraft =>
|
||||
Boolean(character),
|
||||
)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveCharacterImage(character: VisualNovelCharacterDraft) {
|
||||
if (character.imageAssets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
character.imageAssets.find(
|
||||
(asset) => asset.expression === character.defaultExpression,
|
||||
)?.imageSrc ?? character.imageAssets[0]?.imageSrc ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelCharacterStandee({
|
||||
character,
|
||||
index,
|
||||
active,
|
||||
}: {
|
||||
character: VisualNovelCharacterDraft;
|
||||
index: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const imageSrc = resolveCharacterImage(character);
|
||||
const palette =
|
||||
index % 2 === 0
|
||||
? 'from-sky-100/90 via-slate-100/78 to-slate-300/72'
|
||||
: 'from-rose-100/90 via-zinc-100/78 to-stone-300/72';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 flex-col items-center transition ${
|
||||
active ? 'scale-100 opacity-100' : 'scale-[0.96] opacity-78'
|
||||
}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
alt={character.name}
|
||||
className="h-[min(48dvh,21rem)] w-[9rem] object-contain drop-shadow-[0_26px_54px_rgba(0,0,0,0.42)] sm:w-[12rem]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`h-[min(44dvh,18rem)] w-[7.5rem] rounded-t-full border border-white/18 bg-gradient-to-b ${palette} shadow-[0_26px_58px_rgba(15,23,42,0.32)] sm:w-[10rem]`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-w-[8rem] truncate rounded-full border border-white/16 bg-black/28 px-3 py-1 text-xs font-black text-white backdrop-blur">
|
||||
{character.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTextModeLines(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
) {
|
||||
return [...run.history.flatMap((entry) => entry.steps), ...streamedSteps]
|
||||
.filter((step) => step.type === 'narration' || step.type === 'dialogue')
|
||||
.map((step) =>
|
||||
step.type === 'dialogue'
|
||||
? `${step.characterName}: ${step.text}`
|
||||
: step.text,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function resolveChoices(
|
||||
run: VisualNovelRunSnapshot,
|
||||
choiceStep: VisualNovelRuntimeStep | null,
|
||||
) {
|
||||
if (choiceStep?.type === 'choice' && choiceStep.choices.length > 0) {
|
||||
return choiceStep.choices;
|
||||
}
|
||||
|
||||
return run.availableChoices;
|
||||
}
|
||||
|
||||
export function VisualNovelRuntimeShell({
|
||||
draft = mockVisualNovelDraft,
|
||||
run = mockVisualNovelRun,
|
||||
isBusy = false,
|
||||
isSaving = false,
|
||||
isLoadingArchives = false,
|
||||
resumingWorldKey = null,
|
||||
error,
|
||||
streamedSteps = [],
|
||||
streamingText = '',
|
||||
saveArchives,
|
||||
onBack,
|
||||
onSubmitAction,
|
||||
onContinue,
|
||||
onRegenerateHistoryEntry,
|
||||
onSaveRun,
|
||||
onResumeSaveArchive,
|
||||
onTextModeChange,
|
||||
}: VisualNovelRuntimeShellProps) {
|
||||
const [activePanel, setActivePanel] =
|
||||
useState<VisualNovelRuntimePanelKind | null>(null);
|
||||
const [freeText, setFreeText] = useState('');
|
||||
const [localTextModeEnabled, setLocalTextModeEnabled] = useState(
|
||||
run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false,
|
||||
);
|
||||
const displayDraft = draft ?? mockVisualNovelDraft;
|
||||
const baseRun = run ?? mockVisualNovelRun;
|
||||
const runtimeController = useVisualNovelRuntimeController({
|
||||
draft: displayDraft,
|
||||
initialRun: baseRun,
|
||||
profileId: displayDraft.profileId,
|
||||
autoStart: false,
|
||||
});
|
||||
const displayRun = runtimeController.run ?? baseRun;
|
||||
const displayBusy = isBusy || runtimeController.isBusy;
|
||||
const displaySaving = isSaving || runtimeController.isSaving;
|
||||
const displayLoadingArchives =
|
||||
isLoadingArchives || runtimeController.isLoadingArchives;
|
||||
const displayResumingWorldKey =
|
||||
resumingWorldKey ?? runtimeController.resumingWorldKey;
|
||||
const displayError = error ?? runtimeController.error;
|
||||
const displaySaveArchives = saveArchives ?? runtimeController.saveArchives;
|
||||
const displayStreamedSteps =
|
||||
streamedSteps.length > 0 ? streamedSteps : runtimeController.streamedSteps;
|
||||
const displayStreamingText =
|
||||
streamingText || runtimeController.streamingText;
|
||||
const displayState = useMemo(
|
||||
() => resolveDisplayState(displayRun, displayStreamedSteps),
|
||||
[displayRun, displayStreamedSteps],
|
||||
);
|
||||
const textModeEnabled = localTextModeEnabled;
|
||||
const sceneId = resolveSceneId(displayRun, displayState.sceneStep);
|
||||
const sceneName = resolveSceneName(displayDraft, sceneId);
|
||||
const backgroundImageSrc = resolveSceneBackground(
|
||||
displayDraft,
|
||||
sceneId,
|
||||
displayState.sceneStep,
|
||||
);
|
||||
const visibleCharacters = useMemo(
|
||||
() =>
|
||||
resolveVisibleCharacters(
|
||||
displayDraft,
|
||||
displayRun,
|
||||
displayState.dialogueStep,
|
||||
),
|
||||
[displayDraft, displayRun, displayState.dialogueStep],
|
||||
);
|
||||
const choices = resolveChoices(displayRun, displayState.choiceStep);
|
||||
const canSubmitFreeText =
|
||||
displayDraft.runtimeConfig.allowFreeTextAction &&
|
||||
freeText.trim() &&
|
||||
!displayBusy;
|
||||
const canShowAttributes =
|
||||
displayDraft.runtimeConfig.attributePanelMode !== 'off';
|
||||
const primarySpeaker =
|
||||
displayState.dialogueStep?.characterName ||
|
||||
(displayState.narrationStep ? '旁白' : displayDraft.workTitle);
|
||||
const primaryText =
|
||||
displayState.dialogueStep?.text ||
|
||||
displayState.narrationStep?.text ||
|
||||
displayDraft.opening.narration ||
|
||||
displayDraft.workDescription;
|
||||
const textModeLines = buildTextModeLines(displayRun, displayStreamedSteps);
|
||||
|
||||
const loadRuntimeSaveArchives = runtimeController.loadSaveArchives;
|
||||
|
||||
useEffect(() => {
|
||||
if (activePanel === 'save' && !saveArchives) {
|
||||
void loadRuntimeSaveArchives();
|
||||
}
|
||||
}, [activePanel, loadRuntimeSaveArchives, saveArchives]);
|
||||
|
||||
const updateTextMode = (enabled: boolean) => {
|
||||
setLocalTextModeEnabled(enabled);
|
||||
onTextModeChange?.(enabled);
|
||||
};
|
||||
|
||||
const submitChoice = (choice: VisualNovelChoiceDraft) => {
|
||||
if (displayBusy) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
actionKind: 'choice',
|
||||
choiceId: choice.choiceId,
|
||||
clientEventId: buildClientEventId('choice'),
|
||||
} satisfies VisualNovelRuntimeActionRequest;
|
||||
if (onSubmitAction) {
|
||||
onSubmitAction(payload);
|
||||
return;
|
||||
}
|
||||
void runtimeController.submitAction(payload);
|
||||
};
|
||||
|
||||
const submitFreeText = () => {
|
||||
const text = freeText.trim();
|
||||
if (!text || displayBusy) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
actionKind: 'free_text',
|
||||
text,
|
||||
clientEventId: buildClientEventId('free-text'),
|
||||
} satisfies VisualNovelRuntimeActionRequest;
|
||||
if (onSubmitAction) {
|
||||
onSubmitAction(payload);
|
||||
} else {
|
||||
void runtimeController.submitAction(payload);
|
||||
}
|
||||
setFreeText('');
|
||||
};
|
||||
|
||||
const continueRuntime = () => {
|
||||
if (displayBusy) {
|
||||
return;
|
||||
}
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
void runtimeController.continueRun();
|
||||
};
|
||||
|
||||
const regenerateHistoryEntry = (entryId: string) => {
|
||||
if (onRegenerateHistoryEntry) {
|
||||
onRegenerateHistoryEntry(entryId);
|
||||
return;
|
||||
}
|
||||
void runtimeController.regenerateFromHistory(entryId);
|
||||
};
|
||||
|
||||
const saveRuntime = () => {
|
||||
if (onSaveRun) {
|
||||
onSaveRun();
|
||||
return;
|
||||
}
|
||||
void runtimeController.saveCurrentRun();
|
||||
};
|
||||
|
||||
const resumeArchive = (worldKey: string) => {
|
||||
if (onResumeSaveArchive) {
|
||||
onResumeSaveArchive(worldKey);
|
||||
return;
|
||||
}
|
||||
void runtimeController.resumeSaveArchive(worldKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#111827] text-white">
|
||||
{backgroundImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.10),rgba(15,23,42,0.92)),linear-gradient(135deg,#162235_0%,#334155_46%,#111827_100%)]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.88)),linear-gradient(90deg,rgba(0,0,0,0.32),transparent_36%,rgba(0,0,0,0.38))]" />
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100vw',
|
||||
width: 'min(100vw, 64rem)',
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="min-w-0 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
||||
<span className="block max-w-[12rem] truncate sm:max-w-[22rem]">
|
||||
{sceneName || displayDraft.workTitle}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur ${
|
||||
textModeEnabled ? 'ring-2 ring-white/40' : ''
|
||||
}`}
|
||||
onClick={() => updateTextMode(!textModeEnabled)}
|
||||
aria-label="文本模式"
|
||||
title="文本模式"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="relative mt-3 flex min-h-0 flex-1 items-end justify-center overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 px-3 pb-3 pt-6 shadow-[0_20px_54px_rgba(0,0,0,0.28)] backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),transparent_34%),linear-gradient(90deg,rgba(15,23,42,0.22),transparent_36%,rgba(15,23,42,0.3))]" />
|
||||
<div className="relative flex w-full max-w-4xl items-end justify-center gap-3 sm:gap-9">
|
||||
{visibleCharacters.length > 0 ? (
|
||||
visibleCharacters.map((character, index) => (
|
||||
<VisualNovelCharacterStandee
|
||||
key={character.characterId}
|
||||
character={character}
|
||||
index={index}
|
||||
active={
|
||||
!displayState.dialogueStep ||
|
||||
displayState.dialogueStep.characterId ===
|
||||
character.characterId
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-[min(40dvh,16rem)]" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative mt-3 rounded-[1.25rem] border border-white/16 bg-black/46 p-3 shadow-[0_18px_44px_rgba(0,0,0,0.28)] backdrop-blur">
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="min-w-0 truncate text-sm font-black text-white">
|
||||
{primarySpeaker}
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] font-bold text-white/58">
|
||||
{displayRun.mode === 'test' ? 'TEST' : 'PLAY'}
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 min-h-[4.75rem] break-words text-base leading-7 text-white/92">
|
||||
{primaryText}
|
||||
</p>
|
||||
{displayState.transitionStep?.text ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm font-semibold text-white/76">
|
||||
{displayState.transitionStep.text}
|
||||
</div>
|
||||
) : null}
|
||||
{displayStreamingText ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm leading-6 text-white/72">
|
||||
{displayStreamingText}
|
||||
</div>
|
||||
) : null}
|
||||
{textModeEnabled ? (
|
||||
<div className="mt-3 max-h-32 overflow-y-auto rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 whitespace-pre-line text-sm leading-6 text-white/78">
|
||||
{textModeLines || primaryText}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{choices.length > 0 ? (
|
||||
<section className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={choice.choiceId}
|
||||
type="button"
|
||||
disabled={displayBusy}
|
||||
onClick={() => submitChoice(choice)}
|
||||
className="min-h-12 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 py-2 text-left text-sm font-black leading-5 text-white shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<span className="block break-words">{choice.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={displayBusy}
|
||||
onClick={continueRuntime}
|
||||
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 text-sm font-black text-white backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<span>继续</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={freeText}
|
||||
disabled={
|
||||
displayBusy || !displayDraft.runtimeConfig.allowFreeTextAction
|
||||
}
|
||||
onChange={(event) => setFreeText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitFreeText();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-full border border-white/16 bg-black/26 px-4 text-sm font-semibold text-white outline-none placeholder:text-white/42"
|
||||
placeholder="输入行动"
|
||||
aria-label="输入行动"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmitFreeText}
|
||||
onClick={submitFreeText}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/16 bg-white/14 text-white backdrop-blur transition hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label="发送行动"
|
||||
title="发送行动"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{displayError ? (
|
||||
<div className="mt-3 rounded-[0.95rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
|
||||
{displayError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer
|
||||
className={`mt-3 grid gap-2 ${canShowAttributes ? 'grid-cols-4' : 'grid-cols-3'}`}
|
||||
>
|
||||
{[
|
||||
{ kind: 'history' as const, label: '历史', icon: History },
|
||||
{ kind: 'save' as const, label: '存档', icon: Bookmark },
|
||||
{ kind: 'settings' as const, label: '设置', icon: Settings },
|
||||
...(canShowAttributes
|
||||
? [
|
||||
{
|
||||
kind: 'attributes' as const,
|
||||
label: '属性',
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
onClick={() => setActivePanel(item.kind)}
|
||||
className="flex min-h-12 min-w-0 flex-col items-center justify-center gap-1 rounded-[0.95rem] border border-white/14 bg-black/24 px-2 text-xs font-black text-white backdrop-blur transition hover:bg-black/34"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="max-w-full truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{activePanel ? (
|
||||
<VisualNovelRuntimePanel
|
||||
kind={activePanel}
|
||||
draft={displayDraft}
|
||||
run={displayRun}
|
||||
isBusy={displayBusy}
|
||||
isSaving={displaySaving}
|
||||
isLoadingArchives={displayLoadingArchives}
|
||||
resumingWorldKey={displayResumingWorldKey}
|
||||
saveArchives={displaySaveArchives}
|
||||
allowRegeneration={displayDraft.runtimeConfig.allowHistoryRegeneration}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onRegenerateHistoryEntry={regenerateHistoryEntry}
|
||||
onSaveRun={saveRuntime}
|
||||
onResumeSaveArchive={resumeArchive}
|
||||
textModeEnabled={textModeEnabled}
|
||||
onTextModeChange={updateTextMode}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelRuntimeShell;
|
||||
102
src/components/visual-novel-runtime/VisualNovelSavePanel.tsx
Normal file
102
src/components/visual-novel-runtime/VisualNovelSavePanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Bookmark, Loader2, Play } from 'lucide-react';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
type VisualNovelSavePanelProps = {
|
||||
run: VisualNovelRunSnapshot;
|
||||
saveArchives?: ProfileSaveArchiveSummary[];
|
||||
isSaving?: boolean;
|
||||
isLoadingArchives?: boolean;
|
||||
resumingWorldKey?: string | null;
|
||||
onSaveRun?: () => void;
|
||||
onResumeSaveArchive?: (worldKey: string) => void;
|
||||
};
|
||||
|
||||
function formatArchiveTime(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(timestamp);
|
||||
}
|
||||
|
||||
export function VisualNovelSavePanel({
|
||||
run,
|
||||
saveArchives = [],
|
||||
isSaving = false,
|
||||
isLoadingArchives = false,
|
||||
resumingWorldKey = null,
|
||||
onSaveRun,
|
||||
onResumeSaveArchive,
|
||||
}: VisualNovelSavePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onSaveRun || isSaving}
|
||||
onClick={onSaveRun}
|
||||
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-button-primary-fill)] px-4 text-sm font-black text-white transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Bookmark className="h-4 w-4" />
|
||||
)}
|
||||
<span>{isSaving ? '保存中' : '保存'}</span>
|
||||
</button>
|
||||
|
||||
{isLoadingArchives ? (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
读取中
|
||||
</div>
|
||||
) : saveArchives.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{saveArchives.map((entry) => {
|
||||
const isResuming = resumingWorldKey === entry.worldKey;
|
||||
return (
|
||||
<button
|
||||
key={entry.worldKey}
|
||||
type="button"
|
||||
disabled={isResuming || !onResumeSaveArchive}
|
||||
onClick={() => onResumeSaveArchive?.(entry.worldKey)}
|
||||
className="flex min-h-20 items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3 text-left transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-[0.85rem] bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
|
||||
{isResuming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{entry.worldName || run.profileId}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{entry.subtitle || entry.summaryText || run.runId}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||||
{formatArchiveTime(entry.lastPlayedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无存档
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelSavePanel;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MessageSquareText } from 'lucide-react';
|
||||
|
||||
import type { VisualNovelResultDraft } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
type VisualNovelSettingsPanelProps = {
|
||||
draft: VisualNovelResultDraft;
|
||||
textModeEnabled: boolean;
|
||||
onTextModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function VisualNovelSettingsPanel({
|
||||
draft,
|
||||
textModeEnabled,
|
||||
onTextModeChange,
|
||||
}: VisualNovelSettingsPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTextModeChange?.(!textModeEnabled)}
|
||||
className="flex min-h-12 w-full items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3 text-left transition hover:bg-white"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
<MessageSquareText className="h-4 w-4 shrink-0" />
|
||||
<span>文本模式</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
|
||||
{textModeEnabled ? '开启' : '关闭'}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
|
||||
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
自由输入
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
|
||||
{draft.runtimeConfig.allowFreeTextAction ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
|
||||
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
历史重生成
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
|
||||
{draft.runtimeConfig.allowHistoryRegeneration ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelSettingsPanel;
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelRunMode,
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeActionRequest,
|
||||
VisualNovelRuntimeStep,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
buildVisualNovelRuntimeCheckpoint,
|
||||
buildVisualNovelSaveArchiveState,
|
||||
visualNovelRuntimeClient,
|
||||
} from '../../services/visual-novel-runtime';
|
||||
|
||||
export type UseVisualNovelRuntimeControllerParams = {
|
||||
draft?: VisualNovelResultDraft | null;
|
||||
initialRun?: VisualNovelRunSnapshot | null;
|
||||
profileId?: string | null;
|
||||
runId?: string | null;
|
||||
mode?: VisualNovelRunMode;
|
||||
autoStart?: boolean;
|
||||
};
|
||||
|
||||
function createVisualNovelClientEventId(kind: string) {
|
||||
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
function resolveVisualNovelErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message.trim()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function resolveProfileId(params: UseVisualNovelRuntimeControllerParams) {
|
||||
return params.profileId?.trim() || params.draft?.profileId?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveRunFromResumeSnapshot(value: unknown) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = value as {
|
||||
currentStory?: { run?: VisualNovelRunSnapshot | null } | null;
|
||||
gameState?: { runId?: unknown; archiveState?: unknown } | null;
|
||||
};
|
||||
|
||||
return snapshot.currentStory?.run ?? null;
|
||||
}
|
||||
|
||||
function resolveRunIdFromResumeSnapshot(value: unknown) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = value as {
|
||||
gameState?: {
|
||||
runId?: unknown;
|
||||
archiveState?: { runId?: unknown } | null;
|
||||
} | null;
|
||||
};
|
||||
const directRunId = snapshot.gameState?.runId;
|
||||
const archiveRunId = snapshot.gameState?.archiveState?.runId;
|
||||
|
||||
if (typeof directRunId === 'string' && directRunId.trim()) {
|
||||
return directRunId.trim();
|
||||
}
|
||||
|
||||
if (typeof archiveRunId === 'string' && archiveRunId.trim()) {
|
||||
return archiveRunId.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useVisualNovelRuntimeController(
|
||||
params: UseVisualNovelRuntimeControllerParams,
|
||||
) {
|
||||
const [run, setRun] = useState<VisualNovelRunSnapshot | null>(
|
||||
params.initialRun ?? null,
|
||||
);
|
||||
const [saveArchives, setSaveArchives] = useState<
|
||||
ProfileSaveArchiveSummary[]
|
||||
>([]);
|
||||
const [streamedSteps, setStreamedSteps] = useState<VisualNovelRuntimeStep[]>(
|
||||
[],
|
||||
);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoadingArchives, setIsLoadingArchives] = useState(false);
|
||||
const [resumingWorldKey, setResumingWorldKey] = useState<string | null>(null);
|
||||
const activeActionAbortRef = useRef<AbortController | null>(null);
|
||||
const profileId = resolveProfileId(params);
|
||||
|
||||
useEffect(() => {
|
||||
setRun(params.initialRun ?? null);
|
||||
}, [params.initialRun]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
activeActionAbortRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadSaveArchives = useCallback(async () => {
|
||||
if (!profileId) {
|
||||
setSaveArchives([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
setIsLoadingArchives(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const entries = await visualNovelRuntimeClient.listSaveArchives(profileId);
|
||||
setSaveArchives(entries);
|
||||
return entries;
|
||||
} catch (loadError) {
|
||||
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说存档失败'));
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingArchives(false);
|
||||
}
|
||||
}, [profileId]);
|
||||
|
||||
const startRun = useCallback(async () => {
|
||||
if (!profileId || isBusy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamedSteps([]);
|
||||
setStreamingText('');
|
||||
|
||||
try {
|
||||
const response = await visualNovelRuntimeClient.startRun(profileId, {
|
||||
profileId,
|
||||
mode: params.mode ?? 'play',
|
||||
});
|
||||
setRun(response.run);
|
||||
return response.run;
|
||||
} catch (startError) {
|
||||
setError(resolveVisualNovelErrorMessage(startError, '启动视觉小说失败'));
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [isBusy, params.mode, profileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (run || !params.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
|
||||
void visualNovelRuntimeClient
|
||||
.getRun(params.runId)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setRun(response.run);
|
||||
}
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) {
|
||||
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说失败'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [params.runId, run]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.autoStart || run || params.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void startRun();
|
||||
}, [params.autoStart, params.runId, run, startRun]);
|
||||
|
||||
const submitAction = useCallback(
|
||||
async (payload: VisualNovelRuntimeActionRequest) => {
|
||||
if (!run || isBusy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
activeActionAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
activeActionAbortRef.current = abortController;
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamedSteps([]);
|
||||
setStreamingText('');
|
||||
|
||||
try {
|
||||
const nextRun = await visualNovelRuntimeClient.streamAction(
|
||||
run.runId,
|
||||
payload,
|
||||
{
|
||||
signal: abortController.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'raw_text') {
|
||||
// 中文注释:raw_text 只用于流式临场反馈,不进入 history 或最终文本模式。
|
||||
setStreamingText((currentText) => `${currentText}${event.text}`);
|
||||
}
|
||||
|
||||
if (event.type === 'step') {
|
||||
setStreamedSteps((currentSteps) => [
|
||||
...currentSteps,
|
||||
event.step,
|
||||
]);
|
||||
}
|
||||
|
||||
if (event.type === 'snapshot' || event.type === 'complete') {
|
||||
setRun(event.run);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
setRun(nextRun);
|
||||
setStreamedSteps([]);
|
||||
setStreamingText('');
|
||||
return nextRun;
|
||||
} catch (submitError) {
|
||||
setError(resolveVisualNovelErrorMessage(submitError, '推进视觉小说失败'));
|
||||
return null;
|
||||
} finally {
|
||||
if (activeActionAbortRef.current === abortController) {
|
||||
activeActionAbortRef.current = null;
|
||||
}
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, run],
|
||||
);
|
||||
|
||||
const continueRun = useCallback(
|
||||
() =>
|
||||
submitAction({
|
||||
actionKind: 'continue',
|
||||
clientEventId: createVisualNovelClientEventId('continue'),
|
||||
}),
|
||||
[submitAction],
|
||||
);
|
||||
|
||||
const regenerateFromHistory = useCallback(
|
||||
async (historyEntryId: string) => {
|
||||
if (!run || isBusy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamedSteps([]);
|
||||
setStreamingText('');
|
||||
|
||||
try {
|
||||
const response = await visualNovelRuntimeClient.regenerateRun(run.runId, {
|
||||
historyEntryId,
|
||||
clientEventId: createVisualNovelClientEventId('regenerate'),
|
||||
});
|
||||
setRun(response.run);
|
||||
return response.run;
|
||||
} catch (regenError) {
|
||||
setError(
|
||||
resolveVisualNovelErrorMessage(regenError, '重生成视觉小说历史失败'),
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, run],
|
||||
);
|
||||
|
||||
const saveCurrentRun = useCallback(async () => {
|
||||
if (!run || isSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const checkpoint = buildVisualNovelRuntimeCheckpoint({ run });
|
||||
await visualNovelRuntimeClient.putSnapshot(checkpoint);
|
||||
await loadSaveArchives();
|
||||
return buildVisualNovelSaveArchiveState(run);
|
||||
} catch (saveError) {
|
||||
setError(resolveVisualNovelErrorMessage(saveError, '保存视觉小说失败'));
|
||||
return null;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [isSaving, loadSaveArchives, run]);
|
||||
|
||||
const resumeSaveArchive = useCallback(
|
||||
async (worldKey: string) => {
|
||||
if (!worldKey.trim() || resumingWorldKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setResumingWorldKey(worldKey);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response =
|
||||
await visualNovelRuntimeClient.resumeSaveArchive(worldKey);
|
||||
setSaveArchives((currentEntries) =>
|
||||
currentEntries.map((entry) =>
|
||||
entry.worldKey === response.entry.worldKey ? response.entry : entry,
|
||||
),
|
||||
);
|
||||
|
||||
const restoredRun = resolveRunFromResumeSnapshot(response.snapshot);
|
||||
if (restoredRun) {
|
||||
setRun(restoredRun);
|
||||
return restoredRun;
|
||||
}
|
||||
|
||||
const restoredRunId = resolveRunIdFromResumeSnapshot(response.snapshot);
|
||||
if (restoredRunId) {
|
||||
const runResponse =
|
||||
await visualNovelRuntimeClient.getRun(restoredRunId);
|
||||
setRun(runResponse.run);
|
||||
return runResponse.run;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (resumeError) {
|
||||
setError(resolveVisualNovelErrorMessage(resumeError, '恢复视觉小说失败'));
|
||||
return null;
|
||||
} finally {
|
||||
setResumingWorldKey(null);
|
||||
}
|
||||
},
|
||||
[resumingWorldKey],
|
||||
);
|
||||
|
||||
const currentArchiveState = useMemo(
|
||||
() => (run ? buildVisualNovelSaveArchiveState(run) : null),
|
||||
[run],
|
||||
);
|
||||
|
||||
return {
|
||||
draft: params.draft ?? null,
|
||||
run,
|
||||
setRun,
|
||||
currentArchiveState,
|
||||
saveArchives,
|
||||
streamedSteps,
|
||||
streamingText,
|
||||
error,
|
||||
isBusy,
|
||||
isSaving,
|
||||
isLoadingArchives,
|
||||
resumingWorldKey,
|
||||
startRun,
|
||||
submitAction,
|
||||
continueRun,
|
||||
regenerateFromHistory,
|
||||
saveCurrentRun,
|
||||
loadSaveArchives,
|
||||
resumeSaveArchive,
|
||||
};
|
||||
}
|
||||
|
||||
export type VisualNovelRuntimeControllerResult = ReturnType<
|
||||
typeof useVisualNovelRuntimeController
|
||||
>;
|
||||
@@ -0,0 +1,13 @@
|
||||
const VISUAL_NOVEL_FORBIDDEN_COPY_PARTS = [
|
||||
['回', '放'],
|
||||
['分享', '回', '放'],
|
||||
['录', '制'],
|
||||
['复', '盘'],
|
||||
];
|
||||
|
||||
export function buildVisualNovelForbiddenCopyPattern() {
|
||||
return new RegExp(
|
||||
VISUAL_NOVEL_FORBIDDEN_COPY_PARTS.map((parts) => parts.join('')).join('|'),
|
||||
'u',
|
||||
);
|
||||
}
|
||||
455
src/components/visual-novel-runtime/visualNovelMockData.ts
Normal file
455
src/components/visual-novel-runtime/visualNovelMockData.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import type {
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelSourceMode,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
const MOCK_NOW = '2026-05-05T12:00:00.000Z';
|
||||
|
||||
// 中文注释:VN-04 只提供 UI 骨架,mock 数据必须保持在前端组件域内,避免被误用成业务真相源。
|
||||
export const mockVisualNovelDraft: VisualNovelResultDraft = {
|
||||
profileId: 'vn-profile-mock-1',
|
||||
workTitle: '雪线电台',
|
||||
workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。',
|
||||
workTags: ['悬疑', '冬夜', '双人叙事'],
|
||||
coverImageSrc: null,
|
||||
sourceMode: 'idea',
|
||||
sourceAssetIds: [],
|
||||
world: {
|
||||
title: '北境终点线',
|
||||
summary: '终年落雪的边境小城依靠一座旧电台维系列车、灯塔和远方来信。',
|
||||
background:
|
||||
'十二年前的雪崩掩埋了山间支线,也留下无法解释的夜间广播。现在,最后一班列车重新驶入废弃站台。',
|
||||
premise: '玩家需要在日出前找出列车停摆的原因,并决定是否公开电台里的旧录音。',
|
||||
literaryStyle: '克制、冷光感、对白带有细微试探。',
|
||||
playerRole: '临时接任的列车广播员',
|
||||
defaultTone: '安静、紧张、带一点温柔',
|
||||
},
|
||||
characters: [
|
||||
{
|
||||
characterId: 'vn-char-lin-yao',
|
||||
name: '林遥',
|
||||
gender: '女',
|
||||
role: 'main',
|
||||
appearance: '深灰长外套,围巾边缘有细小电台徽章。',
|
||||
personality: '谨慎、敏锐,习惯先观察再回答。',
|
||||
tone: '短句多,情绪压得很低。',
|
||||
background: '曾在旧电台做夜班实习生,熟悉失踪列车的第一份报案。',
|
||||
relationshipToPlayer: '玩家的临时搭档',
|
||||
imageAssets: [],
|
||||
defaultExpression: 'calm',
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
{
|
||||
characterId: 'vn-char-he-shen',
|
||||
name: '赫慎',
|
||||
gender: '男',
|
||||
role: 'supporting',
|
||||
appearance: '车长制服整洁,袖口有被火星燎过的旧痕。',
|
||||
personality: '礼貌、固执,对列车时刻表近乎偏执。',
|
||||
tone: '正式、慢速,偶尔忽然沉默。',
|
||||
background: '事故后仍守着废弃站台的人。',
|
||||
relationshipToPlayer: '提供线索,也隐瞒关键事故记录',
|
||||
imageAssets: [],
|
||||
defaultExpression: 'reserved',
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
],
|
||||
scenes: [
|
||||
{
|
||||
sceneId: 'vn-scene-platform',
|
||||
name: '风雪站台',
|
||||
description: '站灯忽明忽暗,远处铁轨被雪压出发亮的线。',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'opening',
|
||||
phaseIds: ['vn-phase-1'],
|
||||
},
|
||||
{
|
||||
sceneId: 'vn-scene-radio-room',
|
||||
name: '旧电台室',
|
||||
description: '木桌上堆着线圈、胶带和没来得及寄出的明信片。',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'phase_locked',
|
||||
phaseIds: ['vn-phase-2'],
|
||||
},
|
||||
],
|
||||
storyPhases: [
|
||||
{
|
||||
phaseId: 'vn-phase-1',
|
||||
title: '重启站台',
|
||||
goal: '确认列车为何停在废弃站台。',
|
||||
summary: '玩家抵达风雪站台,第一次听见旧电台播出自己的名字。',
|
||||
entryCondition: '开场进入',
|
||||
exitCondition: '找到车长日志或解开广播频段',
|
||||
sceneIds: ['vn-scene-platform'],
|
||||
characterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
|
||||
suggestedChoices: ['询问林遥旧电台的来历', '检查站台广播柜', '追问车长日志'],
|
||||
},
|
||||
{
|
||||
phaseId: 'vn-phase-2',
|
||||
title: '未寄出的录音',
|
||||
goal: '判断旧录音是否可信。',
|
||||
summary: '旧电台室中出现十二年前的夜班录音,内容和现实开始互相抵触。',
|
||||
entryCondition: '完成站台调查',
|
||||
exitCondition: '选择公开或隐藏录音',
|
||||
sceneIds: ['vn-scene-radio-room'],
|
||||
characterIds: ['vn-char-lin-yao'],
|
||||
suggestedChoices: ['倒回录音最后十秒', '让林遥辨认声音', '检查明信片落款'],
|
||||
},
|
||||
],
|
||||
opening: {
|
||||
sceneId: 'vn-scene-platform',
|
||||
narration: '雪落得很慢,像有人把整座车站的时间调低了一格。',
|
||||
speakerCharacterId: 'vn-char-lin-yao',
|
||||
firstDialogue: '你听见了吗?电台刚才念出了你的名字。',
|
||||
initialChoices: [
|
||||
{
|
||||
choiceId: 'vn-choice-radio',
|
||||
text: '靠近广播柜,确认频段来源。',
|
||||
actionHint: 'investigate_radio',
|
||||
},
|
||||
{
|
||||
choiceId: 'vn-choice-lin',
|
||||
text: '先问林遥为什么认识这段广播。',
|
||||
actionHint: 'ask_lin_yao',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfig: {
|
||||
textModeEnabled: true,
|
||||
defaultTextMode: false,
|
||||
maxHistoryEntries: 80,
|
||||
maxAssistantStepCountPerTurn: 8,
|
||||
allowFreeTextAction: true,
|
||||
allowHistoryRegeneration: true,
|
||||
attributePanelMode: 'template_config',
|
||||
saveArchiveEnabled: true,
|
||||
},
|
||||
publishReady: false,
|
||||
validationIssues: [
|
||||
{
|
||||
issueId: 'vn-issue-cover',
|
||||
code: 'MISSING_COVER_IMAGE',
|
||||
severity: 'warning',
|
||||
path: 'coverImageSrc',
|
||||
message: '封面待补齐。',
|
||||
},
|
||||
],
|
||||
updatedAt: MOCK_NOW,
|
||||
};
|
||||
|
||||
export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
|
||||
sessionId: 'vn-session-mock-1',
|
||||
ownerUserId: 'mock-user',
|
||||
sourceMode: 'idea',
|
||||
status: 'ready',
|
||||
messages: [
|
||||
{
|
||||
id: 'vn-message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
|
||||
createdAt: MOCK_NOW,
|
||||
},
|
||||
{
|
||||
id: 'vn-message-2',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。',
|
||||
createdAt: MOCK_NOW,
|
||||
},
|
||||
],
|
||||
draft: mockVisualNovelDraft,
|
||||
pendingAction: {
|
||||
actionId: 'vn-action-compile-mock',
|
||||
kind: 'compile_work_profile',
|
||||
label: '生成结果页',
|
||||
},
|
||||
createdAt: MOCK_NOW,
|
||||
updatedAt: MOCK_NOW,
|
||||
};
|
||||
|
||||
function createVisualNovelDraftId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
export function createBlankVisualNovelDraft(
|
||||
sourceMode: VisualNovelSourceMode = 'blank',
|
||||
): VisualNovelResultDraft {
|
||||
const draftId = createVisualNovelDraftId('vn-profile-local');
|
||||
const sceneId = `${draftId}-scene-opening`;
|
||||
const characterId = `${draftId}-char-main`;
|
||||
const phaseId = `${draftId}-phase-opening`;
|
||||
|
||||
return {
|
||||
profileId: draftId,
|
||||
workTitle: '未命名视觉小说',
|
||||
workDescription: '',
|
||||
workTags: [],
|
||||
coverImageSrc: null,
|
||||
sourceMode,
|
||||
sourceAssetIds: [],
|
||||
world: {
|
||||
title: '',
|
||||
summary: '',
|
||||
background: '',
|
||||
premise: '',
|
||||
literaryStyle: '',
|
||||
playerRole: '',
|
||||
defaultTone: '',
|
||||
},
|
||||
characters: [
|
||||
{
|
||||
characterId,
|
||||
name: '',
|
||||
gender: null,
|
||||
role: 'main',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
tone: '',
|
||||
background: '',
|
||||
relationshipToPlayer: '',
|
||||
imageAssets: [],
|
||||
defaultExpression: null,
|
||||
isPlayerVisible: false,
|
||||
},
|
||||
],
|
||||
scenes: [
|
||||
{
|
||||
sceneId,
|
||||
name: '',
|
||||
description: '',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
ambientSoundSrc: null,
|
||||
availability: 'opening',
|
||||
phaseIds: [phaseId],
|
||||
},
|
||||
],
|
||||
storyPhases: [
|
||||
{
|
||||
phaseId,
|
||||
title: '',
|
||||
goal: '',
|
||||
summary: '',
|
||||
entryCondition: '开场进入',
|
||||
exitCondition: '',
|
||||
sceneIds: [sceneId],
|
||||
characterIds: [characterId],
|
||||
suggestedChoices: ['', ''],
|
||||
},
|
||||
],
|
||||
opening: {
|
||||
sceneId,
|
||||
narration: '',
|
||||
speakerCharacterId: characterId,
|
||||
firstDialogue: '',
|
||||
initialChoices: [
|
||||
{
|
||||
choiceId: `${draftId}-choice-1`,
|
||||
text: '',
|
||||
actionHint: null,
|
||||
},
|
||||
{
|
||||
choiceId: `${draftId}-choice-2`,
|
||||
text: '',
|
||||
actionHint: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfig: {
|
||||
textModeEnabled: true,
|
||||
defaultTextMode: false,
|
||||
maxHistoryEntries: 80,
|
||||
maxAssistantStepCountPerTurn: 8,
|
||||
allowFreeTextAction: true,
|
||||
allowHistoryRegeneration: true,
|
||||
attributePanelMode: 'off',
|
||||
saveArchiveEnabled: true,
|
||||
},
|
||||
publishReady: false,
|
||||
validationIssues: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockVisualNovelSessionFromDraft(
|
||||
draft: VisualNovelResultDraft,
|
||||
): VisualNovelAgentSessionSnapshot {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
sessionId: createVisualNovelDraftId('vn-session-local'),
|
||||
ownerUserId: 'local-user',
|
||||
sourceMode: draft.sourceMode,
|
||||
status: 'ready',
|
||||
messages: [
|
||||
{
|
||||
id: createVisualNovelDraftId('vn-message-local'),
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '已创建可编辑底稿。',
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
draft,
|
||||
pendingAction: {
|
||||
actionId: createVisualNovelDraftId('vn-action-local'),
|
||||
kind: 'compile_work_profile',
|
||||
label: '保存草稿',
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockVisualNovelRunFromDraft(
|
||||
draft: VisualNovelResultDraft,
|
||||
): VisualNovelRunSnapshot {
|
||||
const now = new Date().toISOString();
|
||||
const openingSceneId = draft.opening.sceneId ?? draft.scenes[0]?.sceneId ?? null;
|
||||
const firstPhase = draft.storyPhases.find((phase) =>
|
||||
openingSceneId ? phase.sceneIds.includes(openingSceneId) : false,
|
||||
);
|
||||
const speaker = draft.opening.speakerCharacterId
|
||||
? draft.characters.find(
|
||||
(character) =>
|
||||
character.characterId === draft.opening.speakerCharacterId,
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
runId: createVisualNovelDraftId('vn-run-local'),
|
||||
ownerUserId: 'local-user',
|
||||
profileId: draft.profileId ?? createVisualNovelDraftId('vn-profile-local'),
|
||||
mode: 'test',
|
||||
status: 'active',
|
||||
currentSceneId: openingSceneId,
|
||||
currentPhaseId: firstPhase?.phaseId ?? draft.storyPhases[0]?.phaseId ?? null,
|
||||
visibleCharacterIds: draft.characters
|
||||
.filter((character) => character.role !== 'protagonist')
|
||||
.map((character) => character.characterId)
|
||||
.slice(0, 2),
|
||||
flags: {},
|
||||
metrics: {},
|
||||
history: [
|
||||
{
|
||||
entryId: createVisualNovelDraftId('vn-history-local'),
|
||||
runId: 'local-test-run',
|
||||
turnIndex: 1,
|
||||
source: 'assistant',
|
||||
actionText: null,
|
||||
steps: [
|
||||
...(openingSceneId
|
||||
? [
|
||||
{
|
||||
type: 'scene_change' as const,
|
||||
sceneId: openingSceneId,
|
||||
backgroundImageSrc:
|
||||
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
|
||||
?.backgroundImageSrc ?? null,
|
||||
musicSrc:
|
||||
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
|
||||
?.musicSrc ?? null,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'narration' as const,
|
||||
text: draft.opening.narration || draft.workDescription || draft.world.summary,
|
||||
},
|
||||
...(speaker && draft.opening.firstDialogue
|
||||
? [
|
||||
{
|
||||
type: 'dialogue' as const,
|
||||
characterId: speaker.characterId,
|
||||
characterName: speaker.name,
|
||||
expression: speaker.defaultExpression,
|
||||
text: draft.opening.firstDialogue,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
snapshotBeforeHash: null,
|
||||
snapshotAfterHash: 'local-test-snapshot',
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
availableChoices: draft.opening.initialChoices.filter((choice) =>
|
||||
choice.text.trim(),
|
||||
),
|
||||
textModeEnabled: draft.runtimeConfig.defaultTextMode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockVisualNovelRun: VisualNovelRunSnapshot = {
|
||||
runId: 'vn-run-mock-1',
|
||||
ownerUserId: 'mock-user',
|
||||
profileId: 'vn-profile-mock-1',
|
||||
mode: 'test',
|
||||
status: 'active',
|
||||
currentSceneId: 'vn-scene-platform',
|
||||
currentPhaseId: 'vn-phase-1',
|
||||
visibleCharacterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
|
||||
flags: {
|
||||
radioUnlocked: false,
|
||||
conductorTrust: 1,
|
||||
},
|
||||
metrics: {
|
||||
tension: 42,
|
||||
trust: 18,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
entryId: 'vn-history-1',
|
||||
runId: 'vn-run-mock-1',
|
||||
turnIndex: 1,
|
||||
source: 'assistant',
|
||||
actionText: null,
|
||||
steps: [
|
||||
{
|
||||
type: 'scene_change',
|
||||
sceneId: 'vn-scene-platform',
|
||||
backgroundImageSrc: null,
|
||||
musicSrc: null,
|
||||
},
|
||||
{
|
||||
type: 'narration',
|
||||
text: '雪落得很慢,站台灯把铁轨照成两道苍白的线。',
|
||||
},
|
||||
{
|
||||
type: 'dialogue',
|
||||
characterId: 'vn-char-lin-yao',
|
||||
characterName: '林遥',
|
||||
expression: 'calm',
|
||||
text: '你听见了吗?电台刚才念出了你的名字。',
|
||||
},
|
||||
],
|
||||
snapshotBeforeHash: null,
|
||||
snapshotAfterHash: 'mock-hash-1',
|
||||
createdAt: MOCK_NOW,
|
||||
},
|
||||
{
|
||||
entryId: 'vn-history-2',
|
||||
runId: 'vn-run-mock-1',
|
||||
turnIndex: 2,
|
||||
source: 'player',
|
||||
actionText:
|
||||
'靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。',
|
||||
steps: [],
|
||||
snapshotBeforeHash: 'mock-hash-1',
|
||||
snapshotAfterHash: 'mock-hash-2',
|
||||
createdAt: MOCK_NOW,
|
||||
},
|
||||
],
|
||||
availableChoices: mockVisualNovelDraft.opening.initialChoices,
|
||||
textModeEnabled: true,
|
||||
createdAt: MOCK_NOW,
|
||||
updatedAt: MOCK_NOW,
|
||||
};
|
||||
Reference in New Issue
Block a user