From 9dc65e1d4792917ac72d44c78721985f1dedca61 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 16:51:13 +0800 Subject: [PATCH] impl client message id and client retry --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 3 +- packages/shared/src/contracts/editorAgent.ts | 3 + .../crates/api-server/src/editor_agent/api.rs | 271 ++++++++++++++---- .../shared-contracts/src/editor_agent.rs | 104 +++++-- .../useEditorAgentConversation.test.tsx | 4 + .../useEditorAgentConversation.ts | 13 + src/services/apiClient.test.ts | 54 ++++ .../image-editor/editorAgentClient.test.ts | 8 + .../image-editor/editorAgentClient.ts | 7 + 11 files changed, 393 insertions(+), 78 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 683a429fb..6b2f8ad6b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -206,7 +206,7 @@ ## 2026-07-03 画布Agent会话元数据入 SpacetimeDB、消息正文存 OSS - 背景:图片画布工程需要对话式编辑历史,但消息正文随对话和工具结果增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。 -- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。 +- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。用户消息以独立 `clientMessageId` 在会话锁内幂等,数字 `message.id` 只作后端定位;旧 OSS 消息允许缺失幂等键,早期用户消息字符串 `id` 在读取时迁入 `clientMessageId`。画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。 - 影响范围:图片画布右侧 Agent 面板、`shared-contracts` / `packages/shared` 的 `editorAgent` 契约、`spacetime-module` / `spacetime-client`、`platform-oss` 内部读签名边界、画布生成落板规则。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 JSON client 定向测试、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8ed3685c1..dbe718868 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -78,7 +78,7 @@ npm run check:server-rs-ddd ### 图片画布 Agent 对话 -- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。 +- `/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`。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 92f0870be..dd96813f1 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -32,7 +32,7 @@ ## 当前分支落地状态 -- 已落地:会话元数据、OSS 消息文档、会话 CRUD、普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。 +- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。 - 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。工具失败时仍应把失败 assistant / system 消息、`status=failed`、模型和错误信息写入 OSS 会话历史;不能只在本次 JSON 响应中返回瞬时 `errorMessage`。 - 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。 @@ -44,6 +44,7 @@ - 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。 - 会话标题:新会话默认「新对话」,首条含文本的用户消息发出后自动截取前 N 字作为标题;列表摘要、详情和消息回包均携带同一必填标题,前端只展示该标题,不以会话 ID 或本地推导兜底。标题写入失败会使该消息请求失败,不能静默继续。 - 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。 +- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId`;`editorAgentClient` 对网络错误和通用瞬时状态码显式启用 1 次 POST transport 重试,重试复用同一个已序列化 body、`clientMessageId` 和 `x-request-id`。该字段独立于数字 `message.id` 并随用户消息写入 OSS。旧消息缺失时按 `None` 兼容;早期 SSE 文档若把客户端键存成用户消息字符串 `id`,读取时将其迁入 `clientMessageId`,同时重建数字定位符。后端在会话锁内检查重复键:内容一致时返回已持久化的同一回合结果,尚无结果时复用原用户消息继续规划;文本或附件身份不同则返回 `409`,不得再次追加用户消息或调用 LLM。 ## 生成结果落画板(对现有占位规则的例外) diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts index 0a98ac9d3..67d2a5d37 100644 --- a/packages/shared/src/contracts/editorAgent.ts +++ b/packages/shared/src/contracts/editorAgent.ts @@ -99,6 +99,8 @@ export interface EditorAgentMessage { // Frontend must not use this to organize messages. // It is an opaque backend locator for pending tool-call operations. id: number; + // Present only on user messages created from a client send request. + clientMessageId?: string | null; role: EditorAgentMessageRole; text: string; attachments: EditorAgentAttachmentRef[]; @@ -141,6 +143,7 @@ export interface EditorAgentConversationResponse { } export interface EditorAgentMessageRequest { + clientMessageId: string; text: string; attachments?: EditorAgentAttachmentRef[]; } 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 1a026cca7..a2c18a0ad 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -74,7 +74,9 @@ use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignToo use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; -use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string}; +use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; + +const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128; pub async fn editor_agent_message( State(state): State, @@ -85,7 +87,8 @@ pub async fn editor_agent_message( ) -> Result, AppError> { let owner_user_id = authenticated.claims().user_id().to_string(); require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - validate_editor_agent_message_request(&payload)?; + let client_message_id = validate_editor_agent_message_request(&payload)?; + let normalized_text = payload.text.trim().to_string(); // Load conversation & attachments let conversation = state .spacetime_client() @@ -107,66 +110,102 @@ pub async fn editor_agent_message( let mut document: EditorAgentConversationMessagesDocument = read_messages_document(&state, &conversation).await?; - // Determine initialization before attachment bookkeeping adds a system message. - let was_empty = document.messages.is_empty(); - let now = now_rfc3339(); - if !attachments.is_empty() { - let mut attachment_info = String::new(); - attachment_info.push_str("user has just uploaded attachments of the order: "); - for a in &attachments { - attachment_info.push_str(&format!("{} ,", a.clone().into_image_id())) - } - document.messages.push(EditorAgentMessage { - id: 0, - role: EditorAgentMessageRole::System, - text: attachment_info, - attachments: Vec::new(), - tool_call: None, - created_at: now.clone(), - }); - } - // Build conversation history as LlmMessage vec - let previous_messages: Vec = document - .messages + let existing_user_index = find_idempotent_editor_agent_user_message( + &document, + client_message_id.as_str(), + normalized_text.as_str(), + attachments.as_slice(), + )?; + + let (user_message, history_end, conversation_summary) = + if let Some(user_index) = existing_user_index { + let delta_messages = document.messages[user_index + 1..] + .iter() + .take_while(|message| message.role != EditorAgentMessageRole::User) + .cloned() + .collect::>(); + if !delta_messages.is_empty() { + return Ok(Json(EditorAgentMessageResponse { + conversation: conversation_summary_from_record(conversation), + delta_messages, + error_message: None, + })); + } + + ( + document.messages[user_index].clone(), + user_index, + conversation_summary_from_record(conversation.clone()), + ) + } else { + // Determine initialization before attachment bookkeeping adds a system message. + let was_empty = document.messages.is_empty(); + let now = now_rfc3339(); + if !attachments.is_empty() { + let mut attachment_info = String::new(); + attachment_info.push_str("user has just uploaded attachments of the order: "); + for attachment in &attachments { + attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id())); + } + document.messages.push(EditorAgentMessage { + id: document.messages.len(), + client_message_id: None, + role: EditorAgentMessageRole::System, + text: attachment_info, + attachments: Vec::new(), + tool_call: None, + created_at: now.clone(), + }); + } + + let history_end = document.messages.len(); + let user_message = EditorAgentMessage { + id: document.messages.len(), + client_message_id: Some(client_message_id), + role: EditorAgentMessageRole::User, + text: normalized_text, + attachments, + tool_call: None, + created_at: now, + }; + document.messages.push(user_message.clone()); + write_messages_document(&state, &conversation, &document).await?; + + // Persist and return the authoritative summary for every turn. Initialization sets the + // title from the first user prompt; a metadata write failure must fail the request. + let updated_conversation = state + .spacetime_client() + .touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput { + conversation_id: conversation.conversation_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + ( + user_message, + history_end, + conversation_summary_from_record(updated_conversation), + ) + }; + + // The current user message is passed separately to prompt(), so memory stops before it. + let previous_messages: Vec = document.messages[..history_end] .iter() - .map(|msg| match msg.role { - EditorAgentMessageRole::User => LlmMessage::user(&msg.text), - EditorAgentMessageRole::Assistant => LlmMessage::assistant(&msg.text), - EditorAgentMessageRole::System => LlmMessage::system(&msg.text), + .map(|message| match message.role { + EditorAgentMessageRole::User => LlmMessage::user(&message.text), + EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), + EditorAgentMessageRole::System => LlmMessage::system(&message.text), }) - // limit to last 18 messages (larger considering attachment, tool calls are injected as system messages) + // Tool calls and attachment bookkeeping are separate system messages. .rev() .take(18) .rev() .collect(); - // Save user message to document. - let user_message = EditorAgentMessage { - id: document.messages.len(), - role: EditorAgentMessageRole::User, - text: payload.text.trim().to_string(), - attachments, - tool_call: None, - created_at: now, - }; - document.messages.push(user_message.clone()); - write_messages_document(&state, &conversation, &document).await?; - - // Persist and return the authoritative summary for every turn. Initialization sets the - // title from the first user prompt; a metadata write failure must fail the request. - let updated_conversation = state - .spacetime_client() - .touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput { - conversation_id: conversation.conversation_id.clone(), - owner_user_id: conversation.owner_user_id.clone(), - title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())), - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; - let conversation_summary = conversation_summary_from_record(updated_conversation); - // Build tool context from document let tool_context = context::build_tool_context(&document); @@ -212,7 +251,9 @@ pub async fn editor_agent_message( .memory(memory) .build(); - let agent_result = agent.prompt(LlmMessage::user(user_message.text)).await; + let agent_result = agent + .prompt(LlmMessage::user(user_message.text.clone())) + .await; let assistant_now = now_rfc3339(); @@ -246,14 +287,63 @@ pub async fn editor_agent_message( fn validate_editor_agent_message_request( payload: &EditorAgentMessageRequest, -) -> Result<(), AppError> { +) -> Result { + let client_message_id = normalize_required_string(payload.client_message_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; + if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS { + return Err(editor_agent_bad_request(format!( + "clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters" + ))); + } let attachment_reference_ids = payload .attachments .iter() .map(|attachment| attachment.reference_id.clone()) .collect::>(); validate_user_message(payload.text.as_str(), attachment_reference_ids.as_slice()) - .map_err(|error| editor_agent_bad_request(error.to_string())) + .map_err(|error| editor_agent_bad_request(error.to_string()))?; + Ok(client_message_id) +} + +fn find_idempotent_editor_agent_user_message( + document: &EditorAgentConversationMessagesDocument, + client_message_id: &str, + text: &str, + attachments: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], +) -> Result, AppError> { + let Some((index, message)) = document + .messages + .iter() + .enumerate() + .find(|(_, message)| message.client_message_id.as_deref() == Some(client_message_id)) + else { + return Ok(None); + }; + + if message.role != EditorAgentMessageRole::User + || message.text != text + || !editor_agent_attachment_requests_match(&message.attachments, attachments) + { + return Err( + AppError::from_status(axum::http::StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-agent", + "field": "clientMessageId", + "message": "clientMessageId already exists with different message content", + })), + ); + } + + Ok(Some(index)) +} + +fn editor_agent_attachment_requests_match( + stored: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], + submitted: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], +) -> bool { + stored.len() == submitted.len() + && stored.iter().zip(submitted).all(|(left, right)| { + left.source == right.source && left.reference_id == right.reference_id + }) } #[cfg(test)] @@ -277,12 +367,14 @@ mod tests { #[test] fn validates_editor_agent_message_before_normalizing_attachments() { let empty_payload = EditorAgentMessageRequest { + client_message_id: "client-message-empty".to_string(), text: " ".to_string(), attachments: Vec::new(), }; assert!(validate_editor_agent_message_request(&empty_payload).is_err()); let too_many_payload = EditorAgentMessageRequest { + client_message_id: "client-message-many".to_string(), text: "生成一张图".to_string(), attachments: (0..10) .map(|index| attachment(format!("res-{index}"))) @@ -291,10 +383,73 @@ mod tests { assert!(validate_editor_agent_message_request(&too_many_payload).is_err()); let attachment_only_payload = EditorAgentMessageRequest { + client_message_id: "client-message-attachment".to_string(), text: String::new(), attachments: vec![attachment("res-1")], }; assert!(validate_editor_agent_message_request(&attachment_only_payload).is_ok()); + + let missing_client_message_id = EditorAgentMessageRequest { + client_message_id: " ".to_string(), + text: "生成一张图".to_string(), + attachments: Vec::new(), + }; + assert!(validate_editor_agent_message_request(&missing_client_message_id).is_err()); + + let oversized_client_message_id = EditorAgentMessageRequest { + client_message_id: "x".repeat(EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS + 1), + text: "生成一张图".to_string(), + attachments: Vec::new(), + }; + assert!(validate_editor_agent_message_request(&oversized_client_message_id).is_err()); + } + + #[test] + fn detects_idempotent_message_replays_and_content_conflicts() { + let stored_attachment = attachment("res-1"); + let document = EditorAgentConversationMessagesDocument { + version: 2, + conversation_id: "conversation-1".to_string(), + messages: vec![EditorAgentMessage { + id: 0, + client_message_id: Some("client-message-1".to_string()), + role: EditorAgentMessageRole::User, + text: "生成一张图".to_string(), + attachments: vec![stored_attachment.clone()], + tool_call: None, + created_at: "2026-07-16T00:00:00Z".to_string(), + }], + }; + + assert_eq!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-1", + "生成一张图", + &[stored_attachment.clone()], + ) + .expect("same request should be an idempotent replay"), + Some(0), + ); + assert!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-1", + "生成另一张图", + &[stored_attachment], + ) + .is_err() + ); + assert_eq!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-2", + "生成一张图", + &[], + ) + .expect("new request should not match"), + None, + ); } } fn editor_agent_system_prompt() -> &'static str { @@ -321,6 +476,7 @@ fn build_delta_messages( PromptOutput::Text(text) => { messages.push(EditorAgentMessage { id: absolute_idx, + client_message_id: None, role: EditorAgentMessageRole::Assistant, text, attachments: Vec::new(), @@ -338,6 +494,7 @@ fn build_delta_messages( )?; messages.push(EditorAgentMessage { id: absolute_idx, + client_message_id: None, role: EditorAgentMessageRole::System, text: tco.message, attachments: Vec::new(), diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs index 3f7e1c41b..8cbd6b33f 100644 --- a/server-rs/crates/shared-contracts/src/editor_agent.rs +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -170,22 +170,37 @@ pub struct EditorAgentToolCall { #[serde(default)] pub error: Option, } -fn deserialize_id_or_zero<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +enum RawEditorAgentMessageId { + Number(usize), + String(String), #[allow(dead_code)] - #[serde(untagged)] - enum IdOrZero { - Num(usize), - Str(String), - Other(serde_json::Value), + Other(serde_json::Value), +} + +impl Default for RawEditorAgentMessageId { + fn default() -> Self { + Self::Number(0) + } +} + +impl RawEditorAgentMessageId { + fn numeric_id(&self) -> usize { + match self { + Self::Number(id) => *id, + Self::String(_) | Self::Other(_) => 0, + } } - match IdOrZero::deserialize(deserializer)? { - IdOrZero::Num(n) => Ok(n), - _ => Ok(0), + fn legacy_client_message_id(&self, role: EditorAgentMessageRole) -> Option { + if role != EditorAgentMessageRole::User { + return None; + } + match self { + Self::String(id) if !id.trim().is_empty() => Some(id.trim().to_string()), + Self::Number(_) | Self::String(_) | Self::Other(_) => None, + } } } @@ -194,6 +209,8 @@ where pub struct EditorAgentMessage { // to compatible for legacy version pub id: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_message_id: Option, pub role: EditorAgentMessageRole, pub text: String, #[serde(default)] @@ -276,8 +293,10 @@ struct RawEditorAgentConversationMessagesDocument { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawEditorAgentMessage { - #[serde(default, deserialize_with = "deserialize_id_or_zero")] - id: usize, + #[serde(default)] + id: RawEditorAgentMessageId, + #[serde(default)] + client_message_id: Option, role: EditorAgentMessageRole, #[serde(default)] text: String, @@ -293,11 +312,16 @@ struct RawEditorAgentMessage { impl RawEditorAgentMessage { fn into_single_message(self) -> EditorAgentMessage { + let id = self.id.numeric_id(); + let client_message_id = self + .client_message_id + .or_else(|| self.id.legacy_client_message_id(self.role)); let tool_call = self .tool_call .or_else(|| legacy_generations_to_tool_call(self.generations)); EditorAgentMessage { - id: self.id, + id, + client_message_id, role: self.role, text: self.text, attachments: self.attachments, @@ -311,13 +335,19 @@ impl RawEditorAgentMessage { return vec![self.into_single_message()]; } + let id = self.id.numeric_id(); + let client_message_id = self + .client_message_id + .clone() + .or_else(|| self.id.legacy_client_message_id(self.role)); let mut messages = Vec::new(); if self.role != EditorAgentMessageRole::Assistant || !self.text.trim().is_empty() || !self.attachments.is_empty() { messages.push(EditorAgentMessage { - id: self.id, + id, + client_message_id, role: self.role, text: self.text.clone(), attachments: self.attachments, @@ -330,7 +360,8 @@ impl RawEditorAgentMessage { let tool_call_message = legacy_generation_to_tool_call_message(generation, self.created_at.as_str()); EditorAgentMessage { - id: self.id, + id, + client_message_id: None, role: EditorAgentMessageRole::System, text: tool_call_message.text, attachments: Vec::new(), @@ -477,6 +508,7 @@ pub struct EditorAgentConversationResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentMessageRequest { + pub client_message_id: String, pub text: String, #[serde(default)] pub attachments: Vec, @@ -495,6 +527,37 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn message_request_requires_client_message_id_and_user_message_preserves_it() { + let missing_client_message_id = + serde_json::from_value::(json!({ + "text": "生成一张图", + "attachments": [] + })); + assert!(missing_client_message_id.is_err()); + + let request = serde_json::from_value::(json!({ + "clientMessageId": "client-message-1", + "text": "生成一张图", + "attachments": [] + })) + .expect("clientMessageId should deserialize"); + assert_eq!(request.client_message_id, "client-message-1"); + + let message = EditorAgentMessage { + id: 0, + client_message_id: Some(request.client_message_id), + role: EditorAgentMessageRole::User, + text: request.text, + attachments: request.attachments, + tool_call: None, + created_at: "2026-07-16T00:00:00Z".to_string(), + }; + let payload = serde_json::to_value(message).expect("message should serialize"); + assert_eq!(payload["id"], 0); + assert_eq!(payload["clientMessageId"], "client-message-1"); + } + #[test] fn tool_call_display_args_and_media_use_camel_case() { let missing_display_args = serde_json::from_value::(json!({ @@ -642,7 +705,12 @@ mod tests { assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); assert_eq!(document.messages.len(), 3); assert_eq!(document.messages[0].id, 0); + assert_eq!( + document.messages[0].client_message_id.as_deref(), + Some("message-user-1") + ); assert_eq!(document.messages[1].id, 1); + assert!(document.messages[1].client_message_id.is_none()); assert_eq!(document.messages[1].role, EditorAgentMessageRole::Assistant); assert_eq!(document.messages[1].text, "已为你生成森林背景。"); assert!(document.messages[1].tool_call.is_none()); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index feef7df8f..bb35524e2 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -139,6 +139,7 @@ describe('useEditorAgentConversation', () => { expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ + clientMessageId: expect.stringMatching(/^editor-agent-/u), text: '把这个角色改成像素风', attachments: [], }), @@ -155,6 +156,9 @@ describe('useEditorAgentConversation', () => { '把这个角色改成像素风', '我来处理', ]); + expect(result.current.messages[0]?.clientMessageId).toMatch( + /^editor-agent-/u, + ); expect(result.current.messages[1]?.toolCall).toEqual( expect.objectContaining({ toolName: 'generate_image', diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 61daf293f..ed18503ad 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -83,13 +83,23 @@ function isAbortError(error: unknown) { ); } +function createEditorAgentClientMessageId() { + const randomId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `editor-agent-${randomId}`; +} + function createLocalUserMessage(params: { id: number; + clientMessageId: string; text: string; attachments: EditorAgentAttachmentRef[]; }): EditorAgentMessage { return { id: params.id, + clientMessageId: params.clientMessageId, role: 'user', text: params.text, attachments: params.attachments, @@ -359,12 +369,14 @@ export function useEditorAgentConversation({ return; } const conversationId = await ensureConversationForSend(); + const clientMessageId = createEditorAgentClientMessageId(); const abortController = new AbortController(); activeRequestAbortControllerRef.current = abortController; setErrorMessage(null); setIsWaiting(true); const optimisticMessage = createLocalUserMessage({ id: -1, + clientMessageId, text, attachments, }); @@ -377,6 +389,7 @@ export function useEditorAgentConversation({ const response = await client.sendMessage( conversationId, { + clientMessageId, text, attachments, }, diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 79eecfac1..2065b8a76 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -599,6 +599,60 @@ describe('apiClient', () => { expect(result).toEqual({ value: 42 }); }); + it('reuses the exact post body and request id for an enabled unsafe transport retry', async () => { + setStoredAccessToken('editor-agent-token', { emit: false }); + fetchMock + .mockRejectedValueOnce(new TypeError('network unavailable')) + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { deltaMessages: [] }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + headers: { 'Content-Type': 'application/json' }, + }), + ); + const body = JSON.stringify({ + clientMessageId: 'client-message-1', + text: '生成一张图', + attachments: [], + }); + + await requestJson( + '/api/editor/agent-conversations/conversation-1/messages', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }, + '发送画布 Agent 消息失败', + { + authImpact: 'local', + retry: { + maxRetries: 1, + baseDelayMs: 1, + maxDelayMs: 1, + retryUnsafeMethods: true, + }, + }, + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(firstInit.body).toBe(body); + expect(secondInit.body).toBe(body); + expect((firstInit.headers as Record)['x-request-id']).toBe( + 'web-11111111-2222-3333-4444-555555555555', + ); + expect((secondInit.headers as Record)['x-request-id']).toBe( + (firstInit.headers as Record)['x-request-id'], + ); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( diff --git a/src/services/image-editor/editorAgentClient.test.ts b/src/services/image-editor/editorAgentClient.test.ts index 77971e885..98b6f785a 100644 --- a/src/services/image-editor/editorAgentClient.test.ts +++ b/src/services/image-editor/editorAgentClient.test.ts @@ -123,6 +123,7 @@ describe('editorAgentClient', () => { requestJsonMock.mockResolvedValueOnce(responseBody); const result = await sendEditorAgentMessage('conversation-1', { + clientMessageId: 'client-message-1', text: '帮我把角色改成像素风', attachments: [], }); @@ -134,6 +135,7 @@ describe('editorAgentClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + clientMessageId: 'client-message-1', text: '帮我把角色改成像素风', attachments: [], }), @@ -142,6 +144,12 @@ describe('editorAgentClient', () => { expect.objectContaining({ timeoutMs: 1_200_000, authImpact: 'local', + retry: { + maxRetries: 1, + baseDelayMs: 250, + maxDelayMs: 250, + retryUnsafeMethods: true, + }, }), ); }); diff --git a/src/services/image-editor/editorAgentClient.ts b/src/services/image-editor/editorAgentClient.ts index 938fcbc07..d5a6113fe 100644 --- a/src/services/image-editor/editorAgentClient.ts +++ b/src/services/image-editor/editorAgentClient.ts @@ -12,6 +12,12 @@ import { requestJson } from '../apiClient'; const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects'; const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations'; const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000; +const EDITOR_AGENT_MESSAGE_RETRY = { + maxRetries: 1, + baseDelayMs: 250, + maxDelayMs: 250, + retryUnsafeMethods: true, +} as const; export type SendEditorAgentMessageOptions = { signal?: AbortSignal; @@ -116,6 +122,7 @@ export async function sendEditorAgentMessage( { timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS, authImpact: 'local', + retry: EDITOR_AGENT_MESSAGE_RETRY, }, ); }