- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用 - 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线 - 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底 - 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库 - spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串 - 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
123 lines
5.2 KiB
Rust
123 lines
5.2 KiB
Rust
//! 本地图片引用展开(#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<ContentPart>) -> Vec<ContentPart> {
|
|
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(_), .. }));
|
|
}
|
|
}
|