use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString,
)]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Developer,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: String,
pub output: String,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image {
media_type: String,
data: String,
},
#[serde(rename = "image_url")]
ImageUrl { url: String },
#[serde(rename = "document")]
Document {
media_type: String,
filename: String,
data: String,
},
#[serde(rename = "video")]
Video {
media_type: String,
filename: String,
data: String,
},
#[serde(rename = "video_url")]
VideoUrl { url: String },
#[serde(rename = "audio")]
Audio {
media_type: String,
filename: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parts: Option<Vec<ContentPart>>,
#[serde(default = "chrono::Utc::now")]
pub timestamp: DateTime<Utc>,
}
impl Message {
fn new(role: Role, content: String) -> Self {
Self {
role,
content,
tool_calls: None,
tool_call_id: None,
parts: None,
timestamp: Utc::now(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(Role::User, content.into())
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::new(Role::Assistant, content.into())
}
pub fn assistant_with_tool_calls(
content: impl Into<String>,
tool_calls: Vec<ToolCall>,
) -> Self {
Self {
tool_calls: Some(tool_calls),
..Self::new(Role::Assistant, content.into())
}
}
pub fn system(content: impl Into<String>) -> Self {
Self::new(Role::System, content.into())
}
pub fn developer(content: impl Into<String>) -> Self {
Self::new(Role::Developer, content.into())
}
pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
tool_call_id: Some(tool_call_id.into()),
..Self::new(Role::Tool, content.into())
}
}
pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self {
parts: if parts.is_empty() { None } else { Some(parts) },
..Self::new(Role::User, content.into())
}
}
pub fn strip_binary_data(&self) -> Self {
let parts = self.parts.as_ref().map(|parts| {
parts
.iter()
.map(|p| match p {
ContentPart::Image { media_type, .. } => ContentPart::Text {
text: format!("[Image: {}]", media_type),
},
ContentPart::ImageUrl { url } => ContentPart::Text {
text: format!("[Image: {}]", url),
},
ContentPart::Document {
media_type,
filename,
..
} => ContentPart::Text {
text: format!("[Document: {} ({})]", filename, media_type),
},
ContentPart::Video {
media_type,
filename,
..
} => ContentPart::Text {
text: format!("[Video: {} ({})]", filename, media_type),
},
ContentPart::VideoUrl { url } => ContentPart::Text {
text: format!("[Video: {}]", url),
},
ContentPart::Audio {
media_type,
filename,
..
} => ContentPart::Text {
text: format!("[Audio: {} ({})]", filename, media_type),
},
other => other.clone(),
})
.collect()
});
Self {
parts,
..self.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_serialization() {
let msg = Message::user("Hello");
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("Hello"));
}
#[test]
fn test_message_constructors() {
let user = Message::user("hi");
assert_eq!(user.role, Role::User);
assert_eq!(user.content, "hi");
let assistant = Message::assistant("hello");
assert_eq!(assistant.role, Role::Assistant);
let system = Message::system("you are helpful");
assert_eq!(system.role, Role::System);
let developer = Message::developer("injected context");
assert_eq!(developer.role, Role::Developer);
assert_eq!(developer.content, "injected context");
let tool = Message::tool_result("call-1", "result data");
assert_eq!(tool.role, Role::Tool);
assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
}
#[test]
fn test_content_part_serde() {
let text = ContentPart::Text {
text: "hello".into(),
};
let json = serde_json::to_string(&text).unwrap();
assert!(json.contains("\"type\":\"text\""));
let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
match roundtrip {
ContentPart::Text { text } => assert_eq!(text, "hello"),
_ => panic!("expected text"),
}
let img = ContentPart::Image {
media_type: "image/jpeg".into(),
data: "abc123".into(),
};
let json = serde_json::to_string(&img).unwrap();
assert!(json.contains("\"type\":\"image\""));
}
#[test]
fn test_strip_binary_data() {
let msg = Message::user_with_parts(
"photo",
vec![
ContentPart::Text {
text: "a cat".into(),
},
ContentPart::Image {
media_type: "image/jpeg".into(),
data: "bigdata".into(),
},
],
);
let stripped = msg.strip_binary_data();
let parts = stripped.parts.unwrap();
assert_eq!(parts.len(), 2);
match &parts[1] {
ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
_ => panic!("expected text placeholder for image"),
}
}
#[test]
fn test_role_serde() {
let role = Role::Assistant;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, "\"assistant\"");
let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
assert_eq!(parsed, Role::Tool);
}
}