diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7876582e1..4dbd1b33b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -84,6 +84,7 @@ npm run check:server-rs-ddd - 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、SSE 事件持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 +- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 ### 创作 / 游玩统一流程主干 diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 25d122955..6f873f3a9 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -563,7 +563,7 @@ pub async fn cancel_editor_agent_tool_call( Ok(json_success_body( Some(&request_context), - &document.messages[message_id], + json!({ "ok": true }), )) } @@ -606,7 +606,10 @@ pub async fn confirm_editor_agent_tool_call( if tool_call.status != EditorAgentToolCallStatus::NotCompleted || tool_call.external_job_id.is_some() { - return Ok(json_success_body(Some(&request_context), message)); + return Ok(json_success_body( + Some(&request_context), + json!({ "ok": true }), + )); } let tool_name = tool_call.tool_name.clone(); @@ -932,7 +935,7 @@ pub async fn confirm_editor_agent_tool_call( write_messages_document(&state, &conversation, &document).await?; Ok(json_success_body( Some(&request_context), - &document.messages[message_id], + json!({ "ok": true }), )) } diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 7dc459ef1..7168dd0e4 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -576,18 +576,35 @@ describe('EditorAgentConversationPanelView', () => { it('shows structured pending details, hides system text and confirms once', async () => { const client = createClient(); const pendingMessage = createPendingToolCallMessage(); - vi.mocked(client.getConversation).mockResolvedValue({ - conversationId: 'conversation-1', - projectId: 'project-1', - title: '角色参考', - messages: [pendingMessage], - createdAt: '2026-07-03T00:00:00.000Z', - updatedAt: '2026-07-03T00:00:10.000Z', - }); - let resolveConfirmation: (message: EditorAgentMessage) => void = () => {}; + const confirmedMessage: EditorAgentMessage = { + ...pendingMessage, + text: 'internal completed tool output that must stay hidden', + toolCall: { + ...pendingMessage.toolCall!, + externalJobId: 'task-edit-panel-1', + }, + }; + vi.mocked(client.getConversation) + .mockResolvedValueOnce({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [pendingMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }) + .mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [confirmedMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }); + let resolveConfirmation: () => void = () => {}; vi.mocked(client.confirmToolCall).mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveConfirmation = resolve; }), ); @@ -614,6 +631,8 @@ describe('EditorAgentConversationPanelView', () => { expect( screen.queryByText('internal system prompt that must stay hidden'), ).toBeNull(); + const getConversationCallsBeforeConfirm = vi.mocked(client.getConversation) + .mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '确认' })); await waitFor(() => { @@ -626,56 +645,51 @@ describe('EditorAgentConversationPanelView', () => { ); await act(async () => { - resolveConfirmation({ - ...pendingMessage, - id: 2, - text: 'internal completed tool output that must stay hidden', - toolCall: { - ...pendingMessage.toolCall!, - status: 'completed', - externalJobId: 'task-edit-panel-1', - images: [ - { - resourceId: null, - objectKey: 'generated/result.png', - imageSrc: '/result.png', - thumbnailSrc: null, - width: 512, - height: 512, - }, - ], - }, - }); + resolveConfirmation(); }); - expect(await screen.findByText('已完成')).toBeTruthy(); + expect(await screen.findByText('执行中')).toBeTruthy(); + expect(client.getConversation).toHaveBeenCalledTimes( + getConversationCallsBeforeConfirm + 1, + ); expect( screen.queryByText( 'internal completed tool output that must stay hidden', ), ).toBeNull(); - expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); + expect(onCanvasRefreshRequested).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); it('cancels a pending tool call without exposing its system text', async () => { const client = createClient(); const pendingMessage = createPendingToolCallMessage(); - vi.mocked(client.getConversation).mockResolvedValue({ - conversationId: 'conversation-1', - projectId: 'project-1', - title: '角色参考', - messages: [pendingMessage], - createdAt: '2026-07-03T00:00:00.000Z', - updatedAt: '2026-07-03T00:00:10.000Z', - }); - vi.mocked(client.cancelToolCall).mockResolvedValue({ + const cancelledMessage: EditorAgentMessage = { ...pendingMessage, text: 'internal cancelled tool output that must stay hidden', toolCall: { ...pendingMessage.toolCall!, status: 'cancelled', }, - }); + }; + vi.mocked(client.getConversation) + .mockResolvedValueOnce({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [pendingMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }) + .mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [cancelledMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }); + vi.mocked(client.cancelToolCall).mockResolvedValue(undefined); render( { />, ); - fireEvent.click(await screen.findByRole('button', { name: '取消' })); + const cancelButton = await screen.findByRole('button', { name: '取消' }); + const getConversationCallsBeforeCancel = vi.mocked(client.getConversation) + .mock.calls.length; + fireEvent.click(cancelButton); expect(await screen.findByText('已取消')).toBeTruthy(); expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2); + expect(client.getConversation).toHaveBeenCalledTimes( + getConversationCallsBeforeCancel + 1, + ); expect( screen.queryByText( 'internal cancelled tool output that must stay hidden', diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 0c116ec52..ca176a1df 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -603,12 +603,8 @@ export function EditorAgentConversationPanelView({ ? toolCallAction.action : null } - onConfirmToolCall={(messageId) => { - void confirmToolCall(messageId); - }} - onCancelToolCall={(messageId) => { - void cancelToolCall(messageId); - }} + onConfirmToolCall={confirmToolCall} + onCancelToolCall={cancelToolCall} onJobCompleted={() => { void refreshActiveConversation(); onCanvasRefreshRequested?.(); diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index 20d2f4157..3a806c234 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -39,8 +39,8 @@ export function ThinkingBubble() { type MessageBubbleProps = { message: EditorAgentMessage; busyAction: 'confirm' | 'cancel' | null; - onConfirmToolCall: (messageId: number) => void; - onCancelToolCall: (messageId: number) => void; + onConfirmToolCall: (messageId: number) => Promise; + onCancelToolCall: (messageId: number) => Promise; onJobCompleted?: () => void; }; @@ -73,6 +73,7 @@ export function MessageBubble({ const isUser = message.role === 'user'; const isSystem = message.role === 'system'; + return (
void; - onCancel: (messageId: number) => void; + onConfirm: (messageId: number) => Promise; + onCancel: (messageId: number) => Promise; }; function readString(value: unknown) { @@ -28,6 +28,20 @@ export function PendingToolCall({ const displayArgs = toolCall.displayArgs; const label = editorAgentToolLabel(toolCall.toolName); const isBusy = busyAction !== null; + const statusLabel = + busyAction === 'confirm' + ? '执行中' + : busyAction === 'cancel' + ? '取消中' + : '待确认'; + + const handleCancel = () => { + void onCancel(messageId).catch(() => undefined); + }; + + const handleConfirm = () => { + void onConfirm(messageId).catch(() => undefined); + }; return (
@@ -36,7 +50,7 @@ export function PendingToolCall({