This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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