feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user