重构: commands拆分provider域(strategy单批)
- 新建 commands/provider.rs(348行, 7 provider/model IPC + mask_api_key/normalize_provider_type helper) - commands/mod.rs 418→98行(删provider域 + re-export + 清理imports) - re-export三级透传(chat/conversation/provider),lib.rs invoke_handler+前端零改动;FR-S1密钥/F-260614-04c池配置/F-01探测保留 主代兜底: cargo check --workspace 0 + test 98 + grep re-export三域印证 进度: chat✅+conversation✅+provider✅,余 skills+config(4 IPC,最后批→mod.rs收敛re-export only)
This commit is contained in:
@@ -4,11 +4,12 @@
|
|||||||
//! - [`chat`] — chat 域 13 个 IPC(发送/重新生成/编辑/强制发送/停止/审批/上下文分段与压缩/循环控制)
|
//! - [`chat`] — chat 域 13 个 IPC(发送/重新生成/编辑/强制发送/停止/审批/上下文分段与压缩/循环控制)
|
||||||
//! + chat 专用 helper(`finalize_pending_placeholders`)+ `PendingToolCallInfo`
|
//! + chat 专用 helper(`finalize_pending_placeholders`)+ `PendingToolCallInfo`
|
||||||
//! - [`conversation`] — conversation 域 8 个 IPC(创建/列出/切换/删除/重命名/归档/置顶/导出)
|
//! - [`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 拉到
|
//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider}::*` 把各域 IPC
|
||||||
//! `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
|
//! 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
|
||||||
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
||||||
|
|
||||||
// 各域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*)
|
// 各域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*)
|
||||||
@@ -18,339 +19,18 @@ pub use self::chat::*;
|
|||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::conversation::*;
|
pub use self::conversation::*;
|
||||||
|
pub mod provider;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use self::provider::*;
|
||||||
|
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use tauri::State;
|
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,保留。
|
// skills 域(ai_list_skills)仍用 skills_cached/SkillInfo,保留。
|
||||||
use super::skills::{skills_cached, SkillInfo};
|
use super::skills::{skills_cached, SkillInfo};
|
||||||
|
|
||||||
// ============================================================
|
use crate::state::AppState;
|
||||||
// 提供商管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识
|
|
||||||
fn mask_api_key(key: &str) -> String {
|
|
||||||
let chars: Vec<char> = 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<Vec<AiProviderRecord>, 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<String>,
|
|
||||||
name: String,
|
|
||||||
base_url: String,
|
|
||||||
api_key: String,
|
|
||||||
default_model: String,
|
|
||||||
provider_type: String,
|
|
||||||
model_configs: Vec<ModelConfig>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
// 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<ModelConfig>(每个模型名已探测出 4 维度)
|
|
||||||
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
|
|
||||||
///
|
|
||||||
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn ai_fetch_models(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<Vec<ModelConfig>, 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<ModelConfig> = 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<ModelConfig, String> {
|
|
||||||
Ok(df_ai::model_probe::probe(&model_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
348
src-tauri/src/commands/ai/commands/provider.rs
Normal file
348
src-tauri/src/commands/ai/commands/provider.rs
Normal file
@@ -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<char> = 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<Vec<AiProviderRecord>, 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<String>,
|
||||||
|
name: String,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
default_model: String,
|
||||||
|
provider_type: String,
|
||||||
|
model_configs: Vec<ModelConfig>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 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<ModelConfig>(每个模型名已探测出 4 维度)
|
||||||
|
/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec<ModelConfig>
|
||||||
|
///
|
||||||
|
/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_fetch_models(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<Vec<ModelConfig>, 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<ModelConfig> = 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<ModelConfig, String> {
|
||||||
|
Ok(df_ai::model_probe::probe(&model_id))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user