diff --git a/src-tauri/src/commands/ai/commands/mod.rs b/src-tauri/src/commands/ai/commands/mod.rs index 3ed75f0..507f12e 100644 --- a/src-tauri/src/commands/ai/commands/mod.rs +++ b/src-tauri/src/commands/ai/commands/mod.rs @@ -4,11 +4,12 @@ //! - [`chat`] — chat 域 13 个 IPC(发送/重新生成/编辑/强制发送/停止/审批/上下文分段与压缩/循环控制) //! + chat 专用 helper(`finalize_pending_placeholders`)+ `PendingToolCallInfo` //! - [`conversation`] — conversation 域 8 个 IPC(创建/列出/切换/删除/重命名/归档/置顶/导出) -//! - 本文件(`mod.rs`)保留其余域 IPC:provider/skills/config + `mask_api_key` helper -//! (后续批按域继续拆分)。 +//! - [`provider`] — provider 域 7 个 IPC(提供商 CRUD/池配置/模型拉取探测)+ provider 专用 helper +//! (`mask_api_key` api_key 脱敏、`normalize_provider_type_for_fetch` 类型归一) +//! - 本文件(`mod.rs`)保留其余域 IPC:skills/config(后续批按域继续拆分)。 //! -//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation}::*` 把各域 IPC 拉到 -//! `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*` +//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider}::*` 把各域 IPC +//! 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*` //! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 // 各域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*) @@ -18,339 +19,18 @@ pub use self::chat::*; pub mod conversation; #[allow(unused_imports)] pub use self::conversation::*; +pub mod provider; +#[allow(unused_imports)] +pub use self::provider::*; use std::sync::atomic::Ordering; 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_types::types::new_id; -use df_storage::models::AiProviderRecord; - -use crate::state::AppState; -use crate::commands::{err_str, now_millis}; - -// chat/conversation 域专用符号(run_agentic_loop/save_conversation/AiChatEvent/ChatMessage 等) -// 已随 chat.rs/conversation.rs 搬走,本文件剩余 provider/skills/config 域不再引用,故不 import。 // skills 域(ai_list_skills)仍用 skills_cached/SkillInfo,保留。 use super::skills::{skills_cached, SkillInfo}; -// ============================================================ -// 提供商管理 -// ============================================================ - -/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 -fn mask_api_key(key: &str) -> String { - let chars: Vec = key.chars().collect(); - if chars.len() <= 8 { - return "•".repeat(chars.len()); - } - let prefix: String = chars[..4].iter().collect(); - let suffix: String = chars[chars.len() - 4..].iter().collect(); - format!("{}••••{}", prefix, suffix) -} - -/// 列出所有已配置的 AI 提供商(is_default 真相源为 DB,重启不丢) -#[tauri::command] -pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { - let mut providers = state.ai_providers.list_all().await.map_err(err_str)?; - // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 - // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) - for p in &mut providers { - let real = if !p.api_key.is_empty() { - p.api_key.clone() // 未迁移(老明文) - } else { - super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring - }; - p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; - } - Ok(providers) -} - -/// 保存/更新 AI 提供商配置 -#[tauri::command] -pub async fn ai_save_provider( - state: State<'_, AppState>, - id: Option, - name: String, - base_url: String, - api_key: String, - default_model: String, - provider_type: String, - model_configs: Vec, -) -> Result { - // F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), - // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 - // 编辑已有提供商时保留原 created_at,避免被覆盖 - // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 - // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 - let existing = match &id { - Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, - None => None, - }; - let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis); - // is_default:编辑保留原值;新建时若全表尚无默认则设为默认(首个自动默认,避免无默认可用) - let is_default = match &existing { - Some(p) => p.is_default, - None => !state.ai_providers.list_all().await - .map_err(err_str)? - .iter().any(|p| p.is_default), - }; - // F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); - // 新建默认进池(enabled=true,weight=50)。 - let (enabled, weight) = match &existing { - Some(p) => (p.enabled, p.weight), - None => (true, 50), - }; - // FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 - // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 - let provider_id = id.clone().unwrap_or_else(new_id); - if !api_key.is_empty() { - // 显式改/填密钥 → 写 keyring(现状不变) - if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { - return Err(format!("密钥保存到系统钥匙串失败: {}", e)); - } - } else if let Some(pid) = &id { - // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。 - // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, - // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 - // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); - // 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。 - let old = state.ai_providers.get_by_id(pid).await - .map_err(err_str)?; - if let Some(old) = old { - if !old.api_key.is_empty() - && super::secret::get_provider_secret(pid).is_none() - { - // DB 有明文 且 keyring 无 → 即时迁移补密钥 - if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { - return Err(format!( - "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ - 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", - e - )); - } - tracing::info!( - "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", - pid - ); - } - // else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全 - } - } - let api_key = String::new(); // DB 恒空(真实密钥在 keyring) - let record = AiProviderRecord { - id: provider_id, - name, - provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type }, - api_key, - base_url, - default_model, - models: None, - model_configs, - is_default, - config: None, - created_at, - updated_at: now_millis(), - // F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 - enabled, - weight, - }; - let id = record.id.clone(); - state - .ai_providers - .insert(record) - .await - .map_err(err_str)?; - Ok(id) -} - -/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 -/// -/// 与 `ai_save_provider` 的区别: -/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 -/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段, -/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。 -/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。 -/// -/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop -/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。 -#[tauri::command] -pub async fn ai_update_provider_pool( - state: State<'_, AppState>, - provider_id: String, - enabled: bool, - weight: u32, -) -> Result<(), String> { - // 验证 provider 存在(防前端传错 id 静默无操作) - let mut record = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - // weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。 - let weight = weight.min(100); - if record.enabled == enabled && record.weight == weight { - // 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。 - return Ok(()); - } - record.enabled = enabled; - record.weight = weight; - record.updated_at = now_millis(); - // update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。 - state - .ai_providers - .update_full(&record) - .await - .map_err(err_str)?; - // 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider - // 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。 - // caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。 - state.reload_provider_caps().await; - Ok(()) -} - -#[tauri::command] -pub async fn ai_set_provider( - state: State<'_, AppState>, - provider_id: String, -) -> Result<(), String> { - // 验证提供商存在 - let provider = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - - // 互斥写 DB:目标 is_default=true,其余=false。仅写变化的记录。 - let providers = state.ai_providers.list_all().await.map_err(err_str)?; - for p in &providers { - let should = p.id == provider_id; - if p.is_default != should { - let mut updated = p.clone(); - updated.is_default = should; - updated.updated_at = now_millis(); - state.ai_providers.update_full(&updated).await.map_err(err_str)?; - } - } - - let mut session = state.ai_session.lock().await; - session.active_provider_id = Some(provider.id); - Ok(()) -} - -/// 删除 AI 提供商 -#[tauri::command] -pub async fn ai_delete_provider( - state: State<'_, AppState>, - provider_id: String, -) -> Result<(), String> { - state.ai_providers.delete(&provider_id).await.map_err(err_str)?; - // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, - // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) - if let Err(e) = super::secret::delete_provider_secret(&provider_id) { - tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); - } - // 删除的若是当前默认,清空 active 指向,避免悬空 - let mut session = state.ai_session.lock().await; - if session.active_provider_id.as_deref() == Some(&provider_id) { - session.active_provider_id = None; - } - Ok(()) -} - -// ============================================================ -// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC) -// ============================================================ - -/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。 -/// -/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂), -/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。 -/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。 -fn normalize_provider_type_for_fetch(provider_type: &str) -> String { - match provider_type { - "anthropic" => "anthropic_compat".to_string(), - other => other.to_string(), - } -} - -/// 测试连接并拉取厂商模型列表(F-01 阶段5) -/// -/// 流程: -/// 1. DB 取 AiProviderRecord(get_by_id) -/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, -/// 绝不进日志/返回值/错误信息) -/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 -/// df_ai::model_fetch::fetch_and_probe → Vec(每个模型名已探测出 4 维度) -/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec -/// -/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 -#[tauri::command] -pub async fn ai_fetch_models( - state: State<'_, AppState>, - provider_id: String, -) -> Result, String> { - let provider = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - - // FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) - let api_key = df_storage::secret::resolve_provider_secret(&provider); - let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); - - let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key) - .await - .map_err(err_str)?; - - // 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/ - // context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。 - // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router - // 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。 - // 新模型(旧池无同 model_id)用探测默认值。 - let merged: Vec = probed - .iter() - .map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) { - Some(old) => ModelConfig { - weight: old.weight, - enabled: old.enabled, - label: old.label.clone(), - ..c.clone() - }, - None => c.clone(), - }) - .collect(); - - // 写回 model_configs(更新 updated_at) - let mut updated = provider.clone(); - updated.model_configs = merged.clone(); - updated.updated_at = now_millis(); - state - .ai_providers - .update_full(&updated) - .await - .map_err(err_str)?; - - Ok(merged) -} - -/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 -/// -/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。 -/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。 -#[tauri::command] -pub async fn ai_probe_model( - _state: State<'_, AppState>, - model_id: String, -) -> Result { - Ok(df_ai::model_probe::probe(&model_id)) -} +use crate::state::AppState; /// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 #[tauri::command] diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs new file mode 100644 index 0000000..cbba198 --- /dev/null +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -0,0 +1,348 @@ +//! provider 域 IPC — AI 提供商管理 / 模型拉取探测 +//! +//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 provider 域 7 个 IPC: +//! - 提供商 CRUD:`ai_list_providers` / `ai_save_provider` / `ai_set_provider` / +//! `ai_delete_provider` / `ai_update_provider_pool` +//! - 模型拉取探测:`ai_fetch_models` / `ai_probe_model` +//! + provider 专用 helper:`mask_api_key`(api_key 脱敏 FR-S1)、 +//! `normalize_provider_type_for_fetch`(provider_type 归一)。 +//! +//! 其他域(skills/config)仍留在 `commands/mod.rs`,后续批拆分。 +//! +//! re-export:由 `commands/mod.rs` 经 `pub use self::provider::*;` 拉到 `commands::*`, +//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`, +//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 +//! +//! 纯搬迁:不改 IPC 签名/行为(FR-S1 密钥迁移 / F-260614-04c 池配置编辑保留 / F-01 阶段5 探测 +//! 等逻辑原样保留)。 + +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_types::types::new_id; +use df_storage::models::AiProviderRecord; + +use crate::state::AppState; +use crate::commands::{err_str, now_millis}; + +// provider.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 +use super::super::secret::{ + get_provider_secret, set_provider_secret, delete_provider_secret, +}; + +// ============================================================ +// 提供商管理 +// ============================================================ + +/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 +fn mask_api_key(key: &str) -> String { + let chars: Vec = key.chars().collect(); + if chars.len() <= 8 { + return "•".repeat(chars.len()); + } + let prefix: String = chars[..4].iter().collect(); + let suffix: String = chars[chars.len() - 4..].iter().collect(); + format!("{}••••{}", prefix, suffix) +} + +/// 列出所有已配置的 AI 提供商(is_default 真相源为 DB,重启不丢) +#[tauri::command] +pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { + let mut providers = state.ai_providers.list_all().await.map_err(err_str)?; + // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 + // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) + for p in &mut providers { + let real = if !p.api_key.is_empty() { + p.api_key.clone() // 未迁移(老明文) + } else { + get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring + }; + p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; + } + Ok(providers) +} + +/// 保存/更新 AI 提供商配置 +#[tauri::command] +pub async fn ai_save_provider( + state: State<'_, AppState>, + id: Option, + name: String, + base_url: String, + api_key: String, + default_model: String, + provider_type: String, + model_configs: Vec, +) -> Result { + // F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), + // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 + // 编辑已有提供商时保留原 created_at,避免被覆盖 + // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 + // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 + let existing = match &id { + Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, + None => None, + }; + let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis); + // is_default:编辑保留原值;新建时若全表尚无默认则设为默认(首个自动默认,避免无默认可用) + let is_default = match &existing { + Some(p) => p.is_default, + None => !state.ai_providers.list_all().await + .map_err(err_str)? + .iter().any(|p| p.is_default), + }; + // F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); + // 新建默认进池(enabled=true,weight=50)。 + let (enabled, weight) = match &existing { + Some(p) => (p.enabled, p.weight), + None => (true, 50), + }; + // FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 + // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 + let provider_id = id.clone().unwrap_or_else(new_id); + if !api_key.is_empty() { + // 显式改/填密钥 → 写 keyring(现状不变) + if let Err(e) = set_provider_secret(&provider_id, &api_key) { + return Err(format!("密钥保存到系统钥匙串失败: {}", e)); + } + } else if let Some(pid) = &id { + // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。 + // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, + // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 + // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); + // 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。 + let old = state.ai_providers.get_by_id(pid).await + .map_err(err_str)?; + if let Some(old) = old { + if !old.api_key.is_empty() + && get_provider_secret(pid).is_none() + { + // DB 有明文 且 keyring 无 → 即时迁移补密钥 + if let Err(e) = set_provider_secret(pid, &old.api_key) { + return Err(format!( + "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ + 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", + e + )); + } + tracing::info!( + "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", + pid + ); + } + // else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全 + } + } + let api_key = String::new(); // DB 恒空(真实密钥在 keyring) + let record = AiProviderRecord { + id: provider_id, + name, + provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type }, + api_key, + base_url, + default_model, + models: None, + model_configs, + is_default, + config: None, + created_at, + updated_at: now_millis(), + // F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 + enabled, + weight, + }; + let id = record.id.clone(); + state + .ai_providers + .insert(record) + .await + .map_err(err_str)?; + Ok(id) +} + +/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 +/// +/// 与 `ai_save_provider` 的区别: +/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 +/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段, +/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。 +/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。 +/// +/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop +/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。 +#[tauri::command] +pub async fn ai_update_provider_pool( + state: State<'_, AppState>, + provider_id: String, + enabled: bool, + weight: u32, +) -> Result<(), String> { + // 验证 provider 存在(防前端传错 id 静默无操作) + let mut record = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + // weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。 + let weight = weight.min(100); + if record.enabled == enabled && record.weight == weight { + // 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。 + return Ok(()); + } + record.enabled = enabled; + record.weight = weight; + record.updated_at = now_millis(); + // update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。 + state + .ai_providers + .update_full(&record) + .await + .map_err(err_str)?; + // 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider + // 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。 + // caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。 + state.reload_provider_caps().await; + Ok(()) +} + +#[tauri::command] +pub async fn ai_set_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), String> { + // 验证提供商存在 + let provider = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + + // 互斥写 DB:目标 is_default=true,其余=false。仅写变化的记录。 + let providers = state.ai_providers.list_all().await.map_err(err_str)?; + for p in &providers { + let should = p.id == provider_id; + if p.is_default != should { + let mut updated = p.clone(); + updated.is_default = should; + updated.updated_at = now_millis(); + state.ai_providers.update_full(&updated).await.map_err(err_str)?; + } + } + + let mut session = state.ai_session.lock().await; + session.active_provider_id = Some(provider.id); + Ok(()) +} + +/// 删除 AI 提供商 +#[tauri::command] +pub async fn ai_delete_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), String> { + state.ai_providers.delete(&provider_id).await.map_err(err_str)?; + // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, + // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) + if let Err(e) = delete_provider_secret(&provider_id) { + tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); + } + // 删除的若是当前默认,清空 active 指向,避免悬空 + let mut session = state.ai_session.lock().await; + if session.active_provider_id.as_deref() == Some(&provider_id) { + session.active_provider_id = None; + } + Ok(()) +} + +// ============================================================ +// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC) +// ============================================================ + +/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。 +/// +/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂), +/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。 +/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。 +fn normalize_provider_type_for_fetch(provider_type: &str) -> String { + match provider_type { + "anthropic" => "anthropic_compat".to_string(), + other => other.to_string(), + } +} + +/// 测试连接并拉取厂商模型列表(F-01 阶段5) +/// +/// 流程: +/// 1. DB 取 AiProviderRecord(get_by_id) +/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, +/// 绝不进日志/返回值/错误信息) +/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 +/// df_ai::model_fetch::fetch_and_probe → Vec(每个模型名已探测出 4 维度) +/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec +/// +/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 +#[tauri::command] +pub async fn ai_fetch_models( + state: State<'_, AppState>, + provider_id: String, +) -> Result, String> { + let provider = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + + // FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) + let api_key = df_storage::secret::resolve_provider_secret(&provider); + let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); + + let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key) + .await + .map_err(err_str)?; + + // 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/ + // context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。 + // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router + // 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。 + // 新模型(旧池无同 model_id)用探测默认值。 + let merged: Vec = probed + .iter() + .map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) { + Some(old) => ModelConfig { + weight: old.weight, + enabled: old.enabled, + label: old.label.clone(), + ..c.clone() + }, + None => c.clone(), + }) + .collect(); + + // 写回 model_configs(更新 updated_at) + let mut updated = provider.clone(); + updated.model_configs = merged.clone(); + updated.updated_at = now_millis(); + state + .ai_providers + .update_full(&updated) + .await + .map_err(err_str)?; + + Ok(merged) +} + +/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 +/// +/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。 +/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。 +#[tauri::command] +pub async fn ai_probe_model( + _state: State<'_, AppState>, + model_id: String, +) -> Result { + Ok(df_ai::model_probe::probe(&model_id)) +}