legacy doc migration

This commit is contained in:
2026-07-16 10:56:54 +08:00
parent 2f344ed107
commit 701b2338c7
@@ -2,6 +2,7 @@
//! 会话元数据存 SpacetimeDB,消息正文整体存 OSSeditor-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<D>(deserializer: D) -> Result<Self, D::Error>
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<EditorAgentMessage>,
}
#[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<EditorAgentMessage>,
}
// for legacy migration start
impl<'de> Deserialize<'de> for EditorAgentConversationMessagesDocument {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<RawEditorAgentMessage>,
}
#[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<EditorAgentAttachmentRef>,
#[serde(default)]
tool_call: Option<EditorAgentToolCall>,
#[serde(default)]
generations: Vec<LegacyEditorAgentGenerationRecord>,
#[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<EditorAgentMessage> {
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<String>,
#[serde(default)]
task_id: Option<String>,
status: LegacyEditorAgentGenerationStatus,
#[serde(default)]
model: Option<String>,
#[serde(default)]
images: Vec<EditorAgentGeneratedImage>,
#[serde(default)]
error: Option<String>,
}
#[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<LegacyEditorAgentGenerationRecord>,
) -> Option<EditorAgentToolCall> {
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::<EditorAgentConversationMessagesDocument>(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"
);
}
}