优化: anthropic 协议图片 URL 预拉转 base64 + 无 Vision 模型温和丢图

This commit is contained in:
lxy
2026-08-08 14:19:15 +08:00
parent c1f2fe16c8
commit 4ce6a859e7
4 changed files with 254 additions and 4 deletions
+9 -2
View File
@@ -44,6 +44,12 @@ impl Default for TokenEstimator {
} }
} }
// url 模式图片 token 地板(Anthropic 保守 ~1600/图,OpenAI 按 tile;URL 长度估严重低估会致预算裁剪不触发)。
// 2026-08-05(F-260801 Phase4):url 模式无字节可估,仅按 URL 长度估(几十字符≈几 token)会致含图
// 消息 history_tokens 严重低估 → 预算裁剪不触发 → 多图/长会话超 provider 上限 400。地板按每图
// ~1600 token 保守估(对齐 Anthropic 图片 token 成本量级),宁可高估触发裁剪也不低估漏裁。
const URL_IMAGE_TOKEN_FLOOR: usize = 1600;
impl TokenEstimator { impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计) /// 估算单条消息的 token 数(保守估计)
/// ///
@@ -63,8 +69,9 @@ impl TokenEstimator {
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估 // base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 { if let Some(b) = base64 {
char_count += b.chars().count(); char_count += b.chars().count();
} else if let Some(u) = url { } else if url.is_some() {
char_count += u.chars().count(); // F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
char_count += URL_IMAGE_TOKEN_FLOOR;
} }
} }
} }
+1
View File
@@ -636,6 +636,7 @@ impl LlmProvider for OpenAICompatProvider {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::openai_helpers::OpenAiFunctionResp;
/// 辅助:构造普通文本 delta chunk 的 SSE data /// 辅助:构造普通文本 delta chunk 的 SSE data
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String { fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
+169 -2
View File
@@ -15,6 +15,9 @@ use std::sync::atomic::Ordering;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
// F-260801 Phase2:anthropic 协议 url→base64 预拉(base64 内嵌编码,generate_image.rs 同款用法)。
use base64::{engine::general_purpose::STANDARD, Engine as _};
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus}; use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use df_types::augmentation::{MentionRef, MentionSpanDto}; use df_types::augmentation::{MentionRef, MentionSpanDto};
@@ -375,6 +378,115 @@ pub async fn ai_is_generating(
Ok(state.conv_states.is_active(&target)) Ok(state.conv_states.is_active(&target))
} }
// ============================================================
// F-260801 Phase2:anthropic 协议 url→base64 预拉
// ============================================================
/// anthropic/anthropic_compat 需 url→base64 预拉(协议要求内嵌 base64,不接受 URL 直传)。
fn needs_url_prefetch(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 目标模型是否支持 Vision 模态。
///
/// 判定来源(优先后):
/// 1. provider.model_configs 里 model_id 精确匹配的目标模型 → 其 modalities 含 Vision
/// 2. 目标模型无配置 → `model_probe::probe(model_id)` 启发式推断(词素/预设表)
/// 3. 目标模型无法解析 → 保守视为支持(默认放行,不误伤新模型)
fn model_supports_vision(provider: &AiProviderRecord, model_id: &str) -> bool {
// 1. model_configs 精确匹配
if let Some(cfg) = provider.model_configs.iter().find(|m| m.model_id == model_id) {
return cfg.modalities.iter().any(|m| matches!(m, df_ai::router::Modality::Vision));
}
// 2. model_probe 启发式推断(词素/预设表)
let probed = df_ai::model_probe::probe(model_id);
probed.modalities.iter().any(|m| matches!(m, df_ai::router::Modality::Vision))
}
/// 解析图片 parts:先按目标模型 vision 能力过滤(无 vision 丢图片温和降级),再按 provider
/// 预拉(url→base64,anthropic)。返回过滤/预拉后的 parts。
///
/// 模型 ≠ 协议。anthropic/openai 是协议,具体模型可能无 Vision 模态
/// (deepseek-v4-flash / sensenova flash 纯文本),硬发图 → provider 400。故先按目标模型
/// modalities 判断,无 Vision 丢弃图片 parts(仅文本,提示用户),避免 400 + 浪费往返。
async fn resolve_parts_with_capability(
parts: Vec<ContentPart>,
provider: &AiProviderRecord,
model_override: Option<&str>,
) -> (Vec<ContentPart>, bool) {
// 目标模型:override 优先,否则 provider 默认模型。
let model_id = model_override
.map(|s| s.to_string())
.unwrap_or_else(|| provider.default_model.clone());
// 图片片存在才需能力判断;无图直接透传(零开销)。
let has_image = parts.iter().any(|p| matches!(p, ContentPart::Image { .. }));
if !has_image {
return (parts, false);
}
if !model_supports_vision(provider, &model_id) {
// 温和降级:丢弃图片 parts(仅文本),提示用户。不阻塞对话。
tracing::info!(
model = %model_id,
"当前模型不支持 Vision 模态,图片已忽略(仅发文本)",
);
let text_parts: Vec<ContentPart> = parts
.into_iter()
.filter(|p| matches!(p, ContentPart::Text { .. }))
.collect();
return (text_parts, true);
}
// 模型支持图片 → 按 provider 预拉(url→base64,anthropic;openai 直传)。
(resolve_parts_for_provider(parts, &provider.provider_type).await, false)
}
/// 对需要预拉的 provider 将 url 模式 Image 拉字节→base64 内嵌;其他 provider 原样返回。
///
/// 网络拉取在此执行(SSRF 防护链 http::fetch_bytes_ssrf),调用点位于 session.lock 之前,
/// 不在持锁内做 I/O。失败时保留原 url 片不阻断(降级为原 URL 行为,provider 层再报 400)。
async fn resolve_parts_for_provider(
parts: Vec<ContentPart>,
provider_type: &str,
) -> Vec<ContentPart> {
if !needs_url_prefetch(provider_type) {
return parts;
}
let mut out = Vec::with_capacity(parts.len());
for p in parts {
match p {
ContentPart::Image {
url: Some(u),
base64: None,
media_type,
alt,
} => match super::super::http::fetch_bytes_ssrf(&u, 10 * 1024 * 1024).await {
Ok((bytes, ct)) => {
// 原始 part 的 media_type(url 模式可空)优先;空则从 Content-Type/URL 扩展名推断。
let mt = media_type
.unwrap_or_else(|| super::super::http::infer_media_type(&u, ct.as_deref()));
let b64 = STANDARD.encode(&bytes);
out.push(ContentPart::Image {
url: Some(u),
base64: Some(b64),
media_type: Some(mt),
alt,
});
}
Err(e) => {
tracing::warn!(url = %u, error = %e, "[ai] anthropic url→base64 预拉失败,保留原 url 片");
out.push(ContentPart::Image {
url: Some(u),
base64: None,
media_type,
alt,
});
}
},
other => out.push(other),
}
}
out
}
/// 发送消息并获取流式 AI 响应 /// 发送消息并获取流式 AI 响应
/// ///
/// 非阻塞:立即返回 "ok",通过 ai-chat-event 事件流式推送 /// 非阻塞:立即返回 "ok",通过 ai-chat-event 事件流式推送
@@ -403,6 +515,18 @@ pub async fn ai_chat_send(
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(&state).await?;
// anthropic 协议需 url→base64 预拉(base64 内嵌,URL 不可达;OpenAI 兼容原生 URL 直传零改动)。
// 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。
// 另做模型级 vision 能力判断——目标模型无 Vision 模态时,图片 parts 丢弃
// (温和降级:仅发文本,模型不支持图片不硬发 400),否则走预拉/直传。
let (parts, degraded) = match parts {
Some(ps) if !ps.is_empty() => {
let (ps, degraded) = resolve_parts_with_capability(ps, &provider_config, model_override.as_deref()).await;
(Some(ps), degraded)
}
other => (other, false),
};
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话 // 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
// F-260616-09 B 批4(决策 e 真并发上线): // F-260616-09 B 批4(决策 e 真并发上线):
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback // - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
@@ -451,7 +575,24 @@ pub async fn ai_chat_send(
// F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname} // F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname}
// 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。 // 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。
// 非空 message 原样落库。 // 非空 message 原样落库。
let user_content = if message.trim().is_empty() { // 图片降级提示:目标模型无 Vision 时图片被忽略(温和式,不打断对话)。
// 用户已有文本 → append 提示;空文本(纯发图) → 提示即内容。
let user_content = if degraded {
let base = if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else {
message.clone()
};
if base.trim().is_empty() {
"[图片已忽略:当前模型不支持图片]".to_string()
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill { if let Some(ref skill_name) = skill {
format!("/{}", skill_name) format!("/{}", skill_name)
} else { } else {
@@ -1656,6 +1797,15 @@ pub async fn ai_chat_force_send(
// 获取活跃提供商(只读,失败可直接返回,不占用 generating) // 获取活跃提供商(只读,失败可直接返回,不占用 generating)
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(&state).await?;
// anthropic 协议需 url→base64 预拉(同 ai_chat_send,拉取在 lock 之前)+ 模型级 vision 能力过滤。
let (parts, degraded) = match parts {
Some(ps) if !ps.is_empty() => {
let (ps, degraded) = resolve_parts_with_capability(ps, &provider_config, model_override.as_deref()).await;
(Some(ps), degraded)
}
other => (other, false),
};
// 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。 // 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态; // stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。 // 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
@@ -1721,7 +1871,24 @@ pub async fn ai_chat_force_send(
conv.iteration_used = 0; conv.iteration_used = 0;
conv.model_override = model_override.clone(); conv.model_override = model_override.clone();
// user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send // user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send
let user_content = if message.trim().is_empty() { // 图片降级提示:目标模型无 Vision 时图片被忽略(温和式,不打断对话)。
// 用户已有文本 → append 提示;空文本(纯发图) → 提示即内容。
let user_content = if degraded {
let base = if message.trim().is_empty() {
if let Some(ref skill_name) = skill {
format!("/{}", skill_name)
} else {
message.clone()
}
} else {
message.clone()
};
if base.trim().is_empty() {
"[图片已忽略:当前模型不支持图片]".to_string()
} else {
format!("{} [图片已忽略:当前模型不支持图片]", base)
}
} else if message.trim().is_empty() {
if let Some(ref skill_name) = skill { if let Some(ref skill_name) = skill {
format!("/{}", skill_name) format!("/{}", skill_name)
} else { } else {
+75
View File
@@ -222,6 +222,81 @@ pub(crate) async fn execute_with_redirects(
} }
} }
// ============================================================
// 通用字节拉取(SSRF 防护链) — 供 chat.rs 多模态 url→base64 预拉等场景复用
// ============================================================
/// 图片/文件 URL → 字节(SSRF 防护链,参考 generate_image.rs download_image_with_ssrf)。
/// 返回 (bytes, content_type)。仅内存不回写磁盘。
///
/// 安全链路与 execute_http_request 一致:validate_url(协议+词法) → resolve_and_check_host
/// (DNS rebinding 拦截) → execute_with_redirects(每跳重定向重复 SSRF 校验)。
pub(crate) async fn fetch_bytes_ssrf(
url: &str,
max_bytes: u64,
) -> anyhow::Result<(Vec<u8>, Option<String>)> {
use anyhow::Context;
// SSRF 三层校验:词法 URL → DNS resolve IP → 每跳重定向重复(execute_with_redirects 内部)
let (_scheme, host, port) = validate_url(url)?;
resolve_and_check_host(&host, port).await?;
let client = build_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
let resp = execute_with_redirects(
&client,
reqwest::Method::GET,
url.to_string(),
&HashMap::new(), // 无自定义头
&None,
MAX_REDIRECTS,
)
.await?;
if !resp.status().is_success() {
anyhow::bail!(
"请求失败:HTTP {} {}({})",
resp.status().as_u16(),
resp.status().canonical_reason().unwrap_or(""),
url
);
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = resp.bytes().await.context("读取图片字节失败")?;
if bytes.len() as u64 > max_bytes {
anyhow::bail!("文件超过大小上限 {} bytes", max_bytes);
}
Ok((bytes.to_vec(), content_type))
}
/// 从 URL/Content-Type 推断 media_type。content-type 以 image/ 开头优先;否则 URL 扩展名;兜底 image/png。
pub(crate) fn infer_media_type(url: &str, content_type: Option<&str>) -> String {
if let Some(ct) = content_type {
let ct = ct.split(';').next().unwrap_or(ct).trim().to_lowercase();
if ct.starts_with("image/") {
return ct;
}
}
let lower = url.to_lowercase();
for (ext, mt) in [
(".png", "image/png"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
] {
if lower.ends_with(ext) {
return mt.to_string();
}
}
"image/png".to_string()
}
/// 构建单个 reqwest::RequestBuilder(共享 method/url/headers/body 构建逻辑,重定向循环复用)。 /// 构建单个 reqwest::RequestBuilder(共享 method/url/headers/body 构建逻辑,重定向循环复用)。
fn build_request( fn build_request(
client: &reqwest::Client, client: &reqwest::Client,