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(default, skip_serializing_if = "Option::is_none")]
124 pub reasoning: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub tool_calls: Option<Vec<ToolCall>>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub tool_call_id: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub parts: Option<Vec<ContentPart>>,
134 #[serde(default = "chrono::Utc::now")]
136 pub timestamp: DateTime<Utc>,
137}
138
139impl Message {
140 fn new(role: Role, content: String) -> Self {
141 Self {
142 role,
143 content,
144 reasoning: None,
145 tool_calls: None,
146 tool_call_id: None,
147 parts: None,
148 timestamp: Utc::now(),
149 }
150 }
151
152 pub fn user(content: impl Into<String>) -> Self {
154 Self::new(Role::User, content.into())
155 }
156
157 pub fn assistant(content: impl Into<String>) -> Self {
159 Self::new(Role::Assistant, content.into())
160 }
161
162 pub fn assistant_with_tool_calls(
164 content: impl Into<String>,
165 tool_calls: Vec<ToolCall>,
166 ) -> Self {
167 Self {
168 tool_calls: Some(tool_calls),
169 ..Self::new(Role::Assistant, content.into())
170 }
171 }
172
173 pub fn system(content: impl Into<String>) -> Self {
175 Self::new(Role::System, content.into())
176 }
177
178 pub fn developer(content: impl Into<String>) -> Self {
180 Self::new(Role::Developer, content.into())
181 }
182
183 pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
185 Self {
186 tool_call_id: Some(tool_call_id.into()),
187 ..Self::new(Role::Tool, content.into())
188 }
189 }
190
191 pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
193 Self {
194 parts: if parts.is_empty() { None } else { Some(parts) },
195 ..Self::new(Role::User, content.into())
196 }
197 }
198
199 pub fn strip_binary_data(&self) -> Self {
206 let parts = self.parts.as_ref().map(|parts| {
207 parts
208 .iter()
209 .map(|p| match p {
210 ContentPart::Image { media_type, .. } => ContentPart::Text {
211 text: format!("[Image: {}]", media_type),
212 },
213 ContentPart::ImageUrl { url } => ContentPart::Text {
214 text: format!("[Image: {}]", url),
215 },
216 ContentPart::Document {
217 media_type,
218 filename,
219 ..
220 } => ContentPart::Text {
221 text: format!("[Document: {} ({})]", filename, media_type),
222 },
223 ContentPart::Video {
224 media_type,
225 filename,
226 ..
227 } => ContentPart::Text {
228 text: format!("[Video: {} ({})]", filename, media_type),
229 },
230 ContentPart::VideoUrl { url } => ContentPart::Text {
231 text: format!("[Video: {}]", url),
232 },
233 ContentPart::Audio {
234 media_type,
235 filename,
236 ..
237 } => ContentPart::Text {
238 text: format!("[Audio: {} ({})]", filename, media_type),
239 },
240 other => other.clone(),
241 })
242 .collect()
243 });
244
245 Self {
246 parts,
247 ..self.clone()
248 }
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_message_serialization() {
258 let msg = Message::user("Hello");
259 let json = serde_json::to_string(&msg).unwrap();
260 assert!(json.contains("\"role\":\"user\""));
261 assert!(json.contains("Hello"));
262 }
263
264 #[test]
265 fn test_message_constructors() {
266 let user = Message::user("hi");
267 assert_eq!(user.role, Role::User);
268 assert_eq!(user.content, "hi");
269
270 let assistant = Message::assistant("hello");
271 assert_eq!(assistant.role, Role::Assistant);
272
273 let system = Message::system("you are helpful");
274 assert_eq!(system.role, Role::System);
275
276 let developer = Message::developer("injected context");
277 assert_eq!(developer.role, Role::Developer);
278 assert_eq!(developer.content, "injected context");
279
280 let tool = Message::tool_result("call-1", "result data");
281 assert_eq!(tool.role, Role::Tool);
282 assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
283 }
284
285 #[test]
286 fn test_content_part_serde() {
287 let text = ContentPart::Text {
288 text: "hello".into(),
289 };
290 let json = serde_json::to_string(&text).unwrap();
291 assert!(json.contains("\"type\":\"text\""));
292 let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
293 match roundtrip {
294 ContentPart::Text { text } => assert_eq!(text, "hello"),
295 _ => panic!("expected text"),
296 }
297
298 let img = ContentPart::Image {
299 media_type: "image/jpeg".into(),
300 data: "abc123".into(),
301 };
302 let json = serde_json::to_string(&img).unwrap();
303 assert!(json.contains("\"type\":\"image\""));
304 }
305
306 #[test]
307 fn test_strip_binary_data() {
308 let msg = Message::user_with_parts(
309 "photo",
310 vec![
311 ContentPart::Text {
312 text: "a cat".into(),
313 },
314 ContentPart::Image {
315 media_type: "image/jpeg".into(),
316 data: "bigdata".into(),
317 },
318 ],
319 );
320 let stripped = msg.strip_binary_data();
321 let parts = stripped.parts.unwrap();
322 assert_eq!(parts.len(), 2);
323 match &parts[1] {
324 ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
325 _ => panic!("expected text placeholder for image"),
326 }
327 }
328
329 #[test]
330 fn test_role_serde() {
331 let role = Role::Assistant;
332 let json = serde_json::to_string(&role).unwrap();
333 assert_eq!(json, "\"assistant\"");
334
335 let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
336 assert_eq!(parsed, Role::Tool);
337 }
338}