diff --git a/crates/df-ai/src/model_probe.rs b/crates/df-ai/src/model_probe.rs index 579b9e0..9383538 100644 --- a/crates/df-ai/src/model_probe.rs +++ b/crates/df-ai/src/model_probe.rs @@ -4,7 +4,7 @@ //! //! 多源探测,高优先源命中即返(短路): //! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable` -//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable` +//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable` //! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic` //! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default` //! @@ -26,7 +26,7 @@ use crate::model_probe_helpers::{heuristic_infer, presets}; /// /// 多源探测顺序(高优先源命中即返): /// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感) -/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感) +/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感) /// 3. 启发式推断(模型名命名模式) /// 4. 默认值兜底 /// @@ -38,14 +38,27 @@ pub fn probe(model_id: &str) -> ModelConfig { return hit; } - // 2. 预设表模糊匹配(双向子串包含,大小写不敏感) - // 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。 + // 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感) + // 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision), + // gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承 + // (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始, + // 防止 glm-4v2/glm-4vx 命中 glm-4v。 let needle = model_id.to_lowercase(); let fuzzy = presets() .iter() .filter(|m| { let cand = m.model_id.to_lowercase(); - !cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand)) + if cand.is_empty() || cand.len() > needle.len() { + return false; + } + if !needle.starts_with(&cand) { + return false; + } + // 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v) + match needle[cand.len()..].chars().next() { + None => true, // 完全相等(精确匹配已覆盖,这里兜底) + Some(c) => matches!(c, '-' | '.' | '_'), + } }) .max_by_key(|m| m.model_id.len()); @@ -157,20 +170,68 @@ mod tests { assert_eq!(m.context_window, 1_048_576); } - // ── 预设模糊匹配 ── + // ── 预设前缀匹配 ── #[test] fn probe_preset_fuzzy_match_glm4v_variant() { - // "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中 + // "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中 let m = probe("glm-4v-x"); assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); - assert_eq!(m.model_id, "glm-4v-x", "模糊命中后 model_id 应用入参名"); + assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名"); assert!( m.modalities.contains(&Modality::Vision), "应继承 glm-4v 的 vision 模态" ); } + #[test] + fn probe_preset_prefix_match_glm4v_flash() { + // 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision + let m = probe("glm-4v-flash"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert!(m.modalities.contains(&Modality::Vision)); + } + + #[test] + fn probe_preset_prefix_match_gpt4o_mini() { + // 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 - + let m = probe("gpt-4o-mini"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert_eq!(m.model_id, "gpt-4o-mini"); + } + + #[test] + fn probe_preset_prefix_match_case_insensitive() { + // 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底) + let m = probe("GLM-4V-FLASH"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert!(m.modalities.contains(&Modality::Vision)); + } + + #[test] + fn probe_preset_prefix_reject_non_prefix_variant() { + // 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。 + // 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。 + let m = probe("glm-4.6v"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert_eq!( + m.model_id, "glm-4.6v", + "前缀命中后 model_id 应用入参名" + ); + assert!( + !m.modalities.contains(&Modality::Vision), + "glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}", + m.modalities + ); + } + + #[test] + fn probe_preset_prefix_reject_no_separator() { + // 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体 + let m = probe("glm-4v2"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + } + // ── 启发式:Vision ── #[test] diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index d290bdd..57f363b 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -19,7 +19,7 @@ use tauri::State; // df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。 -use df_ai::df_ai_core::model::ModelConfig; +use df_ai::df_ai_core::model::{ModelConfig, ProbeSource}; use df_types::types::new_id; use df_storage::models::AiProviderRecord; @@ -342,9 +342,22 @@ pub async fn ai_fetch_models( // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router // 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。 // 新模型(旧池无同 model_id)用探测默认值。 + // + // 手动优先:旧配置 probe_source == UserSet(用户在表格手动标注能力)→ 保留手动能力维度, + // 不被新探测覆盖(对齐 ProbeSource::UserSet「最高优先,永不被覆盖」设计意图)。 let merged: Vec = probed .iter() .map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) { + Some(old) if old.probe_source == Some(ProbeSource::UserSet) => ModelConfig { + // 手动标注优先:保留用户能力维度 + 手动来源,仅更新路由控制字段 + weight: old.weight, + enabled: old.enabled, + label: old.label.clone(), + modalities: old.modalities.clone(), + capabilities: old.capabilities.clone(), + probe_source: Some(ProbeSource::UserSet), + ..c.clone() + }, Some(old) => ModelConfig { weight: old.weight, enabled: old.enabled,