Skip to main content

machi_protocol/
content.rs

1//! Model- and host-facing content blocks (text / image).
2
3use serde::{Deserialize, Serialize};
4
5/// MIME type for inline images.
6#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(transparent)]
8pub struct ImageMime(pub String);
9
10impl ImageMime {
11    /// Common PNG MIME.
12    #[must_use]
13    pub fn png() -> Self {
14        Self("image/png".into())
15    }
16
17    /// Common JPEG MIME.
18    #[must_use]
19    pub fn jpeg() -> Self {
20        Self("image/jpeg".into())
21    }
22}
23
24/// Inline image payload.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ImageBlock {
27    /// MIME type (e.g. `image/png`).
28    pub mime_type: ImageMime,
29    /// Base64-encoded bytes (or host-specific data URI payload without prefix).
30    pub data: String,
31    /// Optional stable media id for follow-up tool calls.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub media_id: Option<String>,
34    /// Optional filename.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub filename: Option<String>,
37    /// Optional filesystem path when the host materializes the image.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub path: Option<String>,
40}
41
42/// Rich content unit for tool progress, tool results, and multimodal messages.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(tag = "type", rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum ContentBlock {
47    /// Plain text.
48    Text {
49        /// Text body.
50        text: String,
51    },
52    /// Inline image.
53    Image(ImageBlock),
54}
55
56impl ContentBlock {
57    /// Text block helper.
58    #[must_use]
59    pub fn text(text: impl Into<String>) -> Self {
60        Self::Text { text: text.into() }
61    }
62
63    /// Flatten text blocks only (images become empty contribution).
64    #[must_use]
65    pub fn as_text(&self) -> Option<&str> {
66        match self {
67            Self::Text { text } => Some(text.as_str()),
68            Self::Image(_) => None,
69        }
70    }
71}
72
73/// Join text blocks with newlines; skip non-text.
74#[must_use]
75pub fn join_text_blocks(blocks: &[ContentBlock]) -> String {
76    blocks
77        .iter()
78        .filter_map(ContentBlock::as_text)
79        .collect::<Vec<_>>()
80        .join("\n")
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn serde_round_trip_text() {
89        let block = ContentBlock::text("hello");
90        let v = serde_json::to_value(&block).expect("ser");
91        let back: ContentBlock = serde_json::from_value(v).expect("de");
92        assert_eq!(back.as_text(), Some("hello"));
93    }
94}