Files
makelore/src/pages/ImageCanvas/index.tsx
brother7 7b23cce67a 修复视频首帧选择弹窗交互
问题:选择作品图片后需等待 Agent 整轮响应,弹窗长时间不关闭。
修复:选择后立即关闭弹窗并继续提交 Asset ID,失败信息转到主对话区;补充慢响应回归测试。
2026-08-06 13:00:07 +08:00

953 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import {
Bot,
Check,
Clock3,
Cloud,
Download,
Film,
ImageIcon,
Loader2,
Maximize2,
Plus,
RefreshCw,
Send,
Sparkles,
Upload,
WandSparkles,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
IMAGE_WORKSPACE_CREATE_PROJECT_EVENT,
resolveImageWorkspaceAssetUrl,
saveImageWorkspaceAsset,
uploadImageWorkspaceAsset,
} 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']);
const TASK_FAILURE_MESSAGES: Record<string, string> = {
policy_blocked: '内容可能涉及版权或安全风险,请调整设计后重试',
budget_denied: '当前设计点不足,无法开始生成',
quote_expired: '生成方案已过期,请重新确认',
asset_rejected: '生成结果未通过媒体检查,请调整设计后重试',
dependency_unavailable: '生成服务暂时不可用,请稍后重试',
submission_unknown: '正在确认生成请求状态,请稍后查看',
submission_not_accepted: '生成请求未被服务接受,请重新确认后再试',
generation_failed: '生成未完成,请调整设计或稍后重试',
result_persist_failed: '生成结果保存失败,请稍后重试',
cancelled: '生成任务已取消',
expired: '生成任务已过期,请重新确认',
};
const CONFIRMATION_UNAVAILABLE_MESSAGE = (
'当前没有可确认的生成报价,请先让设计 Agent 完成方案与报价。'
);
const GENERATION_CONFIRMATION_INTENTS = new Set([
'确认生成',
'确认开始生成',
'确认并开始生成',
'确认开始制作',
'确认并开始制作',
]);
const VIDEO_FIRST_FRAME_PICKER_REPLY = '从作品列表选择图片';
function isGenerationConfirmationIntent(message: string): boolean {
const normalized = message.trim().replace(/[\s,。.!??、]/g, '');
return GENERATION_CONFIRMATION_INTENTS.has(normalized);
}
function taskStatusLabel(status: DesignTaskStatus): string {
if (status === 'queued') return '排队中';
if (status === 'running') return '生成中';
if (status === 'succeeded') return '已完成';
if (status === 'cancelled') return '已取消';
return '失败';
}
function taskFailureMessage(code: string): string {
return TASK_FAILURE_MESSAGES[code] ?? '生成未完成,请稍后重试';
}
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);
const [previewOpen, setPreviewOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
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"
/>
);
}
const download = async () => {
if (downloading) return;
setDownloading(true);
try {
const result = await saveImageWorkspaceAsset(asset);
if (result.status === 'saved') {
toast.success('图片已保存到本地');
}
} catch (error) {
toast.error('图片下载失败', {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setDownloading(false);
}
};
return (
<>
<div className="bg-surface-subtle">
<button
type="button"
aria-label="放大查看生成图片"
className="group relative block w-full cursor-zoom-in overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-inset"
onClick={() => setPreviewOpen(true)}
>
<img
src={url}
alt="AI 设计生成结果"
className="aspect-video w-full bg-surface-subtle object-contain outline outline-1 -outline-offset-1 outline-black/10 transition-transform duration-200 group-hover:scale-[1.015] group-focus-visible:scale-[1.015]"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1.5 bg-gradient-to-t from-black/55 to-transparent px-3 pb-2.5 pt-8 text-[11px] font-semibold text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
<Maximize2 className="h-3.5 w-3.5" />
</span>
</button>
<div className="grid grid-cols-2 gap-2 border-t border-border/60 bg-background/95 p-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
onClick={() => setPreviewOpen(true)}
>
<Maximize2 className="mr-1.5 h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={downloading ? '正在保存生成图片' : '下载生成图片'}
aria-busy={downloading}
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
disabled={downloading}
onClick={() => void download()}
>
{downloading
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
: <Download className="mr-1.5 h-3.5 w-3.5" />}
{downloading ? '保存中' : '下载图片'}
</Button>
</div>
</div>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-h-[92vh] max-w-6xl gap-0 overflow-hidden p-0">
<DialogHeader className="flex-row items-center justify-between space-y-0 border-b border-border/70 px-4 py-3 sm:px-5">
<div className="min-w-0 text-left">
<DialogTitle className="text-base"></DialogTitle>
<DialogDescription className="mt-0.5 text-xs tabular-nums">
{asset.width} × {asset.height} ·
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
aria-busy={downloading}
className="h-10 rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground transition-transform active:scale-[0.96]"
disabled={downloading}
onClick={() => void download()}
>
{downloading
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <Download className="mr-1.5 h-4 w-4" />}
{downloading ? '正在保存' : '下载原图'}
</Button>
<DialogClose asChild>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="关闭图片预览"
className="h-10 w-10 rounded-xl transition-transform active:scale-[0.96]"
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="flex min-h-0 items-center justify-center bg-black/90 p-3 sm:p-5">
<img
src={url}
alt="AI 设计大图预览"
className="max-h-[calc(92vh-76px)] max-w-full rounded-lg object-contain outline outline-1 outline-white/10"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
function FirstFrameAssetOption({
asset,
busy,
onSelect,
}: {
asset: DesignAsset;
busy: boolean;
onSelect(): void;
}) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((nextUrl) => {
if (!cancelled) setUrl(nextUrl);
})
.catch(() => {
if (!cancelled) setUrl(null);
});
return () => {
cancelled = true;
};
}, [asset.contentPath]);
return (
<article className="overflow-hidden rounded-2xl bg-background shadow-soft ring-1 ring-black/10 dark:ring-white/10">
<div className="flex aspect-video items-center justify-center bg-surface-subtle">
{url ? (
<img
src={url}
alt="可选作品图片"
className="h-full w-full object-contain outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
) : <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-xs font-medium tabular-nums text-muted-foreground">
{asset.width} × {asset.height}
</span>
<Button
type="button"
size="sm"
className="h-10 rounded-xl px-3 text-xs font-semibold transition-transform active:scale-[0.96]"
disabled={busy || !url}
onClick={onSelect}
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
</Button>
</div>
</article>
);
}
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">
{taskFailureMessage(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 [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false);
const [firstFrameBusyKey, setFirstFrameBusyKey] = useState<string | null>(null);
const [firstFrameError, setFirstFrameError] = useState<string | null>(null);
const [uploadedFirstFrameAsset, setUploadedFirstFrameAsset] = useState<DesignAsset | null>(null);
const conversationEndRef = useRef<HTMLDivElement | null>(null);
const quote = useMemo(
() => workspace ? activeQuote(workspace.messages) : null,
[workspace],
);
const generationAvailable = bootstrap?.capabilities.generation ?? false;
const firstFrameAssets = useMemo(
() => {
const assets = tasks
.filter((task) => task.status === 'succeeded')
.flatMap((task) => task.resultAssets)
.filter((asset) => asset.mediaType === 'image' && asset.mimeType === 'image/png');
if (uploadedFirstFrameAsset
&& uploadedFirstFrameAsset.workspaceId === workspace?.workspaceId) {
assets.unshift(uploadedFirstFrameAsset);
}
return [...new Map(assets.map((asset) => [asset.assetId, asset])).values()];
},
[tasks, uploadedFirstFrameAsset, workspace?.workspaceId],
);
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 handleConfirm = async (quoteId: string) => {
if (!workspace || confirmingQuoteId) return;
if (!generationAvailable) {
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
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 handleSend = async (override?: string) => {
const message = (override ?? prompt).trim();
if (!workspace || !message || submitting) return;
if (isGenerationConfirmationIntent(message)) {
setPrompt('');
if (quote && generationAvailable) {
await handleConfirm(quote.quoteId);
} else {
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
}
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 handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void handleSend();
}
};
const handleFirstFrameSelect = async (asset: DesignAsset) => {
if (!workspace || submitting || firstFrameBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
setFirstFrameBusyKey(asset.assetId);
setFirstFrameError(null);
setActionError(null);
setSubmitting(true);
setFirstFramePickerOpen(false);
try {
await sendMessage('使用作品图片作为视频首帧', [asset.assetId]);
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setActionError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setSubmitting(false);
}
};
const handleFirstFrameUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = '';
if (!workspace || !file || submitting || firstFrameBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
setFirstFrameBusyKey('upload');
setFirstFrameError(null);
setSubmitting(true);
try {
const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file);
setUploadedFirstFrameAsset(asset);
await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]);
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFramePickerOpen(false);
}
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFrameError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setSubmitting(false);
}
};
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 === VIDEO_FIRST_FRAME_PICKER_REPLY) {
setFirstFrameError(null);
setFirstFramePickerOpen(true);
return;
}
if (!isGenerationConfirmationIntent(reply)) {
setPrompt(reply);
return;
}
if (quote && generationAvailable) {
void handleConfirm(quote.quoteId);
return;
}
setPrompt('');
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
}}
>
{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">
{!generationAvailable ? (
<p className="mb-2 rounded-xl bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-700 dark:text-amber-300">
/
</p>
) : null}
<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>
<Dialog
open={firstFramePickerOpen}
onOpenChange={(open) => {
if (!open && firstFrameBusyKey) return;
setFirstFramePickerOpen(open);
if (!open) setFirstFrameError(null);
}}
>
<DialogContent className="max-h-[88vh] max-w-4xl grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
<DialogHeader className="flex-row items-start justify-between space-y-0 px-5 py-4 sm:px-6">
<div className="min-w-0 text-left">
<DialogTitle className="text-balance text-base"></DialogTitle>
<DialogDescription className="mt-1 text-pretty text-xs leading-5">
</DialogDescription>
</div>
<DialogClose asChild>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="关闭首帧选择"
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
disabled={Boolean(firstFrameBusyKey)}
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</DialogHeader>
<div className="min-h-0 overflow-y-auto bg-surface-subtle/55 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="mt-0.5 text-xs text-muted-foreground">
PNG
</p>
</div>
<label
htmlFor="design-first-frame-upload"
aria-busy={firstFrameBusyKey === 'upload'}
className={cn(
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm transition-transform active:scale-[0.96]',
firstFrameBusyKey && 'pointer-events-none opacity-50',
)}
>
{firstFrameBusyKey === 'upload'
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <Upload className="mr-1.5 h-4 w-4" />}
{firstFrameBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
<input
id="design-first-frame-upload"
type="file"
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
aria-label="上传本地图片"
className="sr-only"
disabled={Boolean(firstFrameBusyKey)}
onChange={(event) => void handleFirstFrameUpload(event)}
/>
</label>
</div>
{firstFrameError ? (
<p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">
{firstFrameError}
</p>
) : null}
{firstFrameAssets.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{firstFrameAssets.map((asset) => (
<FirstFrameAssetOption
key={asset.assetId}
asset={asset}
busy={Boolean(firstFrameBusyKey)}
onSelect={() => void handleFirstFrameSelect(asset)}
/>
))}
</div>
) : (
<div className="mt-4 rounded-2xl bg-background px-5 py-10 text-center shadow-soft ring-1 ring-black/5 dark:ring-white/10">
<ImageIcon className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 text-sm font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default ImageCanvas;