Files
makelore/src/pages/ImageCanvas/index.tsx
brother7 90a249db31 feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
2026-08-02 21:21:50 +08:00

558 lines
24 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 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;