diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index c3e7760..3473582 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -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; } } } diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 3dfc45e..e95c17c 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -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 { diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index f3b67b6..073126f 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -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, + provider: &AiProviderRecord, + model_override: Option<&str>, +) -> (Vec, 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 = 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, + provider_type: &str, +) -> Vec { + 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 { diff --git a/src-tauri/src/commands/ai/http.rs b/src-tauri/src/commands/ai/http.rs index ef62423..31eccbf 100644 --- a/src-tauri/src/commands/ai/http.rs +++ b/src-tauri/src/commands/ai/http.rs @@ -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, Option)> { + 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,