Skip to main content

rune_chain_core/
message.rs

1use serde::{Deserialize, Serialize};
2
3use crate::ToolCall;
4
5/// The originator of a [`Message`] in a conversation turn.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum Role {
9    /// Instructions that frame the model's behaviour for the whole conversation.
10    System,
11    /// A turn sent by the end user or caller.
12    Human,
13    /// A turn generated by the AI model.
14    Ai,
15    /// A synthetic turn injected by a tool or function-call result.
16    Tool,
17}
18
19impl std::fmt::Display for Role {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Role::System => write!(f, "system"),
23            Role::Human => write!(f, "human"),
24            Role::Ai => write!(f, "ai"),
25            Role::Tool => write!(f, "tool"),
26        }
27    }
28}
29
30/// Raw image payload that can be embedded in a message.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case", tag = "type")]
33pub enum ImageData {
34    /// A publicly accessible image URL.
35    Url { url: String },
36    /// A base64-encoded image with an explicit MIME type.
37    Base64 { mime_type: String, data: String },
38}
39
40/// A single part within a multi-part message content.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case", tag = "type")]
43pub enum ContentPart {
44    /// A plain-text fragment.
45    Text { text: String },
46    /// An image fragment (URL or base64).
47    Image { image: ImageData },
48}
49
50impl ContentPart {
51    /// Convenience constructor for a text part.
52    pub fn text(text: impl Into<String>) -> Self {
53        ContentPart::Text { text: text.into() }
54    }
55
56    /// Convenience constructor for an image-URL part.
57    pub fn image_url(url: impl Into<String>) -> Self {
58        ContentPart::Image {
59            image: ImageData::Url { url: url.into() },
60        }
61    }
62
63    /// Convenience constructor for a base64-encoded image part.
64    pub fn image_base64(mime_type: impl Into<String>, data: impl Into<String>) -> Self {
65        ContentPart::Image {
66            image: ImageData::Base64 {
67                mime_type: mime_type.into(),
68                data: data.into(),
69            },
70        }
71    }
72}
73
74/// The body of a [`Message`]: either plain text or a list of rich content parts.
75///
76/// `MessageContent` serialises as a JSON string for `Text` and as a JSON array
77/// for `Parts`, matching the format expected by OpenAI, Anthropic, and Ollama.
78///
79/// # Constructing
80///
81/// Plain-text messages work through `From<String>` / `From<&str>`:
82/// ```rust
83/// use rune_chain_core::MessageContent;
84///
85/// let c: MessageContent = "hello".into();
86/// assert_eq!(c.as_text(), "hello");
87/// ```
88///
89/// Multi-modal messages use [`MessageContent::Parts`]:
90/// ```rust
91/// use rune_chain_core::{MessageContent, ContentPart};
92///
93/// let c = MessageContent::Parts(vec![
94///     ContentPart::text("What is in this image?"),
95///     ContentPart::image_url("https://example.com/cat.jpg"),
96/// ]);
97/// ```
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(untagged)]
100pub enum MessageContent {
101    /// Plain UTF-8 text.
102    Text(String),
103    /// An ordered sequence of rich content parts (text + images).
104    Parts(Vec<ContentPart>),
105}
106
107impl MessageContent {
108    /// Return the text content.
109    ///
110    /// - For `Text(s)`: borrows `s` directly.
111    /// - For `Parts(...)`: returns `""` — use [`text_content`](Self::text_content) to get all text.
112    pub fn as_text(&self) -> &str {
113        match self {
114            MessageContent::Text(s) => s.as_str(),
115            MessageContent::Parts(_) => "",
116        }
117    }
118
119    /// Collect all text fragments into an owned `String`.
120    ///
121    /// For `Text(s)`: clones `s`.
122    /// For `Parts(...)`: joins all `Text` parts with a space.
123    pub fn text_content(&self) -> String {
124        match self {
125            MessageContent::Text(s) => s.clone(),
126            MessageContent::Parts(parts) => parts
127                .iter()
128                .filter_map(|p| {
129                    if let ContentPart::Text { text } = p {
130                        Some(text.as_str())
131                    } else {
132                        None
133                    }
134                })
135                .collect::<Vec<_>>()
136                .join(" "),
137        }
138    }
139
140    /// Return `true` if this is a plain-text message with no image parts.
141    pub fn is_text(&self) -> bool {
142        matches!(self, MessageContent::Text(_))
143    }
144}
145
146impl From<String> for MessageContent {
147    fn from(s: String) -> Self {
148        MessageContent::Text(s)
149    }
150}
151
152impl From<&str> for MessageContent {
153    fn from(s: &str) -> Self {
154        MessageContent::Text(s.to_string())
155    }
156}
157
158impl From<&String> for MessageContent {
159    fn from(s: &String) -> Self {
160        MessageContent::Text(s.clone())
161    }
162}
163
164impl std::fmt::Display for MessageContent {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        write!(f, "{}", self.text_content())
167    }
168}
169
170impl PartialEq<str> for MessageContent {
171    fn eq(&self, other: &str) -> bool {
172        self.as_text() == other
173    }
174}
175
176impl PartialEq<&str> for MessageContent {
177    fn eq(&self, other: &&str) -> bool {
178        self.as_text() == *other
179    }
180}
181
182impl PartialEq<String> for MessageContent {
183    fn eq(&self, other: &String) -> bool {
184        self.as_text() == other.as_str()
185    }
186}
187
188/// A single turn in a conversation, carrying a [`Role`], rich [`MessageContent`],
189/// and optional tool-call metadata for function-calling workflows.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Message {
192    /// Who produced this message.
193    pub role: Role,
194    /// The message body — plain text or multi-modal parts.
195    pub content: MessageContent,
196    /// Tool calls the model wants to make (populated on [`Role::Ai`] turns when
197    /// the model requests native function calling).
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub tool_calls: Vec<ToolCall>,
200    /// The call ID this result belongs to (populated on [`Role::Tool`] turns).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub tool_call_id: Option<String>,
203}
204
205impl Message {
206    /// Create a new [`Message`] with an explicit role and content.
207    ///
208    /// # Example
209    ///
210    /// ```rust
211    /// use rune_chain_core::{Message, Role};
212    ///
213    /// let msg = Message::new(Role::Human, "Hello!");
214    /// assert_eq!(msg.role, Role::Human);
215    /// assert_eq!(msg.content.as_text(), "Hello!");
216    /// ```
217    pub fn new(role: Role, content: impl Into<MessageContent>) -> Self {
218        Self {
219            role,
220            content: content.into(),
221            tool_calls: Vec::new(),
222            tool_call_id: None,
223        }
224    }
225
226    /// Shorthand for a [`Role::System`] message.
227    pub fn system(content: impl Into<MessageContent>) -> Self {
228        Self::new(Role::System, content)
229    }
230
231    /// Shorthand for a [`Role::Human`] message.
232    pub fn human(content: impl Into<MessageContent>) -> Self {
233        Self::new(Role::Human, content)
234    }
235
236    /// Shorthand for a [`Role::Ai`] message.
237    pub fn ai(content: impl Into<MessageContent>) -> Self {
238        Self::new(Role::Ai, content)
239    }
240
241    /// Create an AI message that carries pending tool calls.
242    ///
243    /// Used internally by function-calling agents to represent the model's
244    /// "I want to call these tools" turn before observations are appended.
245    ///
246    /// # Example
247    ///
248    /// ```rust
249    /// use rune_chain_core::{Message, ToolCall};
250    ///
251    /// let msg = Message::ai_with_tool_calls(
252    ///     "",
253    ///     vec![ToolCall::new("call_1", "upper_case", r#"{"input":"hello"}"#)],
254    /// );
255    /// assert_eq!(msg.tool_calls.len(), 1);
256    /// ```
257    pub fn ai_with_tool_calls(
258        content: impl Into<MessageContent>,
259        tool_calls: Vec<ToolCall>,
260    ) -> Self {
261        Self {
262            role: Role::Ai,
263            content: content.into(),
264            tool_calls,
265            tool_call_id: None,
266        }
267    }
268
269    /// Create a tool-result message to feed back an observation from a tool call.
270    ///
271    /// # Example
272    ///
273    /// ```rust
274    /// use rune_chain_core::Message;
275    ///
276    /// let msg = Message::tool_result("call_1", "HELLO");
277    /// ```
278    pub fn tool_result(call_id: impl Into<String>, content: impl Into<MessageContent>) -> Self {
279        Self {
280            role: Role::Tool,
281            content: content.into(),
282            tool_calls: Vec::new(),
283            tool_call_id: Some(call_id.into()),
284        }
285    }
286}