Skip to main content

machi_types/
message.rs

1//! Conversation message model.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::id::ToolCallId;
7
8/// Participant role in a conversation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11#[non_exhaustive]
12pub enum Role {
13    /// System instructions.
14    System,
15    /// End-user content.
16    #[default]
17    User,
18    /// Model content.
19    Assistant,
20    /// Tool result content.
21    Tool,
22    /// Provider-specific developer role.
23    Developer,
24}
25
26impl Role {
27    /// Stable string form.
28    #[must_use]
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::System => "system",
32            Self::User => "user",
33            Self::Assistant => "assistant",
34            Self::Tool => "tool",
35            Self::Developer => "developer",
36        }
37    }
38}
39
40/// Image MIME types commonly used in multimodal prompts.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43#[non_exhaustive]
44pub enum ImageMime {
45    /// JPEG.
46    #[default]
47    Jpeg,
48    /// PNG.
49    Png,
50    /// GIF.
51    Gif,
52    /// WebP.
53    WebP,
54}
55
56impl ImageMime {
57    /// MIME string.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Jpeg => "image/jpeg",
62            Self::Png => "image/png",
63            Self::Gif => "image/gif",
64            Self::WebP => "image/webp",
65        }
66    }
67}
68
69/// One content part of a multimodal message.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum ContentPart {
74    /// Plain text.
75    Text {
76        /// Text body.
77        text: String,
78    },
79    /// Inline image bytes (base64) or URL.
80    Image {
81        /// MIME type.
82        mime: ImageMime,
83        /// Data URL or https URL.
84        url: String,
85    },
86}
87
88/// A model-emitted tool invocation.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[allow(
91    clippy::derive_partial_eq_without_eq,
92    reason = "serde_json::Value is not Eq"
93)]
94pub struct ToolCall {
95    /// Call id for pairing with tool results.
96    pub id: ToolCallId,
97    /// Tool name as presented to the model.
98    pub name: String,
99    /// JSON arguments object or raw string payload.
100    pub arguments: Value,
101}
102
103/// A single conversation message.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[allow(
106    clippy::derive_partial_eq_without_eq,
107    reason = "contains ToolCall with JSON Value"
108)]
109pub struct Message {
110    /// Role.
111    pub role: Role,
112    /// Text content when not multimodal.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub content: Option<String>,
115    /// Multimodal parts (preferred when present).
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub parts: Vec<ContentPart>,
118    /// Tool calls from the assistant.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub tool_calls: Vec<ToolCall>,
121    /// Tool call id when role is tool.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub tool_call_id: Option<ToolCallId>,
124    /// Tool name when role is tool.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub name: Option<String>,
127}
128
129impl Message {
130    /// System message.
131    #[must_use]
132    pub fn system(content: impl Into<String>) -> Self {
133        Self {
134            role: Role::System,
135            content: Some(content.into()),
136            parts: Vec::new(),
137            tool_calls: Vec::new(),
138            tool_call_id: None,
139            name: None,
140        }
141    }
142
143    /// User message.
144    #[must_use]
145    pub fn user(content: impl Into<String>) -> Self {
146        Self {
147            role: Role::User,
148            content: Some(content.into()),
149            parts: Vec::new(),
150            tool_calls: Vec::new(),
151            tool_call_id: None,
152            name: None,
153        }
154    }
155
156    /// Assistant text message.
157    #[must_use]
158    pub fn assistant(content: impl Into<String>) -> Self {
159        Self {
160            role: Role::Assistant,
161            content: Some(content.into()),
162            parts: Vec::new(),
163            tool_calls: Vec::new(),
164            tool_call_id: None,
165            name: None,
166        }
167    }
168
169    /// Assistant message with tool calls.
170    #[must_use]
171    pub const fn assistant_tools(tool_calls: Vec<ToolCall>) -> Self {
172        Self {
173            role: Role::Assistant,
174            content: None,
175            parts: Vec::new(),
176            tool_calls,
177            tool_call_id: None,
178            name: None,
179        }
180    }
181
182    /// Tool result message.
183    #[must_use]
184    pub fn tool_result(
185        tool_call_id: ToolCallId,
186        name: impl Into<String>,
187        content: impl Into<String>,
188    ) -> Self {
189        Self {
190            role: Role::Tool,
191            content: Some(content.into()),
192            parts: Vec::new(),
193            tool_calls: Vec::new(),
194            tool_call_id: Some(tool_call_id),
195            name: Some(name.into()),
196        }
197    }
198
199    /// Best-effort plain text extraction.
200    #[must_use]
201    pub fn text(&self) -> String {
202        if let Some(content) = &self.content {
203            return content.clone();
204        }
205        self.parts
206            .iter()
207            .filter_map(|p| match p {
208                ContentPart::Text { text } => Some(text.as_str()),
209                ContentPart::Image { .. } => None,
210            })
211            .collect::<Vec<_>>()
212            .join("")
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn round_trip_user() {
222        let m = Message::user("hello");
223        let json = serde_json::to_string(&m).expect("ser");
224        let back: Message = serde_json::from_str(&json).expect("de");
225        assert_eq!(back.role, Role::User);
226        assert_eq!(back.text(), "hello");
227    }
228
229    #[test]
230    fn tool_call_message() {
231        let id = ToolCallId::new("call_1").expect("id");
232        let m = Message::assistant_tools(vec![ToolCall {
233            id: id.clone(),
234            name: "add".into(),
235            arguments: serde_json::json!({"a":1,"b":2}),
236        }]);
237        assert_eq!(m.tool_calls.len(), 1);
238        assert_eq!(m.tool_calls.first().map(|c| &c.id), Some(&id));
239    }
240}