impl client message id and client retry
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 生成结果落画板(对现有占位规则的例外)
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
@@ -85,7 +87,8 @@ pub async fn editor_agent_message(
|
||||
) -> Result<Json<EditorAgentMessageResponse>, 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<LlmMessage> = 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::<Vec<_>>();
|
||||
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<LlmMessage> = 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<String, AppError> {
|
||||
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::<Vec<_>>();
|
||||
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<Option<usize>, 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(),
|
||||
|
||||
@@ -170,22 +170,37 @@ pub struct EditorAgentToolCall {
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
fn deserialize_id_or_zero<'de, D>(deserializer: D) -> Result<usize, D::Error>
|
||||
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<String> {
|
||||
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<String>,
|
||||
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<String>,
|
||||
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<EditorAgentAttachmentRef>,
|
||||
@@ -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::<EditorAgentMessageRequest>(json!({
|
||||
"text": "生成一张图",
|
||||
"attachments": []
|
||||
}));
|
||||
assert!(missing_client_message_id.is_err());
|
||||
|
||||
let request = serde_json::from_value::<EditorAgentMessageRequest>(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::<EditorAgentToolCall>(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());
|
||||
|
||||
+4
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, string>)['x-request-id']).toBe(
|
||||
'web-11111111-2222-3333-4444-555555555555',
|
||||
);
|
||||
expect((secondInit.headers as Record<string, string>)['x-request-id']).toBe(
|
||||
(firstInit.headers as Record<string, string>)['x-request-id'],
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts requests when timeoutMs is reached', async () => {
|
||||
setStoredAccessToken('timeout-token', { emit: false });
|
||||
fetchMock.mockImplementation(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user