|
| 1 | +//! AI Provider 管理 commands(v0.11.6 M1,BYOK 多端点)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 系统层——只做参数校验、调用 ai_provider/ai_credentials 业务 |
| 4 | +//! 模块、错误映射;密钥永不回传前端(视图只报存在性与来源)。 |
| 5 | +//! @ai-context: 测试连接用最小 chat 请求(通用 OpenAI 兼容端点,不依赖 |
| 6 | +//! SiliconFlow 余额接口)——Ollama 本地同样可测。 |
| 7 | +//! @ai-context: 锁序:配置读写在短锁内完成即释放;网络调用(测试连接)不持锁。 |
| 8 | +//! @ai-context: resolve_default_provider_key 为默认 Provider 密钥统一解析口 |
| 9 | +//! (env 优先 > per-provider 凭据 > 旧 default scope 回退), |
| 10 | +//! 精修/补充/余额 4 个旧调用点统一改走此口——Provider 面板 |
| 11 | +//! 保存的密钥对实际 AI 调用生效(Task 4 审查 Important 修复)。 |
| 12 | +
|
| 13 | +use tauri::State; |
| 14 | + |
| 15 | +use crate::ai_provider::{preset_templates, provider_scope, AiProviderConfig}; |
| 16 | +use crate::commands::AppState; |
| 17 | + |
| 18 | +/// Provider 视图(前端展示;密钥只暴露存在性与来源——明文红线)。 |
| 19 | +#[derive(Debug, Clone, serde::Serialize)] |
| 20 | +#[serde(rename_all = "camelCase")] |
| 21 | +pub struct AiProviderView { |
| 22 | + pub id: String, |
| 23 | + pub name: String, |
| 24 | + pub kind: String, |
| 25 | + pub base_url: String, |
| 26 | + pub models: Vec<String>, |
| 27 | + pub default_model: String, |
| 28 | + pub enabled: bool, |
| 29 | + pub fallback_order: Vec<String>, |
| 30 | + pub has_key: bool, |
| 31 | + pub key_source: String, |
| 32 | + pub is_default: bool, |
| 33 | +} |
| 34 | + |
| 35 | +/// Provider 创建/更新入参(密钥字段可选:更新时留空=不改)。 |
| 36 | +#[derive(Debug, Clone, serde::Deserialize)] |
| 37 | +#[serde(rename_all = "camelCase")] |
| 38 | +pub struct AiProviderInput { |
| 39 | + pub id: Option<String>, |
| 40 | + pub name: String, |
| 41 | + pub kind: String, |
| 42 | + pub base_url: String, |
| 43 | + pub models: Vec<String>, |
| 44 | + pub default_model: String, |
| 45 | + pub enabled: bool, |
| 46 | + pub fallback_order: Vec<String>, |
| 47 | + pub api_key: Option<String>, |
| 48 | +} |
| 49 | + |
| 50 | +/// 预设模板列表(前端"添加 Provider"向导数据)。 |
| 51 | +#[tauri::command] |
| 52 | +pub fn ai_provider_presets() -> Vec<AiProviderView> { |
| 53 | + preset_templates() |
| 54 | + .into_iter() |
| 55 | + .map(|p| to_view(&p, false, false)) |
| 56 | + .collect() |
| 57 | +} |
| 58 | + |
| 59 | +/// 当前 Provider 列表(含默认标记与密钥存在性)。 |
| 60 | +#[tauri::command] |
| 61 | +pub fn ai_provider_list(state: State<'_, AppState>) -> Result<Vec<AiProviderView>, String> { |
| 62 | + let store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 63 | + let default_id = store.effective_default_id(); |
| 64 | + let mut views = Vec::new(); |
| 65 | + for p in &store.providers { |
| 66 | + let has_key = state |
| 67 | + .ai_credentials |
| 68 | + .load_key(&provider_scope(&p.id)) |
| 69 | + .map(|k| k.is_some()) |
| 70 | + .unwrap_or(false); |
| 71 | + views.push(to_view(p, has_key, Some(p.id.as_str()) == default_id.as_deref())); |
| 72 | + } |
| 73 | + Ok(views) |
| 74 | +} |
| 75 | + |
| 76 | +/// 添加 Provider(预设模板 id 或自定义)。 |
| 77 | +#[tauri::command] |
| 78 | +pub fn ai_provider_add(state: State<'_, AppState>, input: AiProviderInput) -> Result<AiProviderView, String> { |
| 79 | + let mut provider = resolve_input(&input)?; |
| 80 | + provider.id = input.id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| { |
| 81 | + format!("p-{}", crate::db_sessions_rows::unix_seconds()) |
| 82 | + }); |
| 83 | + provider.validate()?; |
| 84 | + let mut store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 85 | + if store.get(&provider.id).is_some() { |
| 86 | + return Err(format!("Provider id 已存在: {}", provider.id)); |
| 87 | + } |
| 88 | + if store.providers.is_empty() { |
| 89 | + store.default_provider_id = Some(provider.id.clone()); |
| 90 | + } |
| 91 | + if let Some(key) = input.api_key.filter(|k| !k.trim().is_empty()) { |
| 92 | + state.ai_credentials.save_key(&provider_scope(&provider.id), key.trim())?; |
| 93 | + } |
| 94 | + store.providers.push(provider.clone()); |
| 95 | + store.save(&state.ai_providers_path)?; |
| 96 | + let has_key = state.ai_credentials.load_key(&provider_scope(&provider.id)).map(|k| k.is_some()).unwrap_or(false); |
| 97 | + Ok(to_view(&provider, has_key, Some(provider.id.as_str()) == store.effective_default_id().as_deref())) |
| 98 | +} |
| 99 | + |
| 100 | +/// 更新 Provider(id 必填;api_key 留空=不改密钥)。 |
| 101 | +#[tauri::command] |
| 102 | +pub fn ai_provider_update(state: State<'_, AppState>, id: String, input: AiProviderInput) -> Result<(), String> { |
| 103 | + let mut provider = resolve_input(&input)?; |
| 104 | + provider.id = id; |
| 105 | + provider.validate()?; |
| 106 | + let mut store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 107 | + let slot = store.get_mut(&provider.id).ok_or_else(|| format!("Provider 不存在: {}", provider.id))?; |
| 108 | + *slot = provider; |
| 109 | + if let Some(key) = input.api_key.filter(|k| !k.trim().is_empty()) { |
| 110 | + state.ai_credentials.save_key(&provider_scope(&slot.id), key.trim())?; |
| 111 | + } |
| 112 | + store.save(&state.ai_providers_path) |
| 113 | +} |
| 114 | + |
| 115 | +/// 删除 Provider(幂等;删除其凭据;默认 Provider 需先改默认或拒绝)。 |
| 116 | +#[tauri::command] |
| 117 | +pub fn ai_provider_remove(state: State<'_, AppState>, id: String) -> Result<(), String> { |
| 118 | + let mut store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 119 | + if store.effective_default_id().as_deref() == Some(id.as_str()) { |
| 120 | + return Err("默认 Provider 不能删除——请先设置其他默认".to_string()); |
| 121 | + } |
| 122 | + store.providers.retain(|p| p.id != id); |
| 123 | + let _ = state.ai_credentials.clear_key(&provider_scope(&id)); |
| 124 | + store.save(&state.ai_providers_path) |
| 125 | +} |
| 126 | + |
| 127 | +/// 保存 Provider 密钥(scope 化 DPAPI;留空拒绝)。 |
| 128 | +#[tauri::command] |
| 129 | +pub fn ai_provider_save_key(state: State<'_, AppState>, id: String, api_key: String) -> Result<(), String> { |
| 130 | + let key = api_key.trim().to_string(); |
| 131 | + if key.is_empty() { |
| 132 | + return Err("密钥不能为空".to_string()); |
| 133 | + } |
| 134 | + if key.chars().count() > 512 { |
| 135 | + return Err("密钥超长(上限 512 字符)".to_string()); |
| 136 | + } |
| 137 | + if state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?.get(&id).is_none() { |
| 138 | + return Err(format!("Provider 不存在: {}", id)); |
| 139 | + } |
| 140 | + state.ai_credentials.save_key(&provider_scope(&id), &key) |
| 141 | +} |
| 142 | + |
| 143 | +/// 清除 Provider 密钥(幂等)。 |
| 144 | +#[tauri::command] |
| 145 | +pub fn ai_provider_clear_key(state: State<'_, AppState>, id: String) -> Result<(), String> { |
| 146 | + state.ai_credentials.clear_key(&provider_scope(&id)) |
| 147 | +} |
| 148 | + |
| 149 | +/// 设置默认 Provider(存在性校验;enabled 才可设默认)。 |
| 150 | +#[tauri::command] |
| 151 | +pub fn ai_set_default_provider(state: State<'_, AppState>, id: String) -> Result<(), String> { |
| 152 | + let mut store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 153 | + let p = store.get(&id).ok_or_else(|| format!("Provider 不存在: {}", id))?; |
| 154 | + if !p.enabled { |
| 155 | + return Err("已禁用的 Provider 不能设为默认".to_string()); |
| 156 | + } |
| 157 | + store.default_provider_id = Some(id); |
| 158 | + store.save(&state.ai_providers_path) |
| 159 | +} |
| 160 | + |
| 161 | +/// 一键测试连接(最小 chat 请求验证密钥有效性——通用 OpenAI 兼容端点)。 |
| 162 | +#[tauri::command] |
| 163 | +pub fn ai_provider_test(state: State<'_, AppState>, id: String) -> Result<String, String> { |
| 164 | + let provider = { |
| 165 | + let store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 166 | + store.get(&id).cloned().ok_or_else(|| format!("Provider 不存在: {}", id))? |
| 167 | + }; |
| 168 | + if provider.default_model.is_empty() { |
| 169 | + return Err("请先选择默认模型".to_string()); |
| 170 | + } |
| 171 | + let api_key = state.ai_credentials.load_key(&provider_scope(&id))?.unwrap_or_default(); |
| 172 | + let client = crate::ai_client::AiClient::from_provider(&provider, Some(api_key)); |
| 173 | + let reply = client |
| 174 | + .chat_text( |
| 175 | + "你是连通性测试助手。", |
| 176 | + "只回复两个字:正常", |
| 177 | + ) |
| 178 | + .map_err(|e| e.to_string())?; |
| 179 | + Ok(reply.chars().take(20).collect()) |
| 180 | +} |
| 181 | + |
| 182 | +// ──────────────────────────────────────────────────────────── |
| 183 | +// 内部辅助 |
| 184 | +// ──────────────────────────────────────────────────────────── |
| 185 | + |
| 186 | +/// 解析默认 Provider 密钥(env 优先 > per-provider 凭据——Provider 面板 |
| 187 | +/// 配置的密钥在此生效;无默认 Provider 回退旧 default scope 兼容迁移前)。 |
| 188 | +pub fn resolve_default_provider_key(state: &AppState) -> Result<Option<String>, String> { |
| 189 | + let env_key = std::env::var("SILICONFLOW_API_KEY").ok().filter(|k| !k.is_empty()); |
| 190 | + if env_key.is_some() { |
| 191 | + return Ok(env_key); |
| 192 | + } |
| 193 | + let default_id = { |
| 194 | + let store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?; |
| 195 | + store.effective_default_id() |
| 196 | + }; |
| 197 | + match default_id { |
| 198 | + Some(id) => state.ai_credentials.load_key(&provider_scope(&id)), |
| 199 | + None => state.ai_credentials.load_key("default"), |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +/// 入参 → 配置(kind 字符串解析 + 公共字段映射;id 由调用方决定)。 |
| 204 | +fn resolve_input(input: &AiProviderInput) -> Result<AiProviderConfig, String> { |
| 205 | + let kind = match input.kind.as_str() { |
| 206 | + "openAiCompat" | "openai-compat" | "" => crate::ai_provider::ProviderKind::OpenAiCompat, |
| 207 | + "ollama" => crate::ai_provider::ProviderKind::Ollama, |
| 208 | + other => return Err(format!("未知 Provider 类型: {}", other)), |
| 209 | + }; |
| 210 | + Ok(AiProviderConfig { |
| 211 | + id: String::new(), |
| 212 | + name: input.name.trim().to_string(), |
| 213 | + kind, |
| 214 | + base_url: input.base_url.trim().to_string(), |
| 215 | + models: input.models.iter().map(|m| m.trim().to_string()).filter(|m| !m.is_empty()).collect(), |
| 216 | + default_model: input.default_model.trim().to_string(), |
| 217 | + enabled: input.enabled, |
| 218 | + fallback_order: input.fallback_order.clone(), |
| 219 | + }) |
| 220 | +} |
| 221 | + |
| 222 | +/// 视图映射(密钥存在性由调用方注入)。 |
| 223 | +fn to_view(p: &AiProviderConfig, has_key: bool, is_default: bool) -> AiProviderView { |
| 224 | + let key_source = if has_key { "credential" } else { "none" }; |
| 225 | + AiProviderView { |
| 226 | + id: p.id.clone(), |
| 227 | + name: p.name.clone(), |
| 228 | + kind: match p.kind { |
| 229 | + crate::ai_provider::ProviderKind::OpenAiCompat => "openAiCompat".to_string(), |
| 230 | + crate::ai_provider::ProviderKind::Ollama => "ollama".to_string(), |
| 231 | + }, |
| 232 | + base_url: p.base_url.clone(), |
| 233 | + models: p.models.clone(), |
| 234 | + default_model: p.default_model.clone(), |
| 235 | + enabled: p.enabled, |
| 236 | + fallback_order: p.fallback_order.clone(), |
| 237 | + has_key, |
| 238 | + key_source: key_source.to_string(), |
| 239 | + is_default, |
| 240 | + } |
| 241 | +} |
0 commit comments