需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
import {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type KeyboardEvent,
|
||
} from 'react';
|
||
import {
|
||
Bot,
|
||
Check,
|
||
Clock3,
|
||
Cloud,
|
||
Film,
|
||
ImageIcon,
|
||
Loader2,
|
||
Plus,
|
||
RefreshCw,
|
||
Send,
|
||
Sparkles,
|
||
WandSparkles,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import {
|
||
IMAGE_WORKSPACE_CREATE_PROJECT_EVENT,
|
||
resolveImageWorkspaceAssetUrl,
|
||
} from '@/lib/image-workspace';
|
||
import { cn } from '@/lib/utils';
|
||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import type {
|
||
DesignAsset,
|
||
DesignGenerationQuote,
|
||
DesignGenerationTask,
|
||
DesignMessage,
|
||
DesignTaskStatus,
|
||
} from '../../../shared/image-workspace';
|
||
|
||
const ACTIVE_TASK_STATUSES = new Set<DesignTaskStatus>(['queued', 'running']);
|
||
|
||
function taskStatusLabel(status: DesignTaskStatus): string {
|
||
if (status === 'queued') return '排队中';
|
||
if (status === 'running') return '生成中';
|
||
if (status === 'succeeded') return '已完成';
|
||
if (status === 'cancelled') return '已取消';
|
||
return '失败';
|
||
}
|
||
|
||
function mediumLabel(medium: 'image' | 'video'): string {
|
||
return medium === 'video' ? '视频' : '图片';
|
||
}
|
||
|
||
function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
|
||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||
const quote = messages[index].generationQuote;
|
||
if (quote?.status === 'active') return quote;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
|
||
|
||
function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||
const [url, setUrl] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void resolveImageWorkspaceAssetUrl(asset.contentPath).then((nextUrl) => {
|
||
if (!cancelled) setUrl(nextUrl);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [asset.contentPath]);
|
||
|
||
if (!url) {
|
||
return (
|
||
<div role="status" className="flex aspect-video items-center justify-center bg-surface-subtle">
|
||
<Loader2 className="h-5 w-5 animate-spin text-brand" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (asset.mimeType.startsWith('video/')) {
|
||
return (
|
||
<video
|
||
controls
|
||
preload="metadata"
|
||
src={url}
|
||
className="aspect-video w-full bg-black object-contain"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<img
|
||
src={url}
|
||
alt="AI 设计生成结果"
|
||
className="aspect-video w-full bg-surface-subtle object-contain"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function TaskCard({ task }: { task: DesignGenerationTask }) {
|
||
const active = ACTIVE_TASK_STATUSES.has(task.status);
|
||
return (
|
||
<article
|
||
data-testid={`design-task-${task.taskId}`}
|
||
className="overflow-hidden rounded-2xl border border-border/70 bg-background shadow-soft"
|
||
>
|
||
{task.resultAssets.map((asset) => (
|
||
<AssetPreview key={asset.assetId} asset={asset} />
|
||
))}
|
||
<div className="p-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold">
|
||
{task.medium === 'video'
|
||
? <Film className="h-3.5 w-3.5 text-brand" />
|
||
: <ImageIcon className="h-3.5 w-3.5 text-brand" />}
|
||
{mediumLabel(task.medium)}任务
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[10px] font-semibold',
|
||
task.status === 'succeeded' && 'bg-emerald-50 text-emerald-700',
|
||
task.status === 'failed' && 'bg-destructive/10 text-destructive',
|
||
active && 'bg-brand-soft text-brand',
|
||
task.status === 'cancelled' && 'bg-surface-tertiary text-muted-foreground',
|
||
)}
|
||
>
|
||
{active ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||
{task.status === 'succeeded' ? <Check className="h-3 w-3" /> : null}
|
||
{taskStatusLabel(task.status)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 line-clamp-3 text-xs font-medium leading-5 text-muted-foreground">
|
||
{task.briefSummary}
|
||
</p>
|
||
{task.failureCode ? (
|
||
<p role="alert" className="mt-2 rounded-lg bg-destructive/10 px-2 py-1.5 text-[11px] font-semibold text-destructive">
|
||
{task.failureCode}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function QuoteCard({
|
||
quote,
|
||
busy,
|
||
onConfirm,
|
||
}: {
|
||
quote: DesignGenerationQuote;
|
||
busy: boolean;
|
||
onConfirm: () => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
data-testid={`design-quote-${quote.quoteId}`}
|
||
className="mt-3 rounded-2xl border border-brand/25 bg-brand-soft/60 p-3"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-foreground">
|
||
<WandSparkles className="h-4 w-4 text-brand" />
|
||
{mediumLabel(quote.medium)}生成方案
|
||
</span>
|
||
<span className="rounded-full bg-background px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||
{quote.quotedDesignPoints} 设计点
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-xs font-medium leading-5 text-muted-foreground">
|
||
{quote.briefSummary}
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="mt-3 h-8 rounded-full bg-brand px-3 text-xs font-semibold text-primary-foreground"
|
||
onClick={onConfirm}
|
||
disabled={busy}
|
||
>
|
||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" />}
|
||
确认并开始生成
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ImageCanvas() {
|
||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||
const status = useImageWorkspaceStore((state) => state.status);
|
||
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
|
||
const workspace = useImageWorkspaceStore((state) => state.workspace);
|
||
const tasks = useImageWorkspaceStore((state) => state.tasks);
|
||
const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn);
|
||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||
const load = useImageWorkspaceStore((state) => state.load);
|
||
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
|
||
const refreshTasks = useImageWorkspaceStore((state) => state.refreshTasks);
|
||
const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream);
|
||
const disconnectTaskStream = useImageWorkspaceStore((state) => state.disconnectTaskStream);
|
||
const sendMessage = useImageWorkspaceStore((state) => state.sendMessage);
|
||
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
|
||
const [prompt, setPrompt] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
|
||
const [actionError, setActionError] = useState<string | null>(null);
|
||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const quote = useMemo(
|
||
() => workspace ? activeQuote(workspace.messages) : null,
|
||
[workspace],
|
||
);
|
||
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
|
||
if (!workspace) return [];
|
||
const messages: RenderedDesignMessage[] = [...workspace.messages];
|
||
if (!pendingTurn || pendingTurn.workspaceId !== workspace.workspaceId) return messages;
|
||
if (pendingTurn.userText) {
|
||
messages.push({
|
||
id: `${pendingTurn.clientTurnId}:user`,
|
||
role: 'user',
|
||
kind: 'user',
|
||
text: pendingTurn.userText,
|
||
quickReplies: [],
|
||
generationQuote: null,
|
||
turnRevision: pendingTurn.turnRevision,
|
||
createdAt: pendingTurn.createdAt,
|
||
});
|
||
}
|
||
messages.push({
|
||
id: `${pendingTurn.clientTurnId}:assistant`,
|
||
role: 'assistant',
|
||
kind: 'reply',
|
||
text: pendingTurn.assistantText,
|
||
quickReplies: [],
|
||
generationQuote: null,
|
||
turnRevision: pendingTurn.turnRevision,
|
||
createdAt: pendingTurn.createdAt,
|
||
streaming: true,
|
||
});
|
||
return messages;
|
||
}, [pendingTurn, workspace]);
|
||
const taskWorkspaceId = workspace?.workspaceId ?? null;
|
||
const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status));
|
||
|
||
useEffect(() => {
|
||
if (status === 'idle' || (status === 'auth-required' && authenticated)) void load();
|
||
}, [authenticated, load, status]);
|
||
|
||
useEffect(() => {
|
||
if (!taskWorkspaceId) return;
|
||
connectTaskStream();
|
||
return disconnectTaskStream;
|
||
}, [connectTaskStream, disconnectTaskStream, taskWorkspaceId]);
|
||
|
||
useEffect(() => {
|
||
if (typeof conversationEndRef.current?.scrollIntoView === 'function') {
|
||
conversationEndRef.current.scrollIntoView({ block: 'end' });
|
||
}
|
||
}, [conversationMessages.length, pendingTurn?.assistantText]);
|
||
|
||
const handleSend = async (override?: string) => {
|
||
const message = (override ?? prompt).trim();
|
||
if (!workspace || !message || submitting) return;
|
||
const requestedWorkspaceId = workspace.workspaceId;
|
||
setSubmitting(true);
|
||
setActionError(null);
|
||
setPrompt('');
|
||
try {
|
||
await sendMessage(message);
|
||
} catch (error) {
|
||
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
|
||
setPrompt((current) => current.trim() ? current : message);
|
||
setActionError(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleConfirm = async (quoteId: string) => {
|
||
if (!workspace || confirmingQuoteId) return;
|
||
const requestedWorkspaceId = workspace.workspaceId;
|
||
setConfirmingQuoteId(quoteId);
|
||
setActionError(null);
|
||
try {
|
||
await confirmGeneration(quoteId);
|
||
} catch (error) {
|
||
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
|
||
setActionError(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
setConfirmingQuoteId(null);
|
||
}
|
||
};
|
||
|
||
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||
event.preventDefault();
|
||
void handleSend();
|
||
}
|
||
};
|
||
|
||
const openCreateProject = () => {
|
||
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
|
||
};
|
||
|
||
if (status === 'idle' || status === 'loading') {
|
||
return (
|
||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||
<div role="status" className="surface-card flex items-center gap-3 rounded-2xl border border-border/70 bg-background px-5 py-4 text-sm font-semibold shadow-float">
|
||
<Loader2 className="h-5 w-5 animate-spin text-brand" />
|
||
<span>正在读取 AI 设计项目</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (status === 'unavailable' || status === 'error' || !bootstrap) {
|
||
return (
|
||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||
<section data-testid="image-workspace-unavailable" className="surface-card w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-float">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-tertiary text-foreground">
|
||
<Cloud className="h-6 w-6" />
|
||
</div>
|
||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">AI 设计暂不可用</h1>
|
||
<p className="mx-auto mt-3 max-w-sm text-sm font-medium leading-6 text-muted-foreground">
|
||
暂时无法连接设计服务,请检查登录状态或稍后重试。
|
||
</p>
|
||
{workspaceError ? <p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">{workspaceError}</p> : null}
|
||
<Button type="button" variant="outline" className="mt-5" onClick={() => void load()}>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
重新连接
|
||
</Button>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (bootstrap.workspaces.length === 0) {
|
||
return (
|
||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||
<section className="surface-card w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-float">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||
<WandSparkles className="h-6 w-6" />
|
||
</div>
|
||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">创建第一个设计项目</h1>
|
||
<p className="mx-auto mt-3 max-w-sm text-sm font-medium leading-6 text-muted-foreground">
|
||
每个项目会持续保留与设计 Agent 的对话、确认过的方向,以及图片和视频生成任务。
|
||
</p>
|
||
<Button type="button" className="mt-5 bg-brand text-primary-foreground" onClick={openCreateProject}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建设计项目
|
||
</Button>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!workspace) {
|
||
return (
|
||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||
<Loader2 role="status" className="h-6 w-6 animate-spin text-brand" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div data-testid="image-canvas-page" className="-m-5 flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground sm:-m-6">
|
||
<header className="glass-surface flex shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-4 py-3 sm:px-6">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand">
|
||
<WandSparkles className="h-4 w-4" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<h1 className="truncate text-base font-semibold tracking-[-0.015em]">{workspace.title}</h1>
|
||
<span className="hidden rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground sm:inline-flex">
|
||
AI Design
|
||
</span>
|
||
</div>
|
||
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||
<Bot className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">
|
||
{workspace.brief.ready ? workspace.brief.summary : '设计 Agent 正在与你确认创作方向'}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
aria-label="刷新设计项目"
|
||
className="h-9 w-9 rounded-full"
|
||
onClick={() => {
|
||
void refreshWorkspace()
|
||
.then(() => refreshTasks())
|
||
.catch(() => undefined);
|
||
}}
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
</header>
|
||
|
||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
|
||
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
|
||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
||
{conversationMessages.length === 0 ? (
|
||
<div data-testid="image-workspace-empty-state" className="flex flex-1 items-center justify-center py-10 text-center">
|
||
<div className="max-w-xl">
|
||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||
<Sparkles className="h-6 w-6" />
|
||
</div>
|
||
<h2 className="mt-5 text-2xl font-medium tracking-[-0.025em]">先聊清楚,再开始创作</h2>
|
||
<p className="mx-auto mt-3 text-sm font-medium leading-6 text-muted-foreground">
|
||
告诉设计 Agent 你的用途、受众和想表达的感觉。它会帮助你收敛方向,确认后再创建生图或生视频任务。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-7">
|
||
{conversationMessages.map((message) => {
|
||
const assistant = message.role === 'assistant';
|
||
return (
|
||
<article key={message.id} className={cn('flex gap-3', assistant ? 'justify-start' : 'justify-end')}>
|
||
{assistant ? (
|
||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-surface-subtle">
|
||
<Bot className="h-4 w-4" />
|
||
</div>
|
||
) : null}
|
||
<div className={cn('min-w-0', assistant ? 'w-full max-w-2xl' : 'max-w-[85%]')}>
|
||
<div className={cn('mb-2 text-[11px] font-semibold text-muted-foreground', !assistant && 'text-right')}>
|
||
{assistant ? '设计 Agent' : '你'}
|
||
</div>
|
||
<div className={assistant ? 'chat-assistant-message-surface' : 'chat-user-message-surface rounded-2xl px-4 py-3'}>
|
||
<p className="whitespace-pre-wrap text-sm font-medium leading-6">
|
||
{message.text}
|
||
{message.streaming && message.text ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="ml-0.5 inline-block h-4 w-0.5 animate-pulse bg-brand align-middle"
|
||
/>
|
||
) : null}
|
||
{message.streaming && !message.text ? (
|
||
<span
|
||
role="status"
|
||
aria-label="设计 Agent 正在回复"
|
||
className="inline-flex items-center gap-2 text-muted-foreground"
|
||
>
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
正在整理设计方向…
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
{message.generationQuote?.status === 'active' ? (
|
||
<QuoteCard
|
||
quote={message.generationQuote}
|
||
busy={confirmingQuoteId === message.generationQuote.quoteId}
|
||
onConfirm={() => void handleConfirm(message.generationQuote!.quoteId)}
|
||
/>
|
||
) : null}
|
||
{assistant && message.quickReplies.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{message.quickReplies.map((reply) => (
|
||
<Button
|
||
key={reply}
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 rounded-full px-3 text-xs font-semibold"
|
||
onClick={() => {
|
||
if (reply.includes('确认') && quote) {
|
||
void handleConfirm(quote.quoteId);
|
||
} else {
|
||
setPrompt(reply);
|
||
}
|
||
}}
|
||
>
|
||
{reply}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
<div ref={conversationEndRef} aria-hidden="true" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<form
|
||
data-testid="image-workspace-composer"
|
||
className="shrink-0 bg-background px-4 pb-4 pt-2 sm:px-6"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void handleSend();
|
||
}}
|
||
>
|
||
<div className="mx-auto w-full max-w-3xl">
|
||
<div className="chat-composer-surface rounded-2xl border border-border/70 bg-background p-3 shadow-soft focus-within:ring-1 focus-within:ring-foreground/10">
|
||
<Textarea
|
||
aria-label="设计需求"
|
||
value={prompt}
|
||
onChange={(event) => {
|
||
setPrompt(event.target.value);
|
||
setActionError(null);
|
||
}}
|
||
onKeyDown={handleComposerKeyDown}
|
||
rows={3}
|
||
placeholder="告诉设计 Agent:要做什么、给谁看、希望是什么感觉…"
|
||
className="max-h-40 !min-h-[72px] resize-none border-0 bg-transparent p-0 text-sm font-medium leading-6 shadow-none focus-visible:ring-0"
|
||
/>
|
||
{actionError ? <p role="alert" className="mt-2 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">{actionError}</p> : null}
|
||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-border/70 pt-2">
|
||
<span className="text-[10px] font-medium text-muted-foreground">⌘/Ctrl + Enter 发送</span>
|
||
<Button type="submit" className="h-9 rounded-full bg-brand px-4 text-primary-foreground" disabled={submitting || !prompt.trim()}>
|
||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||
发送给设计 Agent
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<div data-testid="design-pane-divider" aria-hidden="true" className="hidden w-px shrink-0 bg-border/70 lg:block" />
|
||
|
||
<aside data-testid="design-task-list" className="min-h-0 w-full shrink-0 overflow-hidden border-t border-border/70 bg-surface-subtle/45 lg:h-full lg:w-80 lg:border-t-0">
|
||
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
||
<div>
|
||
<h2 className="text-sm font-semibold">生成任务</h2>
|
||
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">图片与视频任务统一展示</p>
|
||
</div>
|
||
{hasActiveTasks ? <Clock3 className="h-4 w-4 animate-pulse text-brand" /> : null}
|
||
</div>
|
||
<div data-testid="design-task-scroll" className="h-full max-h-72 space-y-3 overflow-y-auto overscroll-contain p-3 lg:h-[calc(100%-57px)] lg:max-h-none">
|
||
{tasks.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-border/80 bg-background/70 px-4 py-7 text-center">
|
||
<WandSparkles className="mx-auto h-5 w-5 text-muted-foreground" />
|
||
<p className="mt-3 text-xs font-semibold">还没有生成任务</p>
|
||
<p className="mt-1 text-[11px] font-medium leading-5 text-muted-foreground">
|
||
与 Agent 确认设计方向后,任务会出现在这里。
|
||
</p>
|
||
</div>
|
||
) : tasks.map((task) => <TaskCard key={task.taskId} task={task} />)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ImageCanvas;
|