优化: 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 {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user