1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20#[derive(
24 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString,
25)]
26#[strum(serialize_all = "lowercase")]
27#[serde(rename_all = "lowercase")]
28pub enum Role {
29 System,
31 User,
33 Assistant,
35 Developer,
37 Tool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ToolCall {
44 pub id: String,
46 pub name: String,
48 pub arguments: serde_json::Value,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ToolResult {
64 pub tool_call_id: String,
67 pub output: String,
70 pub success: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(tag = "type")]
80pub enum ContentPart {
81 #[serde(rename = "text")]
82 Text { text: String },
83 #[serde(rename = "image")]
84 Image {
85 media_type: String,
86 data: String,
88 },
89 #[serde(rename = "image_url")]
90 ImageUrl { url: String },
91 #[serde(rename = "document")]
92 Document {
93 media_type: String,
94 filename: String,
95 data: String,
96 },
97 #[serde(rename = "video")]
98 Video {
99 media_type: String,
100 filename: String,
101 data: String,
102 },
103 #[serde(rename = "video_url")]
104 VideoUrl { url: String },
105 #[serde(rename = "audio")]
106 Audio {
107 media_type: String,
108 filename: String,
109 data: String,
110 },
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Message {
118 pub role: Role,
120 pub content: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub tool_calls: Option<Vec<ToolCall>>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub tool_call_id: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub parts: Option<Vec<ContentPart>>,
131 #[serde(default = "chrono::Utc::now")]
133 pub timestamp: DateTime<Utc>,
134}
135
136impl Message {
137 fn new(role: Role, content: String) -> Self {
138 Self {
139 role,
140 content,
141 tool_calls: None,
142 tool_call_id: None,
143 parts: None,
144 timestamp: Utc::now(),
145 }
146 }
147
148 pub fn user(content: impl Into<String>) -> Self {
150 Self::new(Role::User, content.into())
151 }
152
153 pub fn assistant(content: impl Into<String>) -> Self {
155 Self::new(Role::Assistant, content.into())
156 }
157
158 pub fn assistant_with_tool_calls(
160 content: impl Into<String>,
161 tool_calls: Vec<ToolCall>,
162 ) -> Self {
163 Self {
164 tool_calls: Some(tool_calls),
165 ..Self::new(Role::Assistant, content.into())
166 }
167 }
168
169 pub fn system(content: impl Into<String>) -> Self {
171 Self::new(Role::System, content.into())
172 }
173
174 pub fn developer(content: impl Into<String>) -> Self {
176 Self::new(Role::Developer, content.into())
177 }
178
179 pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
181 Self {
182 tool_call_id: Some(tool_call_id.into()),
183 ..Self::new(Role::Tool, content.into())
184 }
185 }
186
187 pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
189 Self {
190 parts: if parts.is_empty() { None } else { Some(parts) },
191 ..Self::new(Role::User, content.into())
192 }
193 }
194
195 pub fn strip_binary_data(&self) -> Self {
202 let parts = self.parts.as_ref().map(|parts| {
203 parts
204 .iter()
205 .map(|p| match p {
206 ContentPart::Image { media_type, .. } => ContentPart::Text {
207 text: format!("[Image: {}]", media_type),
208 },
209 ContentPart::ImageUrl { url } => ContentPart::Text {
210 text: format!("[Image: {}]", url),
211 },
212 ContentPart::Document {
213 media_type,
214 filename,
215 ..
216 } => ContentPart::Text {
217 text: format!("[Document: {} ({})]", filename, media_type),
218 },
219 ContentPart::Video {
220 media_type,
221 filename,
222 ..
223 } => ContentPart::Text {
224 text: format!("[Video: {} ({})]", filename, media_type),
225 },
226 ContentPart::VideoUrl { url } => ContentPart::Text {
227 text: format!("[Video: {}]", url),
228 },
229 ContentPart::Audio {
230 media_type,
231 filename,
232 ..
233 } => ContentPart::Text {
234 text: format!("[Audio: {} ({})]", filename, media_type),
235 },
236 other => other.clone(),
237 })
238 .collect()
239 });
240
241 Self {
242 parts,
243 ..self.clone()
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_message_serialization() {
254 let msg = Message::user("Hello");
255 let json = serde_json::to_string(&msg).unwrap();
256 assert!(json.contains("\"role\":\"user\""));
257 assert!(json.contains("Hello"));
258 }
259
260 #[test]
261 fn test_message_constructors() {
262 let user = Message::user("hi");
263 assert_eq!(user.role, Role::User);
264 assert_eq!(user.content, "hi");
265
266 let assistant = Message::assistant("hello");
267 assert_eq!(assistant.role, Role::Assistant);
268
269 let system = Message::system("you are helpful");
270 assert_eq!(system.role, Role::System);
271
272 let developer = Message::developer("injected context");
273 assert_eq!(developer.role, Role::Developer);
274 assert_eq!(developer.content, "injected context");
275
276 let tool = Message::tool_result("call-1", "result data");
277 assert_eq!(tool.role, Role::Tool);
278 assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
279 }
280
281 #[test]
282 fn test_content_part_serde() {
283 let text = ContentPart::Text {
284 text: "hello".into(),
285 };
286 let json = serde_json::to_string(&text).unwrap();
287 assert!(json.contains("\"type\":\"text\""));
288 let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
289 match roundtrip {
290 ContentPart::Text { text } => assert_eq!(text, "hello"),
291 _ => panic!("expected text"),
292 }
293
294 let img = ContentPart::Image {
295 media_type: "image/jpeg".into(),
296 data: "abc123".into(),
297 };
298 let json = serde_json::to_string(&img).unwrap();
299 assert!(json.contains("\"type\":\"image\""));
300 }
301
302 #[test]
303 fn test_strip_binary_data() {
304 let msg = Message::user_with_parts(
305 "photo",
306 vec![
307 ContentPart::Text {
308 text: "a cat".into(),
309 },
310 ContentPart::Image {
311 media_type: "image/jpeg".into(),
312 data: "bigdata".into(),
313 },
314 ],
315 );
316 let stripped = msg.strip_binary_data();
317 let parts = stripped.parts.unwrap();
318 assert_eq!(parts.len(), 2);
319 match &parts[1] {
320 ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
321 _ => panic!("expected text placeholder for image"),
322 }
323 }
324
325 #[test]
326 fn test_role_serde() {
327 let role = Role::Assistant;
328 let json = serde_json::to_string(&role).unwrap();
329 assert_eq!(json, "\"assistant\"");
330
331 let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
332 assert_eq!(parsed, Role::Tool);
333 }
334}