367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
//! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。
|
||
//! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。
|
||
|
||
use serde::{Deserialize, Deserializer, Serialize};
|
||
|
||
pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9;
|
||
pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20;
|
||
pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话";
|
||
pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 2;
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum EditorAgentMessageRole {
|
||
User,
|
||
Assistant,
|
||
System,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentAttachmentSource {
|
||
CanvasResource,
|
||
LibraryAsset,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentToolCallStatus {
|
||
#[serde(alias = "pending_confirmation", alias = "executing")]
|
||
NotCompleted,
|
||
Completed,
|
||
Failed,
|
||
Cancelled,
|
||
}
|
||
|
||
fn default_editor_agent_tool_call_status() -> EditorAgentToolCallStatus {
|
||
EditorAgentToolCallStatus::NotCompleted
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentAttachmentRef {
|
||
pub source: EditorAgentAttachmentSource,
|
||
pub reference_id: String,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub label: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedImage {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedVideo {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub video_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedAudio {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub audio_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallDisplayArgs {
|
||
pub string_args: Vec<EditorAgentToolCallStringArg>,
|
||
pub image_args: Vec<EditorAgentToolCallImageArg>,
|
||
pub extras: EditorAgentToolCallDisplayExtras,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallDisplayExtras {
|
||
pub price_mud_points: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallStringArg {
|
||
pub name: String,
|
||
pub label: String,
|
||
pub value: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallImageArg {
|
||
pub name: String,
|
||
pub label: String,
|
||
pub refs: Vec<EditorAgentToolCallImageRef>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCallImageRef {
|
||
pub image_id: String,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub label: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolCall {
|
||
pub tool_name: String,
|
||
#[serde(default = "default_editor_agent_tool_call_status")]
|
||
pub status: EditorAgentToolCallStatus,
|
||
pub args: serde_json::Value,
|
||
pub display_args: EditorAgentToolCallDisplayArgs,
|
||
#[serde(default)]
|
||
pub external_job_id: Option<String>,
|
||
#[serde(default)]
|
||
pub images: Vec<EditorAgentGeneratedImage>,
|
||
#[serde(default)]
|
||
pub videos: Vec<EditorAgentGeneratedVideo>,
|
||
#[serde(default)]
|
||
pub audios: Vec<EditorAgentGeneratedAudio>,
|
||
#[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)]
|
||
#[serde(untagged)]
|
||
enum IdOrZero {
|
||
Num(usize),
|
||
Str(String),
|
||
Other(serde_json::Value),
|
||
}
|
||
|
||
match IdOrZero::deserialize(deserializer)? {
|
||
IdOrZero::Num(n) => Ok(n),
|
||
_ => Ok(0),
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, 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,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
// one tool call is inside one system message.
|
||
pub tool_call: Option<EditorAgentToolCall>,
|
||
pub created_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationSummary {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub updated_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationDetail {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub created_at: String,
|
||
pub updated_at: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationMessagesDocument {
|
||
pub version: u32,
|
||
pub conversation_id: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CreateEditorAgentConversationRequest {
|
||
#[serde(default)]
|
||
pub title: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationListResponse {
|
||
pub conversations: Vec<EditorAgentConversationSummary>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationResponse {
|
||
pub conversation: EditorAgentConversationDetail,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessageRequest {
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessageResponse {
|
||
pub conversation: EditorAgentConversationSummary,
|
||
pub delta_messages: Vec<EditorAgentMessage>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn tool_call_display_args_and_media_use_camel_case() {
|
||
let missing_display_args = serde_json::from_value::<EditorAgentToolCall>(json!({
|
||
"toolName": "generate-video",
|
||
"args": {},
|
||
"images": []
|
||
}));
|
||
assert!(missing_display_args.is_err());
|
||
|
||
let tool_call = EditorAgentToolCall {
|
||
tool_name: "generate-video".to_string(),
|
||
status: EditorAgentToolCallStatus::Completed,
|
||
args: json!({}),
|
||
display_args: EditorAgentToolCallDisplayArgs {
|
||
string_args: vec![EditorAgentToolCallStringArg {
|
||
name: "prompt".to_string(),
|
||
label: "提示词".to_string(),
|
||
value: "生成像素风角色".to_string(),
|
||
}],
|
||
image_args: vec![EditorAgentToolCallImageArg {
|
||
name: "reference_image_ids".to_string(),
|
||
label: "参考图片".to_string(),
|
||
refs: vec![EditorAgentToolCallImageRef {
|
||
image_id: "sha256:reference-1".to_string(),
|
||
image_src: "/reference.png".to_string(),
|
||
object_key: Some("generated/reference.png".to_string()),
|
||
thumbnail_src: Some("/reference-thumbnail.png".to_string()),
|
||
label: Some("角色参考".to_string()),
|
||
width: Some(512),
|
||
height: Some(512),
|
||
}],
|
||
}],
|
||
extras: EditorAgentToolCallDisplayExtras {
|
||
price_mud_points: 5,
|
||
},
|
||
},
|
||
external_job_id: Some("task-editor-agent-1".to_string()),
|
||
images: Vec::new(),
|
||
videos: vec![EditorAgentGeneratedVideo {
|
||
resource_id: Some("resource-video-1".to_string()),
|
||
object_key: Some("generated/video.mp4".to_string()),
|
||
asset_object_id: Some("asset-video-1".to_string()),
|
||
video_src: "/generated-video.mp4".to_string(),
|
||
thumbnail_src: Some("/generated-video.png".to_string()),
|
||
width: Some(1280),
|
||
height: Some(720),
|
||
}],
|
||
audios: vec![EditorAgentGeneratedAudio {
|
||
resource_id: Some("resource-audio-1".to_string()),
|
||
object_key: Some("generated/audio.mp3".to_string()),
|
||
asset_object_id: Some("asset-audio-1".to_string()),
|
||
audio_src: "/generated-audio.mp3".to_string(),
|
||
}],
|
||
error: None,
|
||
};
|
||
let payload = serde_json::to_value(tool_call).expect("tool call should serialize");
|
||
assert_eq!(payload["videos"][0]["videoSrc"], "/generated-video.mp4");
|
||
assert_eq!(payload["externalJobId"], "task-editor-agent-1");
|
||
assert_eq!(payload["status"], "completed");
|
||
assert_eq!(payload["audios"][0]["audioSrc"], "/generated-audio.mp3");
|
||
assert_eq!(
|
||
payload["displayArgs"]["stringArgs"][0]["value"],
|
||
"生成像素风角色"
|
||
);
|
||
assert_eq!(
|
||
payload["displayArgs"]["imageArgs"][0]["refs"][0]["imageId"],
|
||
"sha256:reference-1"
|
||
);
|
||
assert_eq!(
|
||
payload["displayArgs"]["imageArgs"][0]["refs"][0]["objectKey"],
|
||
"generated/reference.png"
|
||
);
|
||
assert_eq!(payload["displayArgs"]["extras"]["priceMudPoints"], 5);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_tool_call_status_maps_to_not_completed_on_read() {
|
||
let tool_call = serde_json::from_value::<EditorAgentToolCall>(json!({
|
||
"toolName": "generate-image",
|
||
"status": "executing",
|
||
"args": {},
|
||
"displayArgs": {
|
||
"stringArgs": [],
|
||
"imageArgs": [],
|
||
"extras": { "priceMudPoints": 3 }
|
||
},
|
||
"images": []
|
||
}))
|
||
.expect("legacy status should not break persisted OSS documents");
|
||
|
||
let payload = serde_json::to_value(tool_call).expect("tool call should serialize");
|
||
assert_eq!(payload["status"], "not_completed");
|
||
}
|
||
}
|