优化: anthropic 协议图片 URL 预拉转 base64 + 无 Vision 模型温和丢图
This commit is contained in:
@@ -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 {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
@@ -63,8 +69,9 @@ impl TokenEstimator {
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
if let Some(b) = base64 {
|
||||
char_count += b.chars().count();
|
||||
} else if let Some(u) = url {
|
||||
char_count += u.chars().count();
|
||||
} else if url.is_some() {
|
||||
// F-260801 Phase4:url 模式按地板估(URL 长度估严重低估,见 URL_IMAGE_TOKEN_FLOOR 注)。
|
||||
char_count += URL_IMAGE_TOKEN_FLOOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,6 +636,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openai_helpers::OpenAiFunctionResp;
|
||||
|
||||
/// 辅助:构造普通文本 delta chunk 的 SSE data
|
||||
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
|
||||
|
||||
@@ -15,6 +15,9 @@ use std::sync::atomic::Ordering;
|
||||
use serde::Serialize;
|
||||
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_storage::models::AiProviderRecord;
|
||||
use df_types::augmentation::{MentionRef, MentionSpanDto};
|
||||
@@ -375,6 +378,115 @@ pub async fn ai_is_generating(
|
||||
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 响应
|
||||
///
|
||||
/// 非阻塞:立即返回 "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?;
|
||||
|
||||
// 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 真并发上线):
|
||||
// - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
|
||||
@@ -451,7 +575,24 @@ pub async fn ai_chat_send(
|
||||
// F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname}
|
||||
// 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。
|
||||
// 非空 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 {
|
||||
format!("/{}", skill_name)
|
||||
} else {
|
||||
@@ -1656,6 +1797,15 @@ pub async fn ai_chat_force_send(
|
||||
// 获取活跃提供商(只读,失败可直接返回,不占用 generating)
|
||||
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 + 追加用户消息。
|
||||
// stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
|
||||
// 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
|
||||
@@ -1721,7 +1871,24 @@ pub async fn ai_chat_force_send(
|
||||
conv.iteration_used = 0;
|
||||
conv.model_override = model_override.clone();
|
||||
// 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 {
|
||||
format!("/{}", skill_name)
|
||||
} else {
|
||||
|
||||
@@ -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 构建逻辑,重定向循环复用)。
|
||||
fn build_request(
|
||||
client: &reqwest::Client,
|
||||
|
||||
Reference in New Issue
Block a user