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

@@ -42,6 +42,10 @@ function createClientId(prefix: string): string {
return `${prefix}-${id}`;
}
export function createImageWorkspaceTurnId(): string {
return createClientId('turn');
}
function getErrorStatus(error: unknown): number {
if (error instanceof AppError && typeof error.details?.status === 'number') {
return error.details.status;
@@ -120,13 +124,14 @@ export function sendImageWorkspaceMessage(
workspaceId: string,
expectedTurnRevision: number,
message: string,
clientTurnId = createImageWorkspaceTurnId(),
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
{
method: 'POST',
body: JSON.stringify({
clientTurnId: createClientId('turn'),
clientTurnId,
expectedTurnRevision,
message: message.trim(),
attachmentAssetIds: [],
@@ -139,13 +144,14 @@ export function confirmImageWorkspaceGeneration(
workspaceId: string,
expectedTurnRevision: number,
quoteId: string,
clientTurnId = createImageWorkspaceTurnId(),
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`,
{
method: 'POST',
body: JSON.stringify({
clientTurnId: createClientId('turn'),
clientTurnId,
expectedTurnRevision,
}),
},

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}

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
import {
confirmImageWorkspaceGeneration,
createImageWorkspaceTurnId,
createImageWorkspaceProject,
fetchImageWorkspace,
fetchImageWorkspaceProject,
@@ -13,6 +14,7 @@ import {
import { useAuthStore } from '@/stores/auth';
import {
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
type DesignAssistantDeltaEvent,
type DesignGenerationTask,
type DesignGenerationTasksSnapshotEvent,
type DesignGenerationTaskUpdatedEvent,
@@ -31,12 +33,23 @@ export type ImageWorkspaceLoadStatus =
export type ImageWorkspaceTaskStreamState = 'idle' | 'connecting' | 'connected' | 'degraded';
export type PendingDesignTurn = {
workspaceId: string;
clientTurnId: string;
turnRevision: number;
userText: string | null;
assistantText: string;
lastChunkIndex: number;
createdAt: string;
};
type ImageWorkspaceState = {
status: ImageWorkspaceLoadStatus;
bootstrap: DesignWorkspaceBootstrap | null;
activeWorkspaceId: string | null;
workspace: DesignWorkspace | null;
tasks: DesignGenerationTask[];
pendingTurn: PendingDesignTurn | null;
taskStreamState: ImageWorkspaceTaskStreamState;
error: string | null;
load: () => Promise<DesignWorkspaceBootstrap | null>;
@@ -111,6 +124,10 @@ function parseTasksSnapshotEvent(event: Event): DesignGenerationTasksSnapshotEve
|| typeof payload.id !== 'string'
|| typeof payload.workspaceId !== 'string'
|| !Number.isInteger(payload.workspaceViewRevision)
|| !payload.workspace
|| payload.workspace.workspaceId !== payload.workspaceId
|| payload.workspace.viewRevision !== payload.workspaceViewRevision
|| !Array.isArray(payload.workspace.messages)
|| !Array.isArray(payload.generationTasks)
|| !payload.generationTasks.every((task) => (
typeof task?.taskId === 'string' && task.workspaceId === payload.workspaceId
@@ -123,6 +140,30 @@ function parseTasksSnapshotEvent(event: Event): DesignGenerationTasksSnapshotEve
}
}
function parseAssistantDeltaEvent(event: Event): DesignAssistantDeltaEvent | null {
const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null;
try {
const payload = JSON.parse(data) as Partial<DesignAssistantDeltaEvent>;
if (payload.type !== 'design.assistant.delta'
|| typeof payload.id !== 'string'
|| typeof payload.workspaceId !== 'string'
|| typeof payload.clientTurnId !== 'string'
|| !Number.isInteger(payload.turnRevision)
|| Number(payload.turnRevision) < 1
|| !Number.isInteger(payload.chunkIndex)
|| Number(payload.chunkIndex) < 0
|| typeof payload.delta !== 'string'
|| payload.delta.length === 0
|| payload.delta.length > 128) {
return null;
}
return payload as DesignAssistantDeltaEvent;
} catch {
return null;
}
}
function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
return [...tasks].sort((left, right) => (
right.createdAt.localeCompare(left.createdAt) || right.taskId.localeCompare(left.taskId)
@@ -162,6 +203,22 @@ function upsertSummary(
};
}
function createPendingTurn(
workspace: DesignWorkspace,
clientTurnId: string,
userText: string,
): PendingDesignTurn {
return {
workspaceId: workspace.workspaceId,
clientTurnId,
turnRevision: workspace.turnRevision + 1,
userText,
assistantText: '',
lastChunkIndex: -1,
createdAt: new Date().toISOString(),
};
}
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
const stopTaskStream = () => {
closeTaskEventSource();
@@ -191,6 +248,44 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
return;
}
activeTaskEventSource = source;
source.addEventListener('design.assistant.delta', (event) => {
const delta = parseAssistantDeltaEvent(event);
if (!delta || delta.workspaceId !== activeTaskEventWorkspaceId) return;
set((state) => {
if (state.activeWorkspaceId !== delta.workspaceId
|| (state.workspace?.turnRevision ?? 0) >= delta.turnRevision) {
return state;
}
const pending = state.pendingTurn;
if (!pending) {
if (delta.chunkIndex !== 0) return state;
return {
pendingTurn: {
workspaceId: delta.workspaceId,
clientTurnId: delta.clientTurnId,
turnRevision: delta.turnRevision,
userText: null,
assistantText: delta.delta,
lastChunkIndex: delta.chunkIndex,
createdAt: new Date().toISOString(),
},
};
}
if (pending.workspaceId !== delta.workspaceId
|| pending.clientTurnId !== delta.clientTurnId
|| pending.turnRevision !== delta.turnRevision
|| delta.chunkIndex !== pending.lastChunkIndex + 1) {
return state;
}
return {
pendingTurn: {
...pending,
assistantText: `${pending.assistantText}${delta.delta}`,
lastChunkIndex: delta.chunkIndex,
},
};
});
});
source.addEventListener('design.generation_tasks.snapshot', (event) => {
const snapshot = parseTasksSnapshotEvent(event);
if (!snapshot || snapshot.workspaceId !== activeTaskEventWorkspaceId) return;
@@ -210,32 +305,36 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
Math.max(knownRevision, snapshot.workspaceViewRevision),
);
}
const workspace = state.workspace?.workspaceId === snapshot.workspaceId
? {
...state.workspace,
viewRevision: Math.max(
state.workspace.viewRevision,
snapshot.workspaceViewRevision,
),
}
: state.workspace;
const bootstrap = state.bootstrap
? {
...state.bootstrap,
workspaces: state.bootstrap.workspaces.map((candidate) => (
candidate.workspaceId === snapshot.workspaceId
? {
...candidate,
viewRevision: Math.max(
candidate.viewRevision,
snapshot.workspaceViewRevision,
),
}
: candidate
)),
}
const currentWorkspace = state.workspace?.workspaceId === snapshot.workspaceId
? state.workspace
: null;
return { tasks: sortTasks([...tasksById.values()]), workspace, bootstrap };
let workspace = state.workspace;
if (!currentWorkspace
|| snapshot.workspaceViewRevision >= currentWorkspace.viewRevision) {
workspace = snapshot.workspace;
} else if (snapshot.workspace.turnRevision > currentWorkspace.turnRevision) {
workspace = {
...snapshot.workspace,
title: currentWorkspace.title,
viewRevision: currentWorkspace.viewRevision,
updatedAt: currentWorkspace.updatedAt > snapshot.workspace.updatedAt
? currentWorkspace.updatedAt
: snapshot.workspace.updatedAt,
};
}
const bootstrap = workspace?.workspaceId === snapshot.workspaceId
? upsertSummary(state.bootstrap, workspace)
: state.bootstrap;
const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId
&& snapshot.workspace.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
tasks: sortTasks([...tasksById.values()]),
workspace,
bootstrap,
pendingTurn,
};
});
});
source.addEventListener('design.generation_task.updated', (event) => {
@@ -311,6 +410,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId: null,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: message,
});
@@ -319,13 +419,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
};
const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => {
set((state) => ({
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
activeWorkspaceId: workspace.workspaceId,
workspace,
error: null,
}));
set((state) => {
const pendingTurn = state.pendingTurn?.workspaceId === workspace.workspaceId
&& workspace.turnRevision >= state.pendingTurn.turnRevision
? null
: state.pendingTurn;
return {
status: 'ready',
bootstrap: upsertSummary(state.bootstrap, workspace),
activeWorkspaceId: workspace.workspaceId,
workspace,
pendingTurn,
error: null,
};
});
return workspace;
};
@@ -367,6 +474,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId: null,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
@@ -389,6 +497,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
@@ -404,6 +513,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId: null,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: message,
});
@@ -419,7 +529,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
createProject: async (title) => {
try {
const workspace = applyWorkspace(await createImageWorkspaceProject(title));
set({ tasks: [] });
set({ tasks: [], pendingTurn: null });
startTaskStream(workspace.workspaceId);
return workspace;
} catch (error) {
@@ -444,6 +554,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId: workspaceId,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});
@@ -512,17 +623,31 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
sendMessage: async (message) => {
const workspace = get().workspace;
const userText = message.trim();
const clientTurnId = createImageWorkspaceTurnId();
if (!workspace) throw new Error('请先选择设计项目');
try {
const updated = applyWorkspace(await sendImageWorkspaceMessage(
set({ pendingTurn: createPendingTurn(workspace, clientTurnId, userText), error: null });
const updated = await sendImageWorkspaceMessage(
workspace.workspaceId,
workspace.turnRevision,
message,
));
await get().refreshTasks().catch(() => []);
userText,
clientTurnId,
);
if (get().activeWorkspaceId === workspace.workspaceId) {
applyWorkspace(updated);
await get().refreshTasks().catch(() => []);
}
return updated;
} catch (error) {
set({ error: handleRequestError(error) });
if (get().activeWorkspaceId !== workspace.workspaceId) throw error;
const errorMessage = handleRequestError(error);
set((state) => ({
error: errorMessage,
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
? null
: state.pendingTurn,
}));
return await recoverRevisionConflict(error);
}
},
@@ -530,16 +655,32 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
confirmGeneration: async (quoteId) => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择设计项目');
const clientTurnId = createImageWorkspaceTurnId();
try {
const updated = applyWorkspace(await confirmImageWorkspaceGeneration(
set({
pendingTurn: createPendingTurn(workspace, clientTurnId, '确认生成'),
error: null,
});
const updated = await confirmImageWorkspaceGeneration(
workspace.workspaceId,
workspace.turnRevision,
quoteId,
));
await get().refreshTasks().catch(() => []);
clientTurnId,
);
if (get().activeWorkspaceId === workspace.workspaceId) {
applyWorkspace(updated);
await get().refreshTasks().catch(() => []);
}
return updated;
} catch (error) {
set({ error: handleRequestError(error) });
if (get().activeWorkspaceId !== workspace.workspaceId) throw error;
const errorMessage = handleRequestError(error);
set((state) => ({
error: errorMessage,
pendingTurn: state.pendingTurn?.clientTurnId === clientTurnId
? null
: state.pendingTurn,
}));
return await recoverRevisionConflict(error);
}
},
@@ -553,6 +694,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
activeWorkspaceId: null,
workspace: null,
tasks: [],
pendingTurn: null,
taskStreamState: 'idle',
error: null,
});