legacy doc migration
This commit is contained in:
@@ -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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user