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