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}