diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs index 90697f92a..3f7e1c41b 100644 --- a/server-rs/crates/shared-contracts/src/editor_agent.rs +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -2,6 +2,7 @@ //! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::json; pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; @@ -174,6 +175,7 @@ where D: Deserializer<'de>, { #[derive(Deserialize)] + #[allow(dead_code)] #[serde(untagged)] enum IdOrZero { Num(usize), @@ -187,11 +189,10 @@ where } } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentMessage { // to compatible for legacy version - #[serde(deserialize_with = "deserialize_id_or_zero")] pub id: usize, pub role: EditorAgentMessageRole, pub text: String, @@ -202,6 +203,15 @@ pub struct EditorAgentMessage { pub created_at: String, } +impl<'de> Deserialize<'de> for EditorAgentMessage { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(RawEditorAgentMessage::deserialize(deserializer)?.into_single_message()) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentConversationSummary { @@ -222,7 +232,7 @@ pub struct EditorAgentConversationDetail { pub messages: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentConversationMessagesDocument { pub version: u32, @@ -230,6 +240,221 @@ pub struct EditorAgentConversationMessagesDocument { pub messages: Vec, } +// for legacy migration start +impl<'de> Deserialize<'de> for EditorAgentConversationMessagesDocument { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawEditorAgentConversationMessagesDocument::deserialize(deserializer)?; + let mut messages = Vec::new(); + for raw_message in raw.messages { + for mut message in raw_message.into_document_messages() { + message.id = messages.len(); + messages.push(message); + } + } + Ok(Self { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: raw.conversation_id, + messages, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEditorAgentConversationMessagesDocument { + #[allow(dead_code)] + #[serde(default)] + version: u32, + conversation_id: String, + #[serde(default)] + messages: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEditorAgentMessage { + #[serde(default, deserialize_with = "deserialize_id_or_zero")] + id: usize, + role: EditorAgentMessageRole, + #[serde(default)] + text: String, + #[serde(default)] + attachments: Vec, + #[serde(default)] + tool_call: Option, + #[serde(default)] + generations: Vec, + #[serde(default)] + created_at: String, +} + +impl RawEditorAgentMessage { + fn into_single_message(self) -> EditorAgentMessage { + let tool_call = self + .tool_call + .or_else(|| legacy_generations_to_tool_call(self.generations)); + EditorAgentMessage { + id: self.id, + role: self.role, + text: self.text, + attachments: self.attachments, + tool_call, + created_at: self.created_at, + } + } + + fn into_document_messages(self) -> Vec { + if self.tool_call.is_some() || self.generations.is_empty() { + return vec![self.into_single_message()]; + } + + 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, + role: self.role, + text: self.text.clone(), + attachments: self.attachments, + tool_call: None, + created_at: self.created_at.clone(), + }); + } + + messages.extend(self.generations.into_iter().map(|generation| { + let tool_call_message = + legacy_generation_to_tool_call_message(generation, self.created_at.as_str()); + EditorAgentMessage { + id: self.id, + role: EditorAgentMessageRole::System, + text: tool_call_message.text, + attachments: Vec::new(), + tool_call: Some(tool_call_message.tool_call), + created_at: self.created_at.clone(), + } + })); + messages + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyEditorAgentGenerationRecord { + #[serde(default)] + tool_call_id: String, + tool_name: String, + #[serde(default)] + summary: Option, + #[serde(default)] + task_id: Option, + status: LegacyEditorAgentGenerationStatus, + #[serde(default)] + model: Option, + #[serde(default)] + images: Vec, + #[serde(default)] + error: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LegacyEditorAgentGenerationStatus { + Generating, + Completed, + Failed, +} + +struct LegacyToolCallMessage { + text: String, + tool_call: EditorAgentToolCall, +} + +fn legacy_generations_to_tool_call( + generations: Vec, +) -> Option { + let mut generations = generations.into_iter(); + let first = generations.next()?; + let mut tool_call_message = legacy_generation_to_tool_call_message(first, ""); + for generation in generations { + tool_call_message.tool_call.images.extend(generation.images); + if tool_call_message.tool_call.error.is_none() { + tool_call_message.tool_call.error = generation.error; + } + if tool_call_message.tool_call.status == EditorAgentToolCallStatus::Completed + && generation.status != LegacyEditorAgentGenerationStatus::Completed + { + tool_call_message.tool_call.status = legacy_generation_status(generation.status); + } + } + Some(tool_call_message.tool_call) +} + +fn legacy_generation_to_tool_call_message( + generation: LegacyEditorAgentGenerationRecord, + created_at: &str, +) -> LegacyToolCallMessage { + let tool_name = normalize_legacy_tool_name(generation.tool_name.as_str()); + let status = legacy_generation_status(generation.status); + let summary = generation.summary.clone(); + let error = generation.error.clone(); + let text = legacy_tool_call_text(tool_name.as_str(), summary.as_deref(), error.as_deref()); + LegacyToolCallMessage { + text, + tool_call: EditorAgentToolCall { + tool_name, + status, + args: json!({ + "legacyToolCallId": generation.tool_call_id, + "legacySummary": summary, + "legacyModel": generation.model, + "legacyCreatedAt": created_at, + }), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: generation.task_id, + images: generation.images, + videos: Vec::new(), + audios: Vec::new(), + error, + }, + } +} + +fn legacy_generation_status( + status: LegacyEditorAgentGenerationStatus, +) -> EditorAgentToolCallStatus { + match status { + LegacyEditorAgentGenerationStatus::Generating => EditorAgentToolCallStatus::NotCompleted, + LegacyEditorAgentGenerationStatus::Completed => EditorAgentToolCallStatus::Completed, + LegacyEditorAgentGenerationStatus::Failed => EditorAgentToolCallStatus::Failed, + } +} + +fn normalize_legacy_tool_name(tool_name: &str) -> String { + let normalized = tool_name.trim().replace('_', "-"); + if normalized.is_empty() { + "generate-image".to_string() + } else { + normalized + } +} + +fn legacy_tool_call_text(tool_name: &str, summary: Option<&str>, error: Option<&str>) -> String { + if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) { + return format!("[tool_call:{tool_name}] output: {error}"); + } + let output = summary + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("legacy generation restored"); + format!("[tool_call:{tool_name}] output: {output}") +} +// for legacy migration end + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateEditorAgentConversationRequest { @@ -363,4 +588,92 @@ mod tests { let payload = serde_json::to_value(tool_call).expect("tool call should serialize"); assert_eq!(payload["status"], "not_completed"); } + + #[test] + fn legacy_message_generations_migrate_to_tool_call_media() { + let document = serde_json::from_value::(json!({ + "version": 1, + "conversationId": "editor-agent-conv-legacy", + "messages": [ + { + "id": "message-user-1", + "role": "user", + "kind": "chat", + "text": "帮我生成一张森林背景", + "attachments": [], + "generations": [], + "status": "completed", + "createdAt": "2026-07-03T00:00:00Z" + }, + { + "id": "message-assistant-1", + "role": "assistant", + "kind": "chat", + "text": "已为你生成森林背景。", + "attachments": [], + "generations": [ + { + "toolCallId": "tool-call-legacy-1", + "toolName": "generate_image", + "summary": "森林背景", + "taskId": "task-legacy-1", + "status": "completed", + "model": "z-image", + "images": [ + { + "resourceId": "resource-legacy-1", + "objectKey": "generated/legacy-forest.png", + "assetObjectId": "asset-legacy-1", + "imageSrc": "https://example.test/legacy-forest.png", + "thumbnailSrc": "https://example.test/legacy-forest-thumb.png", + "width": 1024, + "height": 1024 + } + ] + } + ], + "status": "completed", + "createdAt": "2026-07-03T00:00:01Z" + } + ] + })) + .expect("legacy document should deserialize"); + + 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[1].id, 1); + assert_eq!(document.messages[1].role, EditorAgentMessageRole::Assistant); + assert_eq!(document.messages[1].text, "已为你生成森林背景。"); + assert!(document.messages[1].tool_call.is_none()); + assert_eq!(document.messages[2].id, 2); + assert_eq!(document.messages[2].role, EditorAgentMessageRole::System); + + let tool_call = document.messages[2] + .tool_call + .as_ref() + .expect("legacy generation should become a tool call"); + assert_eq!(tool_call.tool_name, "generate-image"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed); + assert_eq!(tool_call.external_job_id.as_deref(), Some("task-legacy-1")); + assert_eq!(tool_call.images.len(), 1); + assert_eq!( + tool_call.images[0].object_key.as_deref(), + Some("generated/legacy-forest.png") + ); + assert_eq!( + tool_call.images[0].image_src, + "https://example.test/legacy-forest.png" + ); + + let payload = serde_json::to_value(&document).expect("document should serialize"); + assert_eq!(payload["version"], EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); + assert!(payload["messages"][1].get("generations").is_none()); + assert!(payload["messages"][1].get("kind").is_none()); + assert!(payload["messages"][2].get("generations").is_none()); + assert_eq!( + payload["messages"][2]["toolCall"]["images"][0]["objectKey"], + "generated/legacy-forest.png" + ); + } }