From 90a249db31cd64893bc4f7aa61668078a821d6b5 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 2 Aug 2026 21:21:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=81=E5=BC=8F=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20Agent=20=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。 --- .../image-workspace/works-square-workspace.ts | 236 +++++++++++++++--- shared/image-workspace.ts | 14 +- src/lib/image-workspace.ts | 10 +- src/pages/ImageCanvas/index.tsx | 65 ++++- src/stores/image-workspace.ts | 226 +++++++++++++---- tests/unit/image-canvas-page.test.tsx | 64 ++++- tests/unit/image-workspace-api.test.ts | 9 +- tests/unit/image-workspace-store.test.ts | 98 ++++++++ .../works-square-design-workspace.test.ts | 190 +++++++++++++- 9 files changed, 819 insertions(+), 93 deletions(-) diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 5660320..360dc4b 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -1,5 +1,6 @@ import type { DesignAsset, + DesignAssistantDeltaEvent, DesignBrief, DesignCapabilities, DesignConfirmGenerationInput, @@ -131,6 +132,36 @@ type ServerAgentEvent = { payload: unknown; }; +type ServerAgentCommand = { + run_id: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + error: ServerAgentCommandError | null; +}; + +type ServerAgentCommandError = { + code: string; + message: string; + retryable: boolean; +}; + +type ServerAgentRun = { + run_id: string; + status: 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled'; + error: ServerAgentCommandError | null; +}; + +type AgentDesignTurnSubmission = { + workspaceId: string; + clientTurnId: string; + expectedTurnRevision: number; + message: string; + attachmentAssetIds: string[]; + action: null | { + type: 'confirm_generation'; + quote_id: string; + }; +}; + type AgentWebSocket = { readyState: number; onopen: (() => void) | null; @@ -159,6 +190,8 @@ type TaskEventQueue = { const AGENT_WEBSOCKET_OPEN = 1; const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000; +const AGENT_RUN_POLL_INTERVAL_MS = 250; +const AGENT_RUN_TIMEOUT_MS = 120_000; function mapBrief(brief: ServerBrief): DesignBrief { return { @@ -266,6 +299,41 @@ function isServerTask(value: unknown): value is ServerTask { && typeof task.updated_at === 'string'; } +function isServerWorkspace(value: unknown): value is ServerWorkspace { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const workspace = value as Record; + const brief = workspace.brief as Record | null; + return typeof workspace.workspace_id === 'string' + && typeof workspace.title === 'string' + && Number.isInteger(workspace.turn_revision) + && Number(workspace.turn_revision) >= 0 + && Number.isInteger(workspace.view_revision) + && Number(workspace.view_revision) >= 0 + && ['shaping', 'awaiting_confirmation', 'blocked'].includes(String(workspace.phase)) + && Boolean(brief) + && Number.isInteger(brief?.version) + && ['draft', 'ready', 'confirmed'].includes(String(brief?.status)) + && (brief?.medium === null || brief?.medium === 'image' || brief?.medium === 'video') + && typeof brief?.summary === 'string' + && typeof brief?.ready === 'boolean' + && (brief?.missing_decision === null || typeof brief?.missing_decision === 'string') + && Array.isArray(workspace.messages) + && workspace.messages.every((message) => { + if (!message || typeof message !== 'object' || Array.isArray(message)) return false; + const item = message as Record; + return (item.role === 'user' || item.role === 'assistant') + && ['user', 'reply', 'choice', 'confirmation', 'safety_redirect', 'failure'] + .includes(String(item.kind)) + && typeof item.text === 'string' + && Array.isArray(item.quick_replies) + && item.quick_replies.every((reply) => typeof reply === 'string') + && Number.isInteger(item.turn_revision) + && Number(item.turn_revision) >= 0 + && typeof item.created_at === 'string'; + }) + && typeof workspace.updated_at === 'string'; +} + function normalizeWorkspaceEvent( value: unknown, sessionId: string, @@ -285,6 +353,31 @@ function normalizeWorkspaceEvent( return null; } const payload = event.payload as Record; + if (event.type === 'design.assistant.delta') { + if (payload.workspace_id !== workspaceId + || typeof payload.client_turn_id !== 'string' + || payload.client_turn_id.length < 1 + || payload.client_turn_id.length > 128 + || !Number.isInteger(payload.turn_revision) + || Number(payload.turn_revision) < 1 + || !Number.isInteger(payload.chunk_index) + || Number(payload.chunk_index) < 0 + || typeof payload.delta !== 'string' + || payload.delta.length < 1 + || payload.delta.length > 128) { + return null; + } + return { + id: `${sessionId}:${event.sequence}`, + type: 'design.assistant.delta', + workspaceId, + clientTurnId: payload.client_turn_id, + turnRevision: Number(payload.turn_revision), + chunkIndex: Number(payload.chunk_index), + delta: payload.delta, + } satisfies DesignAssistantDeltaEvent; + } + if (event.type === 'design.generation_task.updated') { if (payload.workspace_id !== workspaceId || !Number.isInteger(payload.workspace_view_revision) @@ -303,13 +396,11 @@ function normalizeWorkspaceEvent( } if (event.type !== 'design.workspace.updated' - || !payload.workspace - || typeof payload.workspace !== 'object' - || Array.isArray(payload.workspace) + || !isServerWorkspace(payload.workspace) || !Array.isArray(payload.generation_tasks)) { return null; } - const workspace = payload.workspace as Record; + const workspace = payload.workspace; if (workspace.workspace_id !== workspaceId || !Number.isInteger(workspace.view_revision) || Number(workspace.view_revision) < 0 @@ -323,6 +414,7 @@ function normalizeWorkspaceEvent( type: 'design.generation_tasks.snapshot', workspaceId, workspaceViewRevision: Number(workspace.view_revision), + workspace: mapWorkspace(workspace), generationTasks: payload.generation_tasks.map((task) => mapTask(task as ServerTask)), } satisfies DesignGenerationTasksSnapshotEvent; } catch { @@ -464,10 +556,34 @@ function userFacingErrorMessage(code: string, fallback: string): string { design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', design_runtime_unavailable: 'AI 设计服务暂时不可用', design_production_unavailable: '当前生成能力暂时不可用', + agent_command_invalid: '设计请求内容无效,请检查后重试', }; return messages[code] ?? fallback; } +function agentRunErrorStatus(code: string): number { + if (code === 'workspace_not_found') return 404; + if (code === 'budget_denied') return 402; + if (code === 'workspace_revision_conflict' + || code === 'idempotency_conflict' + || code === 'generation_quote_expired' + || code === 'generation_quote_consumed' + || code === 'generation_quote_invalid') { + return 409; + } + if (code === 'agent_command_invalid' + || code === 'reference_asset_invalid' + || code === 'policy_blocked') { + return 422; + } + if (code === 'design_reasoner_unavailable' + || code === 'design_runtime_unavailable' + || code === 'design_production_unavailable') { + return 503; + } + return 502; +} + export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { private readonly apiBaseUrl: string; private readonly fetchImpl: typeof fetch; @@ -539,40 +655,104 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } async submitMessage(input: DesignSubmitMessageInput): Promise { - const workspace = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`, - { - method: 'POST', - body: JSON.stringify({ - client_turn_id: input.clientTurnId, - expected_turn_revision: input.expectedTurnRevision, - message: input.message, - attachment_asset_ids: input.attachmentAssetIds ?? [], - action: null, - }), - }, - ); - return mapWorkspace(workspace); + return this.executeAgentTurn({ + workspaceId: input.workspaceId, + clientTurnId: input.clientTurnId, + expectedTurnRevision: input.expectedTurnRevision, + message: input.message, + attachmentAssetIds: input.attachmentAssetIds ?? [], + action: null, + }); } async confirmGeneration(input: DesignConfirmGenerationInput): Promise { - const workspace = await this.requestJson( - `/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`, + return this.executeAgentTurn({ + workspaceId: input.workspaceId, + clientTurnId: input.clientTurnId, + expectedTurnRevision: input.expectedTurnRevision, + message: '确认生成', + attachmentAssetIds: [], + action: { + type: 'confirm_generation', + quote_id: input.quoteId, + }, + }); + } + + private async executeAgentTurn(input: AgentDesignTurnSubmission): Promise { + let session = await this.ensureEventSession(input.workspaceId); + let command: ServerAgentCommand; + try { + command = await this.submitTurnCommand(session.session_id, input); + } catch (error) { + if (!(error instanceof DesignWorkspaceModuleError) + || (error.code !== 'agent_session_not_found' + && error.code !== 'agent_session_closed')) { + throw error; + } + await this.invalidateEventSession(input.workspaceId); + session = await this.ensureEventSession(input.workspaceId); + command = await this.submitTurnCommand(session.session_id, input); + } + const run = await this.waitForAgentRun(session.session_id, command.run_id); + if (run.status !== 'succeeded') { + const code = run.error?.code ?? ( + run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' + ); + const fallback = run.error?.message ?? ( + run.status === 'cancelled' + ? '设计 Agent 请求已取消' + : '设计 Agent 暂时不可用,请稍后重试' + ); + throw new DesignWorkspaceModuleError( + agentRunErrorStatus(code), + code, + userFacingErrorMessage(code, fallback), + ); + } + return this.getWorkspace(input.workspaceId); + } + + private submitTurnCommand( + sessionId: string, + input: AgentDesignTurnSubmission, + ): Promise { + return this.requestJson( + `/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, { method: 'POST', body: JSON.stringify({ - client_turn_id: input.clientTurnId, - expected_turn_revision: input.expectedTurnRevision, - message: '确认生成', - attachment_asset_ids: [], - action: { - type: 'confirm_generation', - quote_id: input.quoteId, + client_command_id: input.clientTurnId, + name: 'turn.submit', + input: { + expected_turn_revision: input.expectedTurnRevision, + message: input.message, + attachment_asset_ids: input.attachmentAssetIds, + action: input.action, }, }), }, ); - return mapWorkspace(workspace); + } + + private async waitForAgentRun(sessionId: string, runId: string): Promise { + const deadline = Date.now() + AGENT_RUN_TIMEOUT_MS; + while (true) { + const run = await this.requestJson( + `/api/agents/sessions/${encodeURIComponent(sessionId)}/runs/${encodeURIComponent(runId)}`, + ); + if (run.status === 'succeeded' || run.status === 'failed' || run.status === 'cancelled') { + return run; + } + if (Date.now() >= deadline) { + throw new DesignWorkspaceModuleError( + 504, + 'design_agent_run_timeout', + '设计 Agent 响应超时,请稍后重试', + ); + } + await new Promise((resolve) => setTimeout(resolve, AGENT_RUN_POLL_INTERVAL_MS)); + } } async listTasks(workspaceId: string): Promise { diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index f6ca53d..aca98c7 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -98,12 +98,24 @@ export type DesignGenerationTasksSnapshotEvent = { type: 'design.generation_tasks.snapshot'; workspaceId: string; workspaceViewRevision: number; + workspace: DesignWorkspace; generationTasks: DesignGenerationTask[]; }; +export type DesignAssistantDeltaEvent = { + id: string; + type: 'design.assistant.delta'; + workspaceId: string; + clientTurnId: string; + turnRevision: number; + chunkIndex: number; + delta: string; +}; + export type DesignWorkspaceEvent = | DesignGenerationTaskUpdatedEvent - | DesignGenerationTasksSnapshotEvent; + | DesignGenerationTasksSnapshotEvent + | DesignAssistantDeltaEvent; export type DesignWorkspaceBootstrap = { capabilities: DesignCapabilities; diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 48d4d98..8e97a2b 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -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 { 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 { return requestData( `${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`, { method: 'POST', body: JSON.stringify({ - clientTurnId: createClientId('turn'), + clientTurnId, expectedTurnRevision, }), }, diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index a334899..1c868a8 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -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(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(() => { + 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() {
- {workspace.messages.length === 0 ? ( + {conversationMessages.length === 0 ? (
@@ -382,7 +419,7 @@ export function ImageCanvas() {
) : (
- {workspace.messages.map((message) => { + {conversationMessages.map((message) => { const assistant = message.role === 'assistant'; return (
@@ -396,7 +433,25 @@ export function ImageCanvas() { {assistant ? '设计 Agent' : '你'}
-

{message.text}

+

+ {message.text} + {message.streaming && message.text ? ( +

{message.generationQuote?.status === 'active' ? ( Promise; @@ -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).data; + if (typeof data !== 'string') return null; + try { + const payload = JSON.parse(data) as Partial; + 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((set, get) => { const stopTaskStream = () => { closeTaskEventSource(); @@ -191,6 +248,44 @@ export const useImageWorkspaceStore = create((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((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((set, get) => activeWorkspaceId: null, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: message, }); @@ -319,13 +419,20 @@ export const useImageWorkspaceStore = create((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((set, get) => activeWorkspaceId: null, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: null, @@ -389,6 +497,7 @@ export const useImageWorkspaceStore = create((set, get) => activeWorkspaceId, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: null, }); @@ -404,6 +513,7 @@ export const useImageWorkspaceStore = create((set, get) => activeWorkspaceId: null, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: message, }); @@ -419,7 +529,7 @@ export const useImageWorkspaceStore = create((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((set, get) => activeWorkspaceId: workspaceId, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: null, }); @@ -512,17 +623,31 @@ export const useImageWorkspaceStore = create((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((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((set, get) => activeWorkspaceId: null, workspace: null, tasks: [], + pendingTurn: null, taskStreamState: 'idle', error: null, }); diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index 630edf3..f9acff5 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -6,6 +6,7 @@ import { ImageCanvas } from '@/pages/ImageCanvas'; import { useAuthStore } from '@/stores/auth'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { + DesignAssistantDeltaEvent, DesignGenerationTask, DesignGenerationTaskUpdatedEvent, DesignWorkspace, @@ -40,6 +41,14 @@ class MockEventSource { } } +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + vi.mock('@/lib/image-workspace', async (importOriginal) => { const actual = await importOriginal(); return { @@ -309,10 +318,57 @@ describe('ImageCanvas Workspace-first design experience', () => { 'workspace-cloud', 1, '把主角换成暖色轮廓光', + expect.stringMatching(/^turn-/), )); expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled(); }); + it('renders the pending user turn and assistant deltas while the command is running', async () => { + const response = deferred(); + sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); + render(); + await screen.findByText('云端角色设定'); + await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce()); + + fireEvent.change(screen.getByLabelText('设计需求'), { + target: { value: '让画面更有海洋呼吸感' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' })); + + expect(await screen.findByText('让画面更有海洋呼吸感')).toBeInTheDocument(); + expect(screen.getByRole('status', { name: '设计 Agent 正在回复' })).toBeInTheDocument(); + const pending = useImageWorkspaceStore.getState().pendingTurn!; + act(() => { + taskEventSource.emit('design.assistant.delta', { + id: 'session-one:2', + type: 'design.assistant.delta', + workspaceId: 'workspace-cloud', + clientTurnId: pending.clientTurnId, + turnRevision: pending.turnRevision, + chunkIndex: 0, + delta: '可以先增加', + } satisfies DesignAssistantDeltaEvent); + taskEventSource.emit('design.assistant.delta', { + id: 'session-one:3', + type: 'design.assistant.delta', + workspaceId: 'workspace-cloud', + clientTurnId: pending.clientTurnId, + turnRevision: pending.turnRevision, + chunkIndex: 1, + delta: '留白和水流节奏。', + } satisfies DesignAssistantDeltaEvent); + }); + + expect(screen.getByTestId('image-workspace-conversation')) + .toHaveTextContent('可以先增加留白和水流节奏。'); + act(() => response.resolve({ + ...workspaceFixture(), + turnRevision: 2, + viewRevision: 2, + })); + await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull()); + }); + it('refreshes tasks after an Agent turn creates a generation task', async () => { const queuedTask = { ...taskFixture, @@ -337,6 +393,7 @@ describe('ImageCanvas Workspace-first design experience', () => { 'workspace-cloud', 1, confirmationReply, + expect.stringMatching(/^turn-/), )); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument(); @@ -357,7 +414,12 @@ describe('ImageCanvas Workspace-first design experience', () => { fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' })); await waitFor(() => expect(confirmImageWorkspaceGenerationMock) - .toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one')); + .toHaveBeenCalledWith( + 'workspace-cloud', + 1, + 'quote-one', + expect.stringMatching(/^turn-/), + )); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); }); diff --git a/tests/unit/image-workspace-api.test.ts b/tests/unit/image-workspace-api.test.ts index 434c23d..1827e09 100644 --- a/tests/unit/image-workspace-api.test.ts +++ b/tests/unit/image-workspace-api.test.ts @@ -80,7 +80,12 @@ describe('AI design renderer API boundary', () => { }); it('sends conversation turns with the current turn revision and no provider settings', async () => { - await sendImageWorkspaceMessage('workspace/one', 4, ' 继续调整构图 '); + await sendImageWorkspaceMessage( + 'workspace/one', + 4, + ' 继续调整构图 ', + 'turn-client-1', + ); const [path, init] = hostApiFetchMock.mock.calls[0]; expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages'); @@ -89,7 +94,7 @@ describe('AI design renderer API boundary', () => { expectedTurnRevision: 4, message: '继续调整构图', attachmentAssetIds: [], - clientTurnId: expect.stringMatching(/^turn-/), + clientTurnId: 'turn-client-1', }); expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/); }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 9fb75a5..a524bc8 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { + DesignAssistantDeltaEvent, DesignGenerationTask, DesignGenerationTasksSnapshotEvent, DesignGenerationTaskUpdatedEvent, @@ -12,6 +13,7 @@ const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); +const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/image-workspace', async (importOriginal) => { const actual = await importOriginal(); @@ -21,6 +23,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args), fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args), + sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), }; }); @@ -123,6 +126,7 @@ function taskSnapshotEvent( type: 'design.generation_tasks.snapshot', workspaceId: 'workspace-one', workspaceViewRevision, + workspace: workspace('workspace-one', workspaceViewRevision), generationTasks: [{ ...task, status, @@ -139,6 +143,7 @@ describe('AI design task event store', () => { fetchImageWorkspaceMock.mockResolvedValue(bootstrap()); fetchImageWorkspaceProjectMock.mockResolvedValue(workspace()); fetchImageWorkspaceTasksMock.mockResolvedValue([task]); + sendImageWorkspaceMessageMock.mockResolvedValue(workspace()); }); afterEach(() => { @@ -167,6 +172,70 @@ describe('AI design task event store', () => { }); }); + it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => { + const source = new MockEventSource(); + const response = deferred(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); + + await useImageWorkspaceStore.getState().load(); + await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce()); + const sending = useImageWorkspaceStore.getState().sendMessage('把主视觉改成鲸鱼'); + const pending = useImageWorkspaceStore.getState().pendingTurn; + expect(pending).toMatchObject({ + userText: '把主视觉改成鲸鱼', + assistantText: '', + turnRevision: 2, + }); + + const firstDelta = { + id: 'session-one:2', + type: 'design.assistant.delta', + workspaceId: 'workspace-one', + clientTurnId: pending!.clientTurnId, + turnRevision: 2, + chunkIndex: 0, + delta: '可以,先强化', + } satisfies DesignAssistantDeltaEvent; + source.emit('design.assistant.delta', firstDelta); + source.emit('design.assistant.delta', firstDelta); + source.emit('design.assistant.delta', { + ...firstDelta, + id: 'session-one:3', + chunkIndex: 1, + delta: '鲸鱼的轮廓。', + } satisfies DesignAssistantDeltaEvent); + + expect(useImageWorkspaceStore.getState().pendingTurn?.assistantText) + .toBe('可以,先强化鲸鱼的轮廓。'); + + const canonical = { + ...workspace('workspace-one', 2), + turnRevision: 2, + messages: [{ + id: 'workspace-one:2:assistant:0', + role: 'assistant' as const, + kind: 'reply' as const, + text: '可以,先强化鲸鱼的轮廓。', + quickReplies: [], + generationQuote: null, + turnRevision: 2, + createdAt: '2026-08-02T10:01:00Z', + }], + }; + source.emit('design.generation_tasks.snapshot', { + ...taskSnapshotEvent(2, 'queued'), + id: 'session-one:4', + workspace: canonical, + } satisfies DesignGenerationTasksSnapshotEvent); + + expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull(); + expect(useImageWorkspaceStore.getState().workspace?.messages[0].text) + .toBe('可以,先强化鲸鱼的轮廓。'); + response.resolve(canonical); + await sending; + }); + it('closes the previous stream when switching workspaces and on reset', async () => { const first = new MockEventSource(); const second = new MockEventSource(); @@ -189,6 +258,35 @@ describe('AI design task event store', () => { expect(useImageWorkspaceStore.getState().taskStreamState).toBe('idle'); }); + it('does not switch back when an Agent turn finishes after selecting another Workspace', async () => { + const first = new MockEventSource(); + const second = new MockEventSource(); + const response = deferred(); + openImageWorkspaceTaskEventsMock + .mockResolvedValueOnce(first as unknown as EventSource) + .mockResolvedValueOnce(second as unknown as EventSource); + fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two'])); + fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => ( + Promise.resolve(workspace(workspaceId)) + )); + sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise); + + await useImageWorkspaceStore.getState().load(); + const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报'); + await useImageWorkspaceStore.getState().selectProject('workspace-two'); + response.resolve({ + ...workspace('workspace-one', 2), + turnRevision: 2, + }); + await sending; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-two', + workspace: { workspaceId: 'workspace-two' }, + pendingTurn: null, + }); + }); + it('does not let a slow previous Workspace selection overwrite the latest one', async () => { const rootSource = new MockEventSource(); const latestSource = new MockEventSource(); diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index a1f41dc..23d9864 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -147,8 +147,123 @@ describe('Works Square AI design adapter', () => { ))).toBe(true); }); + it('submits a conversation turn through the persistent Agent Gateway Session', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + session_id: 'session-one', + status: 'active', + }, 201)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-one', + status: 'queued', + error: null, + }, 202)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-one', + status: 'succeeded', + error: null, + })) + .mockResolvedValueOnce(jsonResponse(serverWorkspace)); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + clientInstanceId: 'installation-one', + }); + + await expect(adapter.submitMessage({ + workspaceId: 'workspace-one', + clientTurnId: 'turn-two', + expectedTurnRevision: 1, + message: '做一张保护海洋的公益海报', + attachmentAssetIds: ['asset-reference'], + })).resolves.toMatchObject({ + workspaceId: 'workspace-one', + turnRevision: 1, + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://square.example/api/agents/sessions/session-one/commands', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + client_command_id: 'turn-two', + name: 'turn.submit', + input: { + expected_turn_revision: 1, + message: '做一张保护海洋的公益海报', + attachment_asset_ids: ['asset-reference'], + action: null, + }, + }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://square.example/api/agents/sessions/session-one/runs/run-one', + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + 'https://square.example/api/design/workspaces/workspace-one', + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it('maps an invalid Runtime command to a user input error', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + session_id: 'session-one', + status: 'active', + }, 201)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-invalid', + status: 'queued', + error: null, + }, 202)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-invalid', + status: 'failed', + error: { + code: 'agent_command_invalid', + message: 'private validation detail', + retryable: false, + }, + })); + const adapter = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://square.example', + fetchImpl: fetchMock, + }); + + await expect(adapter.submitMessage({ + workspaceId: 'workspace-one', + clientTurnId: 'turn-invalid', + expectedTurnRevision: 1, + message: 'invalid', + })).rejects.toMatchObject({ + status: 422, + code: 'agent_command_invalid', + message: '设计请求内容无效,请检查后重试', + }); + }); + it('keeps task creation behind structured Quote confirmation', async () => { - const fetchMock = vi.fn().mockResolvedValue(jsonResponse(serverWorkspace)); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + session_id: 'session-one', + status: 'active', + }, 201)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-confirm', + status: 'queued', + error: null, + }, 202)) + .mockResolvedValueOnce(jsonResponse({ + run_id: 'run-confirm', + status: 'succeeded', + error: null, + })) + .mockResolvedValueOnce(jsonResponse(serverWorkspace)); const adapter = new WorksSquareDesignWorkspace({ apiBaseUrl: 'https://square.example/', fetchImpl: fetchMock, @@ -162,19 +277,33 @@ describe('Works Square AI design adapter', () => { }); expect(workspace.workspaceId).toBe('workspace-one'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://square.example/api/design/workspaces/workspace-one/turns', + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://square.example/api/agents/sessions/session-one/commands', expect.objectContaining({ method: 'POST', body: JSON.stringify({ - client_turn_id: 'turn-two', - expected_turn_revision: 1, - message: '确认生成', - attachment_asset_ids: [], - action: { type: 'confirm_generation', quote_id: 'quote-one' }, + client_command_id: 'turn-two', + name: 'turn.submit', + input: { + expected_turn_revision: 1, + message: '确认生成', + attachment_asset_ids: [], + action: { type: 'confirm_generation', quote_id: 'quote-one' }, + }, }), }), ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'https://square.example/api/agents/sessions/session-one/runs/run-confirm', + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + 'https://square.example/api/design/workspaces/workspace-one', + expect.objectContaining({ headers: expect.any(Object) }), + ); }); it('maps stable generation tasks and private asset relay paths', async () => { @@ -278,13 +407,32 @@ describe('Works Square AI design adapter', () => { type: 'design.workspace.updated', schema_version: 1, payload: { - workspace: { - workspace_id: 'workspace-one', - view_revision: 2, - }, + workspace: serverWorkspace, generation_tasks: [snapshotTask], }, }; + const assistantDeltaEvent = { + session_id: 'session-one', + sequence: 2, + runtime: 'design', + type: 'design.assistant.delta', + schema_version: 1, + payload: { + workspace_id: 'workspace-one', + client_turn_id: 'turn-two', + turn_revision: 2, + chunk_index: 0, + delta: '方向已经明确', + }, + }; + const malformedDeltaEvent = { + ...assistantDeltaEvent, + sequence: 99, + payload: { + ...assistantDeltaEvent.payload, + chunk_index: -1, + }, + }; const taskEvent = { session_id: 'session-one', sequence: 3, @@ -336,6 +484,8 @@ describe('Works Square AI design adapter', () => { { frames: [ { type: 'event', event: snapshotEvent }, + { type: 'event', event: malformedDeltaEvent }, + { type: 'event', event: assistantDeltaEvent }, { type: 'event', event: taskEvent }, ], closeCode: 1000, @@ -367,12 +517,28 @@ describe('Works Square AI design adapter', () => { type: 'design.generation_tasks.snapshot', workspaceId: 'workspace-one', workspaceViewRevision: 2, + workspace: expect.objectContaining({ + workspaceId: 'workspace-one', + title: serverWorkspace.title, + messages: [expect.objectContaining({ + text: serverWorkspace.messages[0].text, + })], + }), generationTasks: [expect.objectContaining({ taskId: 'task-snapshot', medium: 'image', status: 'running', })], }, + { + id: 'session-one:2', + type: 'design.assistant.delta', + workspaceId: 'workspace-one', + clientTurnId: 'turn-two', + turnRevision: 2, + chunkIndex: 0, + delta: '方向已经明确', + }, { id: 'session-one:3', type: 'design.generation_task.updated',