From b3e78e60619ade518445c91b89c40d6919c3d90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 16 Jun 2026 23:33:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-01=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=98=B6=E6=AE=B51+2(ModelConfig=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=A8=A1=E5=9E=8B+model=5Fprobe=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E5=99=A8+df-storage=E5=85=BC=E5=AE=B9V18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/df-ai-core/src/lib.rs | 2 + crates/df-ai-core/src/model.rs | 462 ++++++++++++++++++++++++++ crates/df-ai/presets/models.json | 172 ++++++++++ crates/df-ai/src/lib.rs | 1 + crates/df-ai/src/model_probe.rs | 434 ++++++++++++++++++++++++ crates/df-nodes/src/ai_node.rs | 1 + crates/df-storage/Cargo.toml | 2 + crates/df-storage/src/crud.rs | 121 ++++++- crates/df-storage/src/migrations.rs | 18 +- crates/df-storage/src/models.rs | 6 + crates/df-storage/src/secret.rs | 2 +- src-tauri/src/commands/ai/commands.rs | 1 + 13 files changed, 1216 insertions(+), 7 deletions(-) create mode 100644 crates/df-ai-core/src/model.rs create mode 100644 crates/df-ai/presets/models.json create mode 100644 crates/df-ai/src/model_probe.rs diff --git a/Cargo.lock b/Cargo.lock index 61eb98a..63f5abc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -864,6 +864,7 @@ name = "df-storage" version = "0.1.0" dependencies = [ "anyhow", + "df-ai-core", "df-core", "keyring", "rusqlite", diff --git a/crates/df-ai-core/src/lib.rs b/crates/df-ai-core/src/lib.rs index edf687d..a925443 100644 --- a/crates/df-ai-core/src/lib.rs +++ b/crates/df-ai-core/src/lib.rs @@ -5,6 +5,8 @@ //! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate //! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。 +pub mod model; pub mod provider; +pub use model::*; pub use provider::*; diff --git a/crates/df-ai-core/src/model.rs b/crates/df-ai-core/src/model.rs new file mode 100644 index 0000000..e1c5c33 --- /dev/null +++ b/crates/df-ai-core/src/model.rs @@ -0,0 +1,462 @@ +//! 模型能力数据模型 — F-01 阶段1 +//! +//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。 +//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。 +//! +//! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §2 + +use serde::{Deserialize, Serialize}; + +// ============================================================ +// 4 维度枚举 +// ============================================================ + +/// 维度 1 — 模态(模型能接收什么输入) +/// +/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。 +/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Modality { + Text, + Vision, + /// 未来扩展:音频输入(预留,Phase 2 多模态) + Audio, + /// 未来扩展:视频输入(预留) + Video, +} + +/// 维度 2 — 能力(模型能做什么) +/// +/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。 +/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// function calling / tool use + ToolUse, + /// 向量嵌入(知识库) + Embedding, + /// 代码生成强项 + CodeGen, +} + +/// 维度 3 — 价格分级(成本) +/// +/// 设计文档 §2.2:Free/Low/Medium/High。序列化为 snake_case。 +/// 路由器按此做成本上限过滤(§6.1 `cost_tier <= max_cost`)。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum CostTier { + Free, + Low, + Medium, + High, +} + +/// 维度 4 — 聪明程度(智力分级) +/// +/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。 +/// 派生 Ord:Lite < Standard < Plus < Ultra,供路由器 `intelligence >= min_intelligence` 比较(§6.1)。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum IntelligenceTier { + /// flash/mini/lite/nano — 快但简单任务 + Lite, + /// air/standard — 日常够用 + Standard, + /// plus/pro/max — 复杂推理 + Plus, + /// 最强模型 — 兜底用 + Ultra, +} + +/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2) +/// +/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProbeSource { + /// 用户手动设定(最高优先) + UserSet, + /// 内置预设表精确/模糊匹配 + PresetTable, + /// 模型名启发式推断 + Heuristic, + /// 默认值兜底 + Default, +} + +// ============================================================ +// 核心结构 ModelConfig +// ============================================================ + +/// 单个模型的完整配置(4 维度 + 路由控制)。 +/// +/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelConfig { + // ── 基础 ── + /// 模型名(如 "glm-4v-flash") + pub model_id: String, + /// 启用/禁用(false = 配了但不参与路由,相当于"备档") + #[serde(default = "default_true")] + pub enabled: bool, + /// 用户自定义别名(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + + // ── 维度 1:模态(能接收什么输入)── + #[serde(default)] + pub modalities: Vec, + + // ── 维度 2:能力(能做什么)── + #[serde(default)] + pub capabilities: Vec, + + // ── 维度 3:价格(成本分级)── + #[serde(default)] + pub cost_tier: CostTier, + + // ── 维度 4:聪明程度(智力分级)── + #[serde(default)] + pub intelligence: IntelligenceTier, + + // ── 路由控制 ── + /// 权重(0-100,同能力候选中优先选权重高的) + #[serde(default = "default_weight")] + pub weight: u32, + /// 上下文窗口大小(tokens) + #[serde(default = "default_context_window")] + pub context_window: usize, + + // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)── + #[serde(default, skip_serializing_if = "Option::is_none")] + pub probe_source: Option, +} + +fn default_true() -> bool { + true +} + +fn default_weight() -> u32 { + 50 +} + +fn default_context_window() -> usize { + 8192 +} + +impl ModelConfig { + /// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。 + /// + /// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。 + /// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] / + /// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。 + /// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正) + pub fn with_defaults(model_id: impl Into) -> Self { + Self { + model_id: model_id.into(), + enabled: true, + label: None, + modalities: vec![Modality::Text], + capabilities: vec![Capability::ToolUse], + cost_tier: CostTier::Medium, + intelligence: IntelligenceTier::Standard, + weight: default_weight(), + context_window: default_context_window(), + probe_source: None, + } + } +} + +/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl) +impl Default for Modality { + fn default() -> Self { + Modality::Text + } +} + +impl Default for CostTier { + fn default() -> Self { + CostTier::Medium + } +} + +impl Default for IntelligenceTier { + fn default() -> Self { + IntelligenceTier::Standard + } +} + +// ============================================================ +// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组 +// ============================================================ + +/// 反序列化 `models` JSON 字段,兼容两种格式: +/// +/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults` +/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组) +/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec +/// +/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。 +pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let opt: Option = Option::deserialize(deserializer)?; + let Some(value) = opt else { + return Ok(Vec::new()); + }; + + match value { + // null / 空字符串 / 空数组 → 空 + serde_json::Value::Null => Ok(Vec::new()), + serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()), + serde_json::Value::String(s) => { + // 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串) + match serde_json::from_str::(s.trim()) { + Ok(inner) => parse_models_value(inner).map_err(D::Error::custom), + Err(_) => Ok(Vec::new()), + } + } + serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom), + other => parse_models_value(other).map_err(D::Error::custom), + } +} + +fn parse_models_value(value: serde_json::Value) -> Result, serde_json::Error> { + match value { + serde_json::Value::Array(arr) => { + // 老格式:全为字符串 → 每个转默认配置 + if arr.iter().all(|v| v.is_string()) { + return Ok(arr + .into_iter() + .filter_map(|v| v.as_str().map(ModelConfig::with_defaults)) + .collect()); + } + // 新格式:ModelConfig 对象数组 + serde_json::from_value::>(serde_json::Value::Array(arr)) + } + serde_json::Value::Null => Ok(Vec::new()), + _ => Ok(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── 枚举 serde 映射 ── + + #[test] + fn modality_serde_snake_case() { + let cases = [ + (Modality::Text, "\"text\""), + (Modality::Vision, "\"vision\""), + (Modality::Audio, "\"audio\""), + (Modality::Video, "\"video\""), + ]; + for (variant, expected) in cases { + let s = serde_json::to_string(&variant).unwrap(); + assert_eq!(s, expected, "serialize {variant:?}"); + let back: Modality = serde_json::from_str(expected).unwrap(); + assert_eq!(back, variant, "deserialize {variant:?}"); + } + } + + #[test] + fn capability_serde_snake_case() { + let cases = [ + (Capability::ToolUse, "\"tool_use\""), + (Capability::Embedding, "\"embedding\""), + (Capability::CodeGen, "\"code_gen\""), + ]; + for (variant, expected) in cases { + let s = serde_json::to_string(&variant).unwrap(); + assert_eq!(s, expected, "serialize {variant:?}"); + let back: Capability = serde_json::from_str(expected).unwrap(); + assert_eq!(back, variant, "deserialize {variant:?}"); + } + } + + #[test] + fn cost_tier_serde_and_ordering() { + let cases = [ + (CostTier::Free, "\"free\""), + (CostTier::Low, "\"low\""), + (CostTier::Medium, "\"medium\""), + (CostTier::High, "\"high\""), + ]; + for (variant, expected) in cases { + assert_eq!(serde_json::to_string(&variant).unwrap(), expected); + assert_eq!(serde_json::from_str::(expected).unwrap(), variant); + } + // Ord:Free < Low < Medium < High(路由器 max_cost 比较) + assert!(CostTier::Free < CostTier::Low); + assert!(CostTier::Low < CostTier::Medium); + assert!(CostTier::Medium < CostTier::High); + } + + #[test] + fn intelligence_tier_serde_and_ordering() { + let cases = [ + (IntelligenceTier::Lite, "\"lite\""), + (IntelligenceTier::Standard, "\"standard\""), + (IntelligenceTier::Plus, "\"plus\""), + (IntelligenceTier::Ultra, "\"ultra\""), + ]; + for (variant, expected) in cases { + assert_eq!(serde_json::to_string(&variant).unwrap(), expected); + assert_eq!(serde_json::from_str::(expected).unwrap(), variant); + } + // Ord:Lite < Standard < Plus < Ultra(路由器 min_intelligence 比较) + assert!(IntelligenceTier::Lite < IntelligenceTier::Standard); + assert!(IntelligenceTier::Standard < IntelligenceTier::Plus); + assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra); + } + + // ── ModelConfig serde roundtrip ── + + #[test] + fn model_config_full_roundtrip() { + let mc = ModelConfig { + model_id: "glm-4v".into(), + enabled: true, + label: Some("我的视觉".into()), + modalities: vec![Modality::Text, Modality::Vision], + capabilities: vec![Capability::ToolUse], + cost_tier: CostTier::Medium, + intelligence: IntelligenceTier::Plus, + weight: 70, + context_window: 128_000, + probe_source: Some(ProbeSource::PresetTable), + }; + let json = serde_json::to_string(&mc).unwrap(); + let back: ModelConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back, mc); + } + + #[test] + fn model_config_skip_none_label_and_probe() { + let mc = ModelConfig::with_defaults("glm-4-flash"); + let json = serde_json::to_string(&mc).unwrap(); + // label / probe_source 为 None 应被跳过 + assert!(!json.contains("label"), "json={json}"); + assert!(!json.contains("probe_source"), "json={json}"); + // 反序列化回来相等(skip_serializing 不影响 deserialize) + let back: ModelConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back, mc); + } + + #[test] + fn model_config_with_defaults_values() { + let mc = ModelConfig::with_defaults("test-model"); + assert_eq!(mc.model_id, "test-model"); + assert!(mc.enabled); + assert_eq!(mc.modalities, vec![Modality::Text]); + assert_eq!(mc.capabilities, vec![Capability::ToolUse]); + assert_eq!(mc.cost_tier, CostTier::Medium); + assert_eq!(mc.intelligence, IntelligenceTier::Standard); + assert_eq!(mc.weight, 50); + assert_eq!(mc.context_window, 8192); + assert_eq!(mc.label, None); + assert_eq!(mc.probe_source, None); + } + + #[test] + fn model_config_defaults_applied_on_missing_fields() { + // 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充 + let json = r#"{"model_id":"minimal"}"#; + let mc: ModelConfig = serde_json::from_str(json).unwrap(); + assert_eq!(mc.model_id, "minimal"); + assert!(mc.enabled, "enabled 默认 true"); + assert_eq!(mc.weight, 50, "weight 默认 50"); + assert_eq!(mc.context_window, 8192, "context_window 默认 8192"); + assert!(mc.modalities.is_empty(), "Vec 字段 default 空"); + assert!(mc.capabilities.is_empty()); + assert_eq!(mc.cost_tier, CostTier::Medium); + assert_eq!(mc.intelligence, IntelligenceTier::Standard); + } + + // ── deserialize_model_configs 向后兼容 ── + + #[derive(Debug, Deserialize)] + struct Wrap { + #[serde(default, deserialize_with = "deserialize_model_configs")] + models: Vec, + } + + #[test] + fn deserialize_old_format_string_array() { + // 老格式:["glm-4-flash", "glm-4v"] + let json = r#"{"models":["glm-4-flash","glm-4v"]}"#; + let w: Wrap = serde_json::from_str(json).unwrap(); + assert_eq!(w.models.len(), 2); + assert_eq!(w.models[0].model_id, "glm-4-flash"); + assert!(w.models[0].enabled); + assert_eq!(w.models[0].modalities, vec![Modality::Text]); + assert_eq!(w.models[1].model_id, "glm-4v"); + } + + #[test] + fn deserialize_new_format_object_array() { + let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#; + let w: Wrap = serde_json::from_str(json).unwrap(); + assert_eq!(w.models.len(), 1); + let m = &w.models[0]; + assert_eq!(m.model_id, "glm-4v"); + assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]); + assert_eq!(m.intelligence, IntelligenceTier::Plus); + assert_eq!(m.context_window, 128_000); + } + + #[test] + fn deserialize_null_or_missing_yields_empty() { + // null + let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap(); + assert!(w.models.is_empty()); + // 缺失字段 + let w: Wrap = serde_json::from_str(r#"{}"#).unwrap(); + assert!(w.models.is_empty()); + // 空字符串(DB TEXT 列可能存空串) + let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap(); + assert!(w.models.is_empty()); + // 空数组 + let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap(); + assert!(w.models.is_empty()); + } + + #[test] + fn deserialize_json_string_text_column() { + // DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"` + let json = r#"{"models":"[\"glm-4-flash\"]"}"#; + let w: Wrap = serde_json::from_str(json).unwrap(); + assert_eq!(w.models.len(), 1); + assert_eq!(w.models[0].model_id, "glm-4-flash"); + } + + #[test] + fn roundtrip_via_text_column() { + // 模拟 DB 落库:Vec → serde_json 序列化 → 存 TEXT 列 + // 读出来:TEXT 列值 → 反序列化回 Vec + let original = vec![ + ModelConfig::with_defaults("glm-4-flash"), + ModelConfig { + model_id: "glm-4v".into(), + modalities: vec![Modality::Text, Modality::Vision], + intelligence: IntelligenceTier::Plus, + weight: 70, + context_window: 128_000, + ..ModelConfig::with_defaults("glm-4v") + }, + ]; + let text = serde_json::to_string(&original).unwrap(); + let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\"")); + let w: Wrap = serde_json::from_str(&wrapped).unwrap(); + assert_eq!(w.models.len(), 2); + assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus); + } +} diff --git a/crates/df-ai/presets/models.json b/crates/df-ai/presets/models.json new file mode 100644 index 0000000..a0bb817 --- /dev/null +++ b/crates/df-ai/presets/models.json @@ -0,0 +1,172 @@ +[ + { + "model_id": "glm-4", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "medium", + "intelligence": "plus", + "weight": 60, + "context_window": 128000 + }, + { + "model_id": "glm-4-air", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "standard", + "weight": 80, + "context_window": 128000 + }, + { + "model_id": "glm-4-flash", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "lite", + "weight": 90, + "context_window": 128000 + }, + { + "model_id": "glm-4-plus", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "high", + "intelligence": "plus", + "weight": 50, + "context_window": 128000 + }, + { + "model_id": "glm-4v", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "medium", + "intelligence": "plus", + "weight": 70, + "context_window": 128000 + }, + { + "model_id": "glm-4v-flash", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "lite", + "weight": 85, + "context_window": 128000 + }, + { + "model_id": "gpt-4o", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "high", + "intelligence": "ultra", + "weight": 50, + "context_window": 128000 + }, + { + "model_id": "gpt-4o-mini", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "standard", + "weight": 80, + "context_window": 128000 + }, + { + "model_id": "claude-3-5-sonnet-20241022", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "high", + "intelligence": "ultra", + "weight": 50, + "context_window": 200000 + }, + { + "model_id": "claude-3-opus-20240229", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "high", + "intelligence": "ultra", + "weight": 45, + "context_window": 200000 + }, + { + "model_id": "claude-3-haiku-20240307", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "standard", + "weight": 80, + "context_window": 200000 + }, + { + "model_id": "gemini-1.5-pro", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "high", + "intelligence": "plus", + "weight": 55, + "context_window": 1000000 + }, + { + "model_id": "gemini-1.5-flash", + "enabled": true, + "modalities": ["text", "vision"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "lite", + "weight": 85, + "context_window": 1000000 + }, + { + "model_id": "deepseek-chat", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "low", + "intelligence": "standard", + "weight": 75, + "context_window": 64000 + }, + { + "model_id": "deepseek-coder", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use", "code_gen"], + "cost_tier": "low", + "intelligence": "plus", + "weight": 75, + "context_window": 64000 + }, + { + "model_id": "qwen-2.5", + "enabled": true, + "modalities": ["text"], + "capabilities": ["tool_use"], + "cost_tier": "medium", + "intelligence": "standard", + "weight": 65, + "context_window": 128000 + }, + { + "model_id": "embedding-3", + "enabled": true, + "modalities": [], + "capabilities": ["embedding"], + "cost_tier": "low", + "intelligence": "lite", + "weight": 50, + "context_window": 8192 + } +] diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 86492da..631fc6a 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_tools; pub mod anthropic_compat; pub mod context; pub mod coordinator; +pub mod model_probe; pub mod openai_compat; pub mod provider; // CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, diff --git a/crates/df-ai/src/model_probe.rs b/crates/df-ai/src/model_probe.rs new file mode 100644 index 0000000..6954891 --- /dev/null +++ b/crates/df-ai/src/model_probe.rs @@ -0,0 +1,434 @@ +//! 模型探测器 — F-01 阶段2 +//! +//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。 +//! +//! 多源探测,高优先源命中即返(短路): +//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable` +//! 2. 内置预设表模糊匹配(子串包含) → `ProbeSource::PresetTable` +//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic` +//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default` +//! +//! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §4 +//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格 +//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。 + +use std::sync::OnceLock; + +use df_ai_core::model::{ + Capability, CostTier, IntelligenceTier, Modality, ModelConfig, ProbeSource, +}; + +// ──────────────────────────────────────────────────────────── +// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖 +// ──────────────────────────────────────────────────────────── + +/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。 +const PRESETS_JSON: &str = include_str!("../presets/models.json"); + +/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。 +fn presets() -> &'static [ModelConfig] { + static PRESETS: OnceLock> = OnceLock::new(); + PRESETS.get_or_init(|| { + // include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。 + serde_json::from_str::>(PRESETS_JSON) + .expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射") + }) +} + +// ──────────────────────────────────────────────────────────── +// 公共入口 +// ──────────────────────────────────────────────────────────── + +/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。 +/// +/// 多源探测顺序(高优先源命中即返): +/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感) +/// 2. 预设表模糊匹配(`model_id` 双向子串包含,大小写不敏感) +/// 3. 启发式推断(模型名命名模式) +/// 4. 默认值兜底 +/// +/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。 +pub fn probe(model_id: &str) -> ModelConfig { + // 1. 预设表精确匹配 + if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() { + hit.probe_source = Some(ProbeSource::PresetTable); + return hit; + } + + // 2. 预设表模糊匹配(双向子串包含,大小写不敏感) + // 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。 + 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)) + }) + .max_by_key(|m| m.model_id.len()); + + if let Some(mut hit) = fuzzy.cloned() { + // 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名) + hit.model_id = model_id.to_string(); + hit.probe_source = Some(ProbeSource::PresetTable); + return hit; + } + + // 3. 启发式推断 + let mut heuristic = heuristic_infer(model_id); + heuristic.probe_source = Some(ProbeSource::Heuristic); + heuristic +} + +// ──────────────────────────────────────────────────────────── +// 启发式推断(模型名命名模式 → 4 维度) +// ──────────────────────────────────────────────────────────── + +/// 模型名启发式推断。命名是行业惯例(flash/mini/v/embed/code),可信度 Medium。 +/// +/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐): +/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text) +/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision +/// - `flash`/`mini`/`lite`/`nano`/`air` → IntelligenceTier::Lite(air 归 Lite,对齐设计 §4.4 air→Standard 但与 Lite 规则并存时取 Lite; +/// 此处 air 单独命中归 Standard — 见 issues) +/// - `plus`/`pro`/`max`/`ultra` → IntelligenceTier::Plus/Ultra +/// - `code`/`coder` → capabilities 加 CodeGen +/// - `4o`/`4.5`/`5`(高价旗舰标识) → CostTier::High +/// - 默认 → Standard / Medium / [Text] / [ToolUse] +fn heuristic_infer(model_id: &str) -> ModelConfig { + let name = model_id.to_lowercase(); + let mut modalities: Vec = Vec::new(); + let mut capabilities: Vec = Vec::new(); + let mut cost_tier = CostTier::Medium; + let mut intelligence = IntelligenceTier::Standard; + + // —— Embedding 优先判定(改变模态集合,且通常独占) —— + // 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首 + let is_embedding = name.contains("embed") + || name.contains("embedding") + || name.starts_with("e-") + || name.starts_with("embedding-") + || name.starts_with("text-embedding"); + + if is_embedding { + capabilities.push(Capability::Embedding); + // embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[]) + return ModelConfig { + model_id: model_id.to_string(), + enabled: true, + label: None, + modalities, + capabilities, + cost_tier: CostTier::Low, // embedding 普遍低价 + intelligence: IntelligenceTier::Lite, // embedding 无智力概念,归 Lite + weight: 50, + context_window: 8192, + probe_source: None, + }; + } + + // —— 对话模型默认 Text 模态 —— + modalities.push(Modality::Text); + + // —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" —— + // 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中: + // 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision" + if has_vision_token(&name) { + modalities.push(Modality::Vision); + } + + // —— CodeGen:含 code / coder —— + if name.contains("code") || name.contains("coder") { + capabilities.push(Capability::CodeGen); + } + + // —— 对话模型默认 ToolUse(主流支持 function calling) —— + capabilities.push(Capability::ToolUse); + + // —— 智力分级 —— + if has_lite_token(&name) { + intelligence = IntelligenceTier::Lite; + } else if has_ultra_token(&name) { + intelligence = IntelligenceTier::Ultra; + } else if has_plus_token(&name) { + intelligence = IntelligenceTier::Plus; + } else if has_standard_token(&name) { + intelligence = IntelligenceTier::Standard; + } + + // —— 价格分级 —— + if has_high_cost_token(&name) { + cost_tier = CostTier::High; + } else if has_lite_token(&name) { + cost_tier = CostTier::Low; + } + + ModelConfig { + model_id: model_id.to_string(), + enabled: true, + label: None, + modalities, + capabilities, + cost_tier, + intelligence, + weight: 50, + context_window: 8192, + probe_source: None, + } +} + +// —— 词素判定助手(命名约定式启发式,集中维护便于增删) —— + +/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。 +/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。 +fn has_vision_token(name: &str) -> bool { + name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v") +} + +/// Lite 智力词素:flash / mini / lite / nano / air(air 对齐设计 §4.4 模糊规则 Lite)。 +/// 注:设计 §4.4 文本规则列了 air,但 §4.4 精确例 glm-4-air→Standard。 +/// 本启发式取模糊规则(air→Lite)以保持一致 — 见 issues。 +fn has_lite_token(name: &str) -> bool { + let toks = ["flash", "mini", "lite", "nano", "air", "haiku", "small"]; + toks.iter().any(|t| name.contains(t)) +} + +/// Plus 智力词素:plus / pro / max / sonnet。 +fn has_plus_token(name: &str) -> bool { + let toks = ["plus", "pro", "max", "sonnet"]; + toks.iter().any(|t| name.contains(t)) +} + +/// Ultra 智力词素:ultra / opus / largest。 +fn has_ultra_token(name: &str) -> bool { + let toks = ["ultra", "opus", "largest"]; + toks.iter().any(|t| name.contains(t)) +} + +/// Standard 智力词素:standard / chat(对话主力模型默认归 Standard)。 +fn has_standard_token(name: &str) -> bool { + let toks = ["standard", "chat", "haiku"]; + toks.iter().any(|t| name.contains(t)) +} + +/// 高价旗舰词素:4o / 4.5 / 5 / opus / o1 / o3(OpenAI/Anthropic 旗舰命名)。 +fn has_high_cost_token(name: &str) -> bool { + let toks = ["4o", "4.5", "opus", "o1", "o3"]; + // "5" 单字符误伤大(如 "5b"),仅匹配词边界 -5 / 5- 或以 5 结尾 + toks.iter().any(|t| name.contains(t)) + || name.ends_with("-5") + || name.contains("-5-") +} + +// ──────────────────────────────────────────────────────────── +// 测试 +// ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource}; + + // ── 预设表加载 ── + + #[test] + fn presets_loaded_and_nonempty() { + let p = presets(); + assert!(!p.is_empty(), "预设表应非空"); + // 确保每个预设都有 model_id + assert!(p.iter().all(|m| !m.model_id.is_empty())); + } + + #[test] + fn presets_contain_expected_models() { + let p = presets(); + let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect(); + assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}"); + assert!(ids.contains(&"glm-4-flash")); + assert!(ids.contains(&"glm-4v")); + assert!(ids.contains(&"gpt-4o")); + assert!(ids.contains(&"claude-3-5-sonnet-20241022")); + } + + // ── 预设精确匹配 ── + + #[test] + fn probe_preset_exact_match_glm4() { + let m = probe("glm-4"); + assert_eq!(m.model_id, "glm-4"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert_eq!(m.modalities, vec![Modality::Text]); + assert_eq!(m.intelligence, IntelligenceTier::Plus); + } + + #[test] + fn probe_preset_exact_match_glm4v_has_vision() { + let m = probe("glm-4v"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert!(m.modalities.contains(&Modality::Vision)); + } + + #[test] + fn probe_preset_exact_match_deepseek_coder_has_codegen() { + let m = probe("deepseek-coder"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert!(m.capabilities.contains(&Capability::CodeGen)); + } + + #[test] + fn probe_preset_exact_match_embedding_no_text_modality() { + let m = probe("embedding-3"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert!(m.capabilities.contains(&Capability::Embedding)); + assert!( + !m.modalities.contains(&Modality::Text), + "embedding 模型不应有 Text 模态" + ); + } + + // ── 预设模糊匹配 ── + + #[test] + fn probe_preset_fuzzy_match_glm4v_variant() { + // "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中 + 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!( + m.modalities.contains(&Modality::Vision), + "应继承 glm-4v 的 vision 模态" + ); + } + + // ── 启发式:Vision ── + + #[test] + fn heuristic_vision_from_v_suffix() { + // glm-4v 不走启发式(精确命中预设);用未知名验证启发式 + let m = probe("custom-model-v"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert!( + m.modalities.contains(&Modality::Vision), + "v 后缀应推断 Vision" + ); + } + + #[test] + fn heuristic_vision_from_vl_token() { + let m = probe("qwen-vl-unknown"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert!(m.modalities.contains(&Modality::Vision)); + } + + #[test] + fn heuristic_vision_from_vision_word() { + let m = probe("some-vision-7b"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert!(m.modalities.contains(&Modality::Vision)); + } + + // ── 启发式:Lite 智力 ── + + #[test] + fn heuristic_lite_from_flash() { + let m = probe("unknown-flash"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert_eq!(m.intelligence, IntelligenceTier::Lite); + assert_eq!(m.cost_tier, CostTier::Low); + } + + #[test] + fn heuristic_lite_from_mini() { + let m = probe("test-mini-model"); + assert_eq!(m.intelligence, IntelligenceTier::Lite); + } + + // ── 启发式:CodeGen ── + + #[test] + fn heuristic_codegen_from_code() { + let m = probe("acme-code-7b"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert!(m.capabilities.contains(&Capability::CodeGen)); + // 对话模型仍应保留 ToolUse + assert!(m.capabilities.contains(&Capability::ToolUse)); + } + + #[test] + fn heuristic_codegen_from_coder() { + let m = probe("acme-coder"); + assert!(m.capabilities.contains(&Capability::CodeGen)); + } + + // ── 启发式:Embedding(特殊路径:无 Text 模态) ── + + #[test] + fn heuristic_embedding_no_text_modality() { + let m = probe("acme-embedding-v3"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert!(m.capabilities.contains(&Capability::Embedding)); + assert!(!m.modalities.contains(&Modality::Text)); + assert!(!m.capabilities.contains(&Capability::ToolUse)); + } + + #[test] + fn heuristic_embedding_from_text_embedding_prefix() { + let m = probe("text-embedding-3-large"); + assert!(m.capabilities.contains(&Capability::Embedding)); + assert!(m.modalities.is_empty()); + } + + // ── 启发式:Plus/Ultra 智力 + High 价格 ── + + #[test] + fn heuristic_plus_from_pro() { + let m = probe("acme-pro"); + assert_eq!(m.intelligence, IntelligenceTier::Plus); + } + + #[test] + fn heuristic_ultra_from_opus() { + let m = probe("acme-opus"); + assert_eq!(m.intelligence, IntelligenceTier::Ultra); + } + + #[test] + fn heuristic_high_cost_from_4o() { + let m = probe("acme-4o"); + assert_eq!(m.cost_tier, CostTier::High); + } + + // ── 启发式:默认(Standard/Medium/Text/ToolUse) ── + + #[test] + fn heuristic_default_for_unknown_name() { + let m = probe("acme-unknown-model"); + assert_eq!(m.probe_source, Some(ProbeSource::Heuristic)); + assert_eq!(m.modalities, vec![Modality::Text]); + assert_eq!(m.capabilities, vec![Capability::ToolUse]); + assert_eq!(m.intelligence, IntelligenceTier::Standard); + assert_eq!(m.cost_tier, CostTier::Medium); + } + + // ── 优先级链:预设精确 > 启发式 ── + + #[test] + fn probe_preset_beats_heuristic() { + // "glm-4-flash" 在预设表 = Lite;若走启发式也会是 Lite,但 source 应为 PresetTable + let m = probe("glm-4-flash"); + assert_eq!(m.probe_source, Some(ProbeSource::PresetTable)); + assert_eq!(m.intelligence, IntelligenceTier::Lite); + } + + #[test] + fn probe_priority_chain_returns_config() { + // 任何名字都应返回完整 ModelConfig,不 panic + for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] { + let m = probe(name); + assert_eq!(m.model_id, name); + assert!( + m.probe_source.is_some(), + "probe_source 应被填充: {name}" + ); + } + } +} diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 17d28f2..4e471e8 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -689,6 +689,7 @@ mod tests { base_url: "https://api.example.com".to_string(), default_model: "glm-4-flash".to_string(), models: None, + model_configs: Vec::new(), is_default, config: None, created_at: "0".to_string(), diff --git a/crates/df-storage/Cargo.toml b/crates/df-storage/Cargo.toml index d6021a1..8c37953 100644 --- a/crates/df-storage/Cargo.toml +++ b/crates/df-storage/Cargo.toml @@ -5,6 +5,8 @@ edition = "2021" [dependencies] df-core = { path = "../df-core" } +# F-01 阶段1:ModelConfig/deserialize_model_configs 向后兼容反序列化(老字符串数组/新对象数组/null/空) +df-ai-core = { path = "../df-ai-core" } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/df-storage/src/crud.rs b/crates/df-storage/src/crud.rs index 1c42b05..1d5d69b 100644 --- a/crates/df-storage/src/crud.rs +++ b/crates/df-storage/src/crud.rs @@ -1032,6 +1032,28 @@ impl_repo!( // ============================================================ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result { + // model_configs:DB TEXT 列存 JSON 字符串。读 Option 兼容老库 NULL, + // 再经 deserialize_model_configs 解析(老字符串数组/新对象数组/空 → Vec)。 + // 解析失败不致命:降级为空 Vec(防单行坏数据拖垮 list_all)。 + let model_configs: Vec = { + let raw: Option = row.get("model_configs").ok(); + match raw { + None => Vec::new(), + Some(s) => serde_json::from_str::(&format!( + r#"{{"v":{}}}"#, + if s.trim().is_empty() { + "null".to_string() + } else if s.trim_start().starts_with('[') || s.trim_start().starts_with('{') { + s + } else { + // 非 JSON 字面文本(理论不会出现)→ 包装为 JSON 字符串让 deserialize 兜底 + serde_json::to_string(&s).unwrap_or_else(|_| "null".into()) + } + )) + .map(|w| w.v) + .unwrap_or_default(), + } + }; Ok(AiProviderRecord { id: row.get("id")?, name: row.get("name")?, @@ -1040,6 +1062,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result("is_default")? != 0, config: row.get("config")?, created_at: row.get("created_at")?, @@ -1047,6 +1070,14 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result } 把任意 JSON 值送进 deserialize_model_configs。 +#[derive(serde::Deserialize)] +struct ModelConfigsWrap { + #[serde(default, deserialize_with = "df_ai_core::model::deserialize_model_configs")] + v: Vec, +} + fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result { Ok(AiConversationRecord { id: row.get("id")?, @@ -1118,22 +1149,25 @@ impl_repo!( from_row => |row| ai_provider_from_row(row), insert => |conn, rec| { let is_default = if rec.is_default { 1i32 } else { 0i32 }; + // model_configs:Vec → JSON 字符串落 TEXT 列 + let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into()); conn.execute( - "INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, is_default, config, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, model_configs, is_default, config, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url, - rec.default_model, rec.models, is_default, rec.config, rec.created_at, rec.updated_at + rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.created_at, rec.updated_at ], ) }, update => |conn, rec| { let is_default = if rec.is_default { 1i32 } else { 0i32 }; + let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into()); conn.execute( - "UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, is_default = ?7, config = ?8, updated_at = ?9 WHERE id = ?10", + "UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, model_configs = ?7, is_default = ?8, config = ?9, updated_at = ?10 WHERE id = ?11", params![ rec.name, rec.provider_type, rec.api_key, rec.base_url, - rec.default_model, rec.models, is_default, rec.config, rec.updated_at, rec.id + rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.updated_at, rec.id ], ) } @@ -1693,6 +1727,83 @@ mod tests { // ---------- COLS 漂移防护(CR-260615-03) ---------- + /// model_configs DB roundtrip + 老库空兼容(F-01 阶段1) + #[tokio::test] + async fn ai_provider_model_configs_roundtrip_and_old_db_compat() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiProviderRepo::new(&db); + use crate::models::AiProviderRecord; + use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig}; + + // 新格式:带多模型 + 多维度配置 + let configs = vec![ + ModelConfig::with_defaults("glm-4-flash"), + ModelConfig { + model_id: "glm-4v".into(), + modalities: vec![Modality::Text, Modality::Vision], + capabilities: vec![Capability::ToolUse], + intelligence: IntelligenceTier::Plus, + weight: 70, + context_window: 128_000, + ..ModelConfig::with_defaults("glm-4v") + }, + ]; + let rec = AiProviderRecord { + id: "p1".into(), + name: "测试".into(), + provider_type: "openai_compat".into(), + api_key: String::new(), + base_url: "https://x".into(), + default_model: "glm-4-flash".into(), + models: None, + model_configs: configs.clone(), + is_default: false, + config: None, + created_at: "0".into(), + updated_at: "0".into(), + }; + repo.insert(rec).await.expect("insert"); + + let got = repo.get_by_id("p1").await.expect("get").expect("row exists"); + assert_eq!(got.model_configs.len(), 2); + assert_eq!(got.model_configs[0].model_id, "glm-4-flash"); + assert_eq!(got.model_configs[1].model_id, "glm-4v"); + assert_eq!(got.model_configs[1].intelligence, IntelligenceTier::Plus); + assert_eq!(got.model_configs[1].context_window, 128_000); + + // 老库空兼容:直接写 model_configs=NULL 的行(模拟 V18 之前的老库行) + // 然后 from_row 应得空 Vec + { + let conn = db.conn(); + let g = conn.lock().await; + g.execute( + "INSERT OR REPLACE INTO ai_providers \ + (id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \ + VALUES ('old','','openai_compat','','','','{}',NULL,0,NULL,'0','0')", + [], + ) + .expect("raw insert old row"); + } + let old = repo.get_by_id("old").await.expect("get").expect("old row"); + assert!(old.model_configs.is_empty(), "NULL 列应得空 Vec"); + + // 老格式字符串数组 JSON(向后兼容 deserialize_model_configs) + { + let conn = db.conn(); + let g = conn.lock().await; + g.execute( + "INSERT OR REPLACE INTO ai_providers \ + (id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \ + VALUES ('legacy','','openai_compat','','','','{}','[\"glm-4-flash\",\"glm-4v\"]',0,NULL,'0','0')", + [], + ) + .expect("raw insert legacy row"); + } + let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row"); + assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig"); + assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash"); + } + /// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。 /// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。 #[test] diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 2f8d57c..2b61b88 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> { // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 - let steps: [(i32, fn(&Connection) -> Result<()>); 17] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 18] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -52,6 +52,7 @@ pub fn run(conn: &Connection) -> Result<()> { (15, migrate_v15), (16, migrate_v16), (17, migrate_v17), + (18, migrate_v18), ]; for (version, migrate_fn) in steps { @@ -312,6 +313,21 @@ fn migrate_v17(conn: &Connection) -> Result<()> { Ok(()) } +/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1) +/// +/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容: +/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。 +/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 模式),对新库/老库均安全。 +fn migrate_v18(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ai_providers", "model_configs") { + conn.execute("ALTER TABLE ai_providers ADD COLUMN model_configs TEXT", [])?; + tracing::info!("v18: 补建 ai_providers.model_configs 列(模型能力配置)"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [18])?; + tracing::info!("迁移 v18 完成"); + Ok(()) +} + /// V1 建表 SQL const V1_SQL: &str = " -- 想法表 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index ae08e4c..43feb23 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -1,5 +1,6 @@ //! 数据模型定义 — 与数据库表对应的 Rust 结构体 +use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use serde::{Deserialize, Serialize}; // ============================================================ @@ -152,6 +153,11 @@ pub struct AiProviderRecord { pub base_url: String, pub default_model: String, pub models: Option, // JSON array of model names + /// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。 + /// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。 + /// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。 + #[serde(default, deserialize_with = "deserialize_model_configs")] + pub model_configs: Vec, pub is_default: bool, pub config: Option, // JSON extra config pub created_at: String, diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs index 27eaca0..85967ec 100644 --- a/crates/df-storage/src/secret.rs +++ b/crates/df-storage/src/secret.rs @@ -209,7 +209,7 @@ mod tests { let rec = AiProviderRecord { id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(), api_key: "sk-db-fallback".into(), base_url: "https://x".into(), - default_model: "m".into(), models: None, is_default: false, + default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false, config: None, created_at: "0".into(), updated_at: "0".into(), }; assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback"); diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index b2e9a71..ce68be6 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -761,6 +761,7 @@ pub async fn ai_save_provider( base_url, default_model, models: None, + model_configs: Vec::new(), is_default, config: None, created_at,