新增: 媒体引用化存储与消息溯源

- 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 发送前按需展开
This commit is contained in:
lxy
2026-10-11 07:33:18 +08:00
parent 8aefe3254c
commit 18e50c5af7
23 changed files with 1542 additions and 105 deletions
+59 -1
View File
@@ -288,9 +288,11 @@ impl AnthropicCompatProvider {
/// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
/// base64 内嵌;本地 file:// 引用(#958)先展开为 base64,http(s) url 模式
/// 当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
let p = crate::image_ref::inline_local_image_part(p);
match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
@@ -427,6 +429,22 @@ impl AnthropicCompatProvider {
.and_then(|m| m.get("role").and_then(|r| r.as_str()))
.unwrap_or("");
if first_role == "user" {
// 首条 user 但 content 为空串(会话压缩/续发残留,典型 text(0B)):原样发出会被
// precheck "user content 空" 拦下而整请求失败。与上方补占位同思路——就地填
// 占位文案(空串无信息可丢),上游实测接受非空占位,precheck 亦放行。
let empty_str = messages[0]
.get("content")
.and_then(|c| c.as_str())
.map(|s| s.is_empty())
.unwrap_or(false);
if empty_str {
warn!(
msg_count = messages.len(),
"ensure_leading_user: 首条 user content 为空串,填占位(防 precheck 拦截)"
);
messages[0]["content"] =
serde_json::json!("(continued from previous context)");
}
return;
}
// 空 Vec(异常会话经 sanitize 清空)或首条非 user → 补 user 占位:
@@ -1163,6 +1181,46 @@ mod tests {
assert_eq!(content[1]["source"]["data"], "iVBOR");
}
/// 本地 file:// 引用(#958)在转换层展开为 base64 source;缺失降级 text 块不炸请求
#[test]
fn anthropic_convert_expands_local_file_ref() {
use base64::Engine as _;
let _convert = convert_lock();
let dir = std::env::temp_dir().join("df-ai-anthropic-ref-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ref.png");
std::fs::write(&path, b"\x89PNG ok").unwrap();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![ChatMessage::user_parts(
"看图",
vec![crate::provider::ContentPart::Image {
url: Some(format!("file://{}", path.display())),
base64: None,
media_type: Some("image/png".into()),
alt: None,
}],
)],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
let body = provider.convert_request(req);
let user_msg = body
.messages
.iter()
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.expect("应有 user 消息");
let content = user_msg.get("content").and_then(|c| c.as_array()).expect("user content 应为数组");
assert_eq!(content[1]["source"]["data"], base64::engine::general_purpose::STANDARD.encode(b"\x89PNG ok"));
std::fs::remove_file(&path).ok();
}
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {