feat: 流式展示设计 Agent 对话

需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
2026-08-02 21:21:50 +08:00
parent 518d7ab4cd
commit 90a249db31
9 changed files with 819 additions and 93 deletions

View File

@@ -58,6 +58,8 @@ function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
return null;
}
type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
function AssetPreview({ asset }: { asset: DesignAsset }) {
const [url, setUrl] = useState<string | null>(null);
@@ -190,6 +192,7 @@ export function ImageCanvas() {
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);
@@ -208,6 +211,35 @@ export function ImageCanvas() {
() => 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));
@@ -225,17 +257,20 @@ export function ImageCanvas() {
if (typeof conversationEndRef.current?.scrollIntoView === 'function') {
conversationEndRef.current.scrollIntoView({ block: 'end' });
}
}, [workspace?.messages.length]);
}, [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);
setPrompt('');
} 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);
@@ -244,11 +279,13 @@ export function ImageCanvas() {
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);
@@ -368,7 +405,7 @@ export function ImageCanvas() {
<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">
{workspace.messages.length === 0 ? (
{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">
@@ -382,7 +419,7 @@ export function ImageCanvas() {
</div>
) : (
<div className="flex flex-col gap-7">
{workspace.messages.map((message) => {
{conversationMessages.map((message) => {
const assistant = message.role === 'assistant';
return (
<article key={message.id} className={cn('flex gap-3', assistant ? 'justify-start' : 'justify-end')}>
@@ -396,7 +433,25 @@ export function ImageCanvas() {
{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}</p>
<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}