//! 本地图片引用展开(#958 图片入站引用化) //! //! 消息持久化只存引用(ContentPart::Image.url = `file://<绝对路径>`,base64 空), //! 发送请求前在此统一展开为 base64 内嵌——存储面瘦 referencing(一张 5MB 图不再以 //! 6.7MB base64 字符串进 SQLite 随历史全量重放),转换面按需读盘(SSD 毫秒级, //! 对比 base64 内嵌的每请求全量携带,磁盘读便宜得多)。 //! //! 展开点是 provider 转换层的入口:主会话/子代理/标题/压缩等全部请求路径在此 //! 单点覆盖,调用方(飞书入站/桌面粘贴图)只管落盘产引用,不关心 provider 差异。 //! //! 失败语义:引用文件丢失/不可读时降级为 Text 片「[图片文件缺失: path]」—— //! 诚实告知模型图不可用,不让请求 400(空 base64 会被两个协议端点拒绝)。 use crate::provider::ContentPart; use base64::Engine as _; /// `file://` 引用前缀。内部引用标识(两端自洽即可,不进端点):落盘方拼 /// `file://<原生绝对路径>`,展开方 strip 后按原生路径读(Windows 盘符路径直接可用)。 pub const LOCAL_FILE_SCHEME: &str = "file://"; /// 判定 Image 片是否为本地引用(base64 空 + url 带本地 scheme) fn is_local_ref(url: Option<&str>, base64: Option<&str>) -> bool { base64.is_none() && url.is_some_and(|u| u.starts_with(LOCAL_FILE_SCHEME)) } /// 把消息内的本地图片引用展开为 base64 内嵌片;非本地片原样透传。 /// 文件缺失/不可读时该 Image 片降级为 Text 片(请求不炸,模型得到可读的缺失说明)。 pub fn inline_local_image_parts(parts: Vec) -> Vec { parts.into_iter().map(inline_local_image_part).collect() } /// 单片版(provider 转换臂逐片 map 用) pub fn inline_local_image_part(p: ContentPart) -> ContentPart { match p { ContentPart::Image { url, base64, media_type, alt } if is_local_ref(url.as_deref(), base64.as_deref()) => { let path = url.unwrap(); match std::fs::read(&path[LOCAL_FILE_SCHEME.len()..]) { Ok(bytes) => ContentPart::Image { url: None, base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), media_type, alt, }, Err(_) => ContentPart::Text { text: format!("[图片文件缺失: {}]", &path[LOCAL_FILE_SCHEME.len()..]), }, } } other => other, } } #[cfg(test)] mod tests { use super::*; #[test] fn local_ref_expands_to_base64() { let dir = std::env::temp_dir().join("df-ai-image-ref-test"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("expands.png"); std::fs::write(&path, b"\x89PNG fake-bytes").unwrap(); let out = inline_local_image_parts(vec![ContentPart::Image { url: Some(format!("file://{}", path.display())), base64: None, media_type: Some("image/png".into()), alt: None, }]); match &out[0] { ContentPart::Image { url, base64, media_type, .. } => { assert!(url.is_none()); let b = base64.as_deref().expect("base64 应已填充"); assert_eq!(b, base64::engine::general_purpose::STANDARD.encode(b"\x89PNG fake-bytes")); assert_eq!(media_type.as_deref(), Some("image/png")); } other => panic!("应保持 Image 片, 实际 {other:?}"), } std::fs::remove_file(&path).ok(); } #[test] fn missing_file_degrades_to_text_not_empty_base64() { let out = inline_local_image_parts(vec![ContentPart::Image { url: Some("file:///nonexistent/df-ai-no-such.png".into()), base64: None, media_type: Some("image/png".into()), alt: None, }]); match &out[0] { ContentPart::Text { text } => { assert!(text.contains("[图片文件缺失"), "应降级为缺失说明, 实际 {text}"); assert!(text.contains("df-ai-no-such.png")); } other => panic!("缺失文件应降级 Text 片, 实际 {other:?}"), } } #[test] fn non_local_parts_pass_through() { let passthrough = vec![ ContentPart::text("hello"), ContentPart::image_base64("image/png", "aGVsbG8="), ContentPart::image_url("https://x/a.png"), // base64 非空的 file:// 不是引用(优先内嵌),原样透传 ContentPart::Image { url: Some("file:///x.png".into()), base64: Some("aGVsbG8=".into()), media_type: Some("image/png".into()), alt: None, }, ]; let out = inline_local_image_parts(passthrough); assert_eq!(out.len(), 4); assert!(matches!(&out[0], ContentPart::Text { .. })); assert!(matches!(&out[1], ContentPart::Image { base64: Some(_), .. })); assert!(matches!(&out[2], ContentPart::Image { url: Some(u), .. } if u.starts_with("https://"))); assert!(matches!(&out[3], ContentPart::Image { base64: Some(_), .. })); } }