From ca4bf6db5e7b2012a9b97cad1dccb2e5fc57cc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 16 Jul 2026 17:18:08 +0800 Subject: [PATCH] persist error message in oss doc --- ...ADR】画布Agent会话消息存OSS-2026-07-03.md | 4 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 4 +- packages/shared/src/contracts/editorAgent.ts | 1 + .../crates/api-server/src/editor_agent/api.rs | 101 ++++++++++++++---- .../shared-contracts/src/editor_agent.rs | 1 + .../EditorAgentConversation/MessageBubble.tsx | 31 ++++-- .../useEditorAgentConversation.test.tsx | 25 ++++- 9 files changed, 134 insertions(+), 37 deletions(-) diff --git a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md index 7897279b9..c1c881dbe 100644 --- a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md +++ b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md @@ -14,7 +14,7 @@ - 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。 - 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。 - 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。 -- Agent 规划或工具调用失败时,仍保留 assistant / system 消息和 `status=failed` 工具记录,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不能只放在本次 JSON 响应的瞬时 `errorMessage` 中。 +- Agent 规划失败使用 `role=system`、正文以 `ERROR ` 开头的消息持久化;首次响应和同 `clientMessageId` 重放都通过 `deltaMessages` 返回该消息,`errorMessage` 不重复携带。前端隐藏前缀并显示红色错误气泡,后端构建后续 LLM memory 时仍保留该消息,让 Agent 获取上一轮失败上下文。工具调用失败继续保留 `status=failed` 工具记录、模型和错误信息;失败记录都是会话历史的一部分。 - 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。 ## 备选方案与取舍 @@ -31,5 +31,5 @@ - 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。 - 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。 - 会话软删只打表标记,OSS 对象保留,便于恢复与审计。 -- 规划或工具生成失败也必须写入消息文档:assistant / system 消息保留失败状态、模型和错误信息,便于用户回看失败原因和后续排障。 +- 规划或工具生成失败也必须写入消息文档:规划失败保存 `ERROR ` system 消息,工具失败保存失败状态、模型和错误信息,便于用户回看失败原因和后续排障。 - 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7c988c30d..3ab61f360 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -77,7 +77,7 @@ - `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。 - `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。 - `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。 -- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。规划或工具失败仍必须把失败 assistant / system 消息、工具状态和错误信息写入 OSS 会话历史,不得只返回一次性 `errorMessage`。 +- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。LLM / 规划失败写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入 `deltaMessages`,不再重复设置 `errorMessage`;前端隐藏前缀后显示红色错误气泡。工具失败继续保存工具状态和错误信息。 - `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。 - `POST /api/editor/assets/folders`:新建素材文件夹。 - `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dbe718868..ae292e591 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -81,7 +81,7 @@ npm run check:server-rs-ddd - `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。 - `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 - `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 -- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。规划或工具失败必须形成可回读的失败消息,不能只返回瞬时 `errorMessage`。 +- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM / 规划失败必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 对话附件只允许引用当前工程 `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/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index dd96813f1..1f5489165 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -33,7 +33,7 @@ ## 当前分支落地状态 - 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。 -- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。工具失败时仍应把失败 assistant / system 消息、`status=failed`、模型和错误信息写入 OSS 会话历史;不能只在本次 JSON 响应中返回瞬时 `errorMessage`。 +- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。 - 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。 ## 会话与持久化 @@ -92,7 +92,7 @@ ## LLM 与计费 - 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。 -- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入明确错误消息,不使用本地关键词或“收到:...”回显兜底。 +- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。 - 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 - 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。 - 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。 diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts index 67d2a5d37..b85143eae 100644 --- a/packages/shared/src/contracts/editorAgent.ts +++ b/packages/shared/src/contracts/editorAgent.ts @@ -1,6 +1,7 @@ // 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 export const EDITOR_AGENT_MAX_ATTACHMENTS = 9; +export const EDITOR_AGENT_ERROR_MESSAGE_PREFIX = 'ERROR '; export type EditorAgentMessageRole = 'user' | 'assistant' | 'system'; 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 a2c18a0ad..2e458bd87 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -18,11 +18,11 @@ use shared_contracts::assets::{ EditorVideoGenerateRequest, }; use shared_contracts::editor_agent::{ - CreateEditorAgentConversationRequest, EditorAgentConversationListResponse, - EditorAgentConversationMessagesDocument, EditorAgentConversationResponse, - EditorAgentConversationSummary, EditorAgentMessage, EditorAgentMessageRequest, - EditorAgentMessageResponse, EditorAgentMessageRole, EditorAgentToolCall, - EditorAgentToolCallStatus, + CreateEditorAgentConversationRequest, EDITOR_AGENT_ERROR_MESSAGE_PREFIX, + EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, + EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentMessage, + EditorAgentMessageRequest, EditorAgentMessageResponse, EditorAgentMessageRole, + EditorAgentToolCall, EditorAgentToolCallStatus, }; use spacetime_client::{ EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, @@ -210,17 +210,30 @@ pub async fn editor_agent_message( let tool_context = context::build_tool_context(&document); // Build and run agent - let llm_client = state.creative_agent_gpt5_client().ok_or_else(|| { - AppError::from_status(axum::http::StatusCode::SERVICE_UNAVAILABLE) - .with_details(json!({ "message": "Creative Agent GPT-5 client not configured" })) - })?; + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + "Creative Agent GPT-5 client not configured", + ) + .await; + }; let llm_client = llm_client.clone(); - let pricing = state.editor_generation_pricing().await.map_err(|error| { - AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - })?; + let pricing = match state.editor_generation_pricing().await { + Ok(pricing) => pricing, + Err(error) => { + return persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + format!("failed to load editor generation pricing: {error}"), + ) + .await; + } + }; let memory = VecMemory::new(previous_messages); @@ -265,11 +278,16 @@ pub async fn editor_agent_message( &tool_context, &pricing, ) { - Err(err) => Ok(Json(EditorAgentMessageResponse { - conversation: conversation_summary, - delta_messages: Vec::new(), - error_message: Some(err.to_string()), - })), + Err(error) => { + persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + error.to_string(), + ) + .await + } Ok(delta_messages) => { for msg in &delta_messages { document.messages.push(msg.clone()); @@ -285,6 +303,39 @@ pub async fn editor_agent_message( } } +fn build_editor_agent_error_message( + message_id: usize, + error: impl std::fmt::Display, +) -> EditorAgentMessage { + EditorAgentMessage { + id: message_id, + client_message_id: None, + role: EditorAgentMessageRole::System, + text: format!("{EDITOR_AGENT_ERROR_MESSAGE_PREFIX}{error}"), + attachments: Vec::new(), + tool_call: None, + created_at: now_rfc3339(), + } +} + +async fn persist_editor_agent_planning_error( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &mut EditorAgentConversationMessagesDocument, + conversation_summary: EditorAgentConversationSummary, + error: impl std::fmt::Display, +) -> Result, AppError> { + let error_message = build_editor_agent_error_message(document.messages.len(), error); + document.messages.push(error_message.clone()); + write_messages_document(state, conversation, document).await?; + + Ok(Json(EditorAgentMessageResponse { + conversation: conversation_summary, + delta_messages: vec![error_message], + error_message: None, + })) +} + fn validate_editor_agent_message_request( payload: &EditorAgentMessageRequest, ) -> Result { @@ -451,6 +502,16 @@ mod tests { None, ); } + + #[test] + fn builds_system_error_message_with_wire_prefix() { + let message = build_editor_agent_error_message(3, "planning failed"); + + assert_eq!(message.id, 3); + assert_eq!(message.role, EditorAgentMessageRole::System); + assert_eq!(message.text, "ERROR planning failed"); + assert!(message.tool_call.is_none()); + } } fn editor_agent_system_prompt() -> &'static str { r#" diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs index 8cbd6b33f..924e885e6 100644 --- a/server-rs/crates/shared-contracts/src/editor_agent.rs +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::json; pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +pub const EDITOR_AGENT_ERROR_MESSAGE_PREFIX: &str = "ERROR "; pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 2; diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index 3a806c234..91e43eb52 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -1,4 +1,7 @@ -import type { EditorAgentMessage } from '@/packages/shared/src/contracts'; +import { + EDITOR_AGENT_ERROR_MESSAGE_PREFIX, + type EditorAgentMessage, +} from '@/packages/shared/src/contracts'; import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx'; @@ -51,7 +54,14 @@ export function MessageBubble({ onCancelToolCall, onJobCompleted, }: MessageBubbleProps) { - if (message.role === 'system' && !message.toolCall) { + const systemErrorText = + message.role === 'system' && + !message.toolCall && + message.text.startsWith(EDITOR_AGENT_ERROR_MESSAGE_PREFIX) + ? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length) + : null; + + if (message.role === 'system' && !message.toolCall && systemErrorText === null) { return null; } if ( @@ -73,29 +83,36 @@ export function MessageBubble({ const isUser = message.role === 'user'; const isSystem = message.role === 'system'; + const isSystemError = systemErrorText !== null; return (
- {!isSystem && message.text ? ( -
{message.text}
+ {(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? ( +
+ {systemErrorText ?? message.text} +
) : null} {!isSystem && message.attachments.length ? (
diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index bb35524e2..45d4b07ae 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -301,7 +301,7 @@ describe('useEditorAgentConversation', () => { ); }); - it('handles backend error responses', async () => { + it('applies persisted backend planning errors as system messages', async () => { const client = createClient(); vi.mocked(client.sendMessage).mockResolvedValue({ conversation: { @@ -310,8 +310,17 @@ describe('useEditorAgentConversation', () => { title: '这是美术素材', updatedAt: '2026-07-03T00:00:01.000Z', }, - deltaMessages: [], - errorMessage: 'LLM 未配置,无法处理这句话。', + deltaMessages: [ + { + id: 2, + role: 'system', + text: 'ERROR LLM 未配置,无法处理这句话。', + attachments: [], + toolCall: null, + createdAt: '2026-07-16T00:00:01.000Z', + }, + ], + errorMessage: null, } as EditorAgentMessageResponse); const { result } = renderHook(() => useEditorAgentConversation({ projectId: 'project-1', client }), @@ -327,7 +336,15 @@ describe('useEditorAgentConversation', () => { await result.current.sendMessage('这是美术素材'); }); - expect(result.current.errorMessage).toBe('LLM 未配置,无法处理这句话。'); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + text: 'ERROR LLM 未配置,无法处理这句话。', + }), + ]), + ); }); it('confirms a pending tool call, replaces its message and requests a canvas refresh', async () => {