新增: 媒体引用化存储与消息溯源
- 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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user