Skip to main content

xyz_rust/
blocks.rs

1//! 内容块结果(spec §12.7):handler 可以返回 `Blocks` 而不是纯 JSON 值。
2//!
3//! 块结果穿过三个前端前先被序列化成**保留块信封**——唯一键为 `content`
4//! 的对象、数组项形如 `{"type":"text","text":…}` 或
5//! `{"type":"image","mimeType":…,"data":…}`(载荷 base64)——前端凭形状把它
6//! 从普通 JSON 里识别出来([`extract`])。MCP 原样透传为 `Content`;CLI 把
7//! 二进制块落盘到临时目录并打印路径;HTTP 直出信封 JSON。
8//!
9//! 载荷不经过库编码/截断:调用方负责提供 base64 字符串。
10
11use serde::ser::{Serialize, SerializeMap, Serializer};
12use serde_json::Value;
13
14use crate::errors;
15use crate::spec::XyzSchema;
16use crate::spec::schema::Schema;
17
18/// 单块内容。与 MCP `Content` 一 一对应(P0 支持 text/image/audio;
19/// resource 留待需要时加入)。
20#[derive(Debug, Clone, PartialEq)]
21pub enum Block {
22    Text {
23        text: String,
24    },
25    /// `data` 是 base64 载荷(MCP 协议形态,不做原始字节存储)。
26    Image {
27        mime_type: String,
28        data: String,
29    },
30    Audio {
31        mime_type: String,
32        data: String,
33    },
34}
35
36/// 块结果:handler 的返回类型(实现 [`XyzSchema`] 与 [`Serialize`],
37/// 直接满足 `define` 的结果约束)。
38#[derive(Debug, Clone, PartialEq, Default)]
39pub struct Blocks {
40    pub content: Vec<Block>,
41}
42
43impl Blocks {
44    pub fn new(content: Vec<Block>) -> Self {
45        Blocks { content }
46    }
47
48    pub fn text(text: impl Into<String>) -> Self {
49        Blocks::new(vec![Block::Text { text: text.into() }])
50    }
51
52    pub fn image(mime_type: impl Into<String>, data: impl Into<String>) -> Self {
53        Blocks::new(vec![Block::Image {
54            mime_type: mime_type.into(),
55            data: data.into(),
56        }])
57    }
58
59    pub fn audio(mime_type: impl Into<String>, data: impl Into<String>) -> Self {
60        Blocks::new(vec![Block::Audio {
61            mime_type: mime_type.into(),
62            data: data.into(),
63        }])
64    }
65
66    /// 追加一块。
67    pub fn push(&mut self, block: Block) {
68        self.content.push(block);
69    }
70}
71
72impl Serialize for Block {
73    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
74        let mut map = s.serialize_map(Some(3))?;
75        match self {
76            Block::Text { text } => {
77                map.serialize_entry("type", "text")?;
78                map.serialize_entry("text", text)?;
79            }
80            Block::Image { mime_type, data } | Block::Audio { mime_type, data } => {
81                let kind = if matches!(self, Block::Image { .. }) {
82                    "image"
83                } else {
84                    "audio"
85                };
86                map.serialize_entry("type", kind)?;
87                map.serialize_entry("mimeType", mime_type)?;
88                map.serialize_entry("data", data)?;
89            }
90        }
91        map.end()
92    }
93}
94
95impl Serialize for Blocks {
96    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
97        let mut map = s.serialize_map(Some(1))?;
98        map.serialize_entry("content", &self.content)?;
99        map.end()
100    }
101}
102
103impl XyzSchema for Blocks {
104    fn xyz_schema() -> Option<Schema> {
105        // 块结果没有单一 JSON Schema:outputSchema 缺省(前端按信封形状渲染)。
106        None
107    }
108}
109
110/// 判定保留块信封并还原块清单。普通 JSON 返回 `None`;形状不完全吻合也
111/// 整体返回 `None`(部分匹配拒绝,绝不半渲染半透传)。
112pub fn extract(v: &Value) -> Option<Vec<Block>> {
113    let obj = v.as_object()?;
114    if obj.len() != 1 {
115        return None;
116    }
117    let items = obj.get("content")?.as_array()?;
118    let mut out = Vec::with_capacity(items.len());
119    for item in items {
120        out.push(block_from_value(item)?);
121    }
122    Some(out)
123}
124
125fn block_from_value(v: &Value) -> Option<Block> {
126    let o = v.as_object()?;
127    match o.get("type")?.as_str()? {
128        "text" => Some(Block::Text {
129            text: o.get("text")?.as_str()?.to_string(),
130        }),
131        "image" => Some(Block::Image {
132            mime_type: o.get("mimeType")?.as_str()?.to_string(),
133            data: o.get("data")?.as_str()?.to_string(),
134        }),
135        "audio" => Some(Block::Audio {
136            mime_type: o.get("mimeType")?.as_str()?.to_string(),
137            data: o.get("data")?.as_str()?.to_string(),
138        }),
139        _ => None,
140    }
141}
142
143/// MIME → 文件扩展名(CLI 落盘时用;未知类型统一 `bin`)。
144pub fn ext_for_mime(mime: &str) -> &'static str {
145    match mime {
146        "image/png" => "png",
147        "image/jpeg" => "jpg",
148        "image/gif" => "gif",
149        "image/webp" => "webp",
150        "image/bmp" => "bmp",
151        "image/svg+xml" => "svg",
152        "audio/mpeg" => "mp3",
153        "audio/wav" => "wav",
154        "audio/ogg" => "ogg",
155        _ => "bin",
156    }
157}
158
159const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
160
161/// 解码标准 base64(可选 `=` padding,容忍空白)。库零三方依赖:这是
162/// 约 40 行的标准算法,只为 CLI 落盘与信封形状校验服务。
163pub fn decode_base64(s: &str) -> errors::Result<Vec<u8>> {
164    fn val(c: u8) -> Option<u32> {
165        B64.iter().position(|&b| b == c).map(|i| i as u32)
166    }
167    let mut out = Vec::with_capacity(s.len() / 4 * 3);
168    let mut acc: u32 = 0;
169    let mut bits = 0u8;
170    for b in s.bytes() {
171        if b.is_ascii_whitespace() {
172            continue;
173        }
174        if b == b'=' {
175            break; // padding 截止;余下交给长度检查
176        }
177        let Some(v) = val(b) else {
178            return Err(errors::new(
179                errors::Kind::Internal,
180                format!("invalid base64 payload: byte {b:#04x}"),
181            ));
182        };
183        acc = (acc << 6) | v;
184        bits += 6;
185        if bits >= 8 {
186            bits -= 8;
187            out.push((acc >> bits) as u8);
188        }
189    }
190    Ok(out)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::json;
197
198    #[test]
199    fn envelope_round_trip() {
200        let b = Blocks::new(vec![
201            Block::Text {
202                text: "hello".to_string(),
203            },
204            Block::Image {
205                mime_type: "image/png".into(),
206                data: "aGVsbG8=".into(),
207            },
208        ]);
209        let v = serde_json::to_value(&b).unwrap();
210        assert_eq!(
211            v,
212            json!({"content":[
213                {"type":"text","text":"hello"},
214                {"type":"image","mimeType":"image/png","data":"aGVsbG8="}
215            ]})
216        );
217        let back = extract(&v).unwrap();
218        assert_eq!(back, b.content);
219        assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
220        assert_eq!(decode_base64("aGVs bG8=").unwrap(), b"hello"); // 容忍空白
221        assert!(decode_base64("not*b64").is_err());
222    }
223
224    #[test]
225    fn extract_rejects_non_envelopes() {
226        // 普通对象、多键对象、坏形状项、未知类型=全部按普通 JSON 处理。
227        assert!(extract(&json!({"msg": "hi"})).is_none());
228        assert!(extract(&json!({"content": "text"})).is_none());
229        assert!(extract(&json!({"content": [{"type": "text"}]})).is_none());
230        assert!(extract(&json!({"content": [{"type": "video", "data": "x"}]})).is_none());
231        assert!(extract(&json!({"content": [{"type": "text", "text": "a"}]})).is_some());
232        assert!(extract(&json!({"content": [], "extra": 1})).is_none());
233        // 空 content 数组是合法空信封。
234        assert_eq!(extract(&json!({"content": []})), Some(vec![]));
235    }
236
237    #[test]
238    fn mime_extensions() {
239        assert_eq!(ext_for_mime("image/png"), "png");
240        assert_eq!(ext_for_mime("audio/mpeg"), "mp3");
241        assert_eq!(ext_for_mime("application/x-unknown"), "bin");
242    }
243}