Skip to content

Commit 161fb8b

Browse files
committed
feat(ai): Provider 管理命令组与默认 Provider 密钥统一(v0.11.6 M1)
1 parent 0a45e78 commit 161fb8b

8 files changed

Lines changed: 283 additions & 15 deletions

File tree

‎app/src-tauri/src/ai_provider.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ pub fn preset_templates() -> Vec<AiProviderConfig> {
183183
]
184184
}
185185

186+
/// Provider 凭据 scope 键(per-provider DPAPI 隔离;与 ai_credentials scope 约定一致)。
187+
pub fn provider_scope(provider_id: &str) -> String {
188+
format!("provider:{}", provider_id)
189+
}
190+
186191
/// 旧配置迁移(首启):旧 ai_settings 的 base_url/model → SiliconFlow Provider。
187192
///
188193
/// @ai-context: 迁移幂等——ai_providers.json 已存在时不触发(调用方判断);

‎app/src-tauri/src/ai_refine_task.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ fn run_refine_task_inner(
191191
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
192192
.clone();
193193
let env_key = std::env::var("SILICONFLOW_API_KEY").ok().filter(|k| !k.is_empty());
194-
let stored_key = st.ai_credentials.load_key("default").ok().flatten();
194+
// M1 统一解析口:env 优先 > 默认 Provider per-provider 凭据 > 旧 default scope
195+
let stored_key = crate::commands_ai_providers::resolve_default_provider_key(st)
196+
.map_err(|e| AiTaskFailure::Other(e.to_string()))?;
195197
// 密钥来源诊断(脱敏:只打长度+前 6 字符;真机 unauthorized 排查 2026-08-21)
196198
eprintln!(
197199
"[refine-task] task={} key: env={} stored={}",
@@ -205,7 +207,7 @@ fn run_refine_task_inner(
205207
.map(|k| format!("{}:{}..", k.len(), &k[..6.min(k.len())]))
206208
.unwrap_or_else(|| "无".to_string()),
207209
);
208-
let store = st.ai_providers.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
210+
let store = st.ai_providers.lock().map_err(|e| AiTaskFailure::Other(format!("AI Provider 存储锁中毒: {}", e)))?.clone();
209211
let client = AiClient::from_settings_with_store(&settings, stored_key, &store);
210212
let adapter = AiNoteRefineAdapter::new(client.clone());
211213
let mock_adapter = AiMockAdapter;

‎app/src-tauri/src/app_setup.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,13 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
155155
crate::ai_settings::AiSettings::load(&ai_settings_path),
156156
));
157157
let ai_credentials = crate::ai_credentials::platform_store(&data_dir.join("ai_credentials.bin"));
158-
// v0.11.6 M1:AI Provider 装配——首启迁移(ai_providers.json 不存在且
159-
// 旧配置非默认值 → 生成 SiliconFlow Provider);密钥迁移同处完成
160-
// (旧凭据条目 → provider:legacy-siliconflow 新条目)
158+
// v0.11.6 M1:AI Provider 装配——providers 为空即迁移(新用户亦生成
159+
// 无密钥预设 Provider,行为自洽);密钥迁移同处完成(旧凭据条目 →
160+
// provider:legacy-siliconflow 新条目)。
161+
// 锁序契约:启动单线程装配、锁序(providers→settings)与运行时调用点
162+
// (settings→providers)相反,禁止在命令层持 providers 锁读 settings。
163+
// save 决策:保存失败阻断启动是有意 fail-fast(数据目录不可写时后续
164+
// 持久化同样失败),勿改为静默吞错。
161165
let ai_providers_path = data_dir.join("ai_providers.json");
162166
let ai_providers = std::sync::Arc::new(std::sync::Mutex::new(
163167
crate::ai_provider::AiProviderStore::load(&ai_providers_path),

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,13 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
274274
None => String::new(),
275275
};
276276
let settings = st.ai_settings.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
277-
let store = st.ai_providers.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
278-
let client = AiClient::from_settings_with_store(&settings, st.ai_credentials.load_key("default").ok().flatten(), &store);
277+
let store = st.ai_providers.lock().map_err(|e| AiTaskFailure::Other(format!("AI Provider 存储锁中毒: {}", e)))?.clone();
278+
// M1 统一解析口:env 优先 > 默认 Provider per-provider 凭据 > 旧 default scope
279+
let client = AiClient::from_settings_with_store(
280+
&settings,
281+
crate::commands_ai_providers::resolve_default_provider_key(&st).ok().flatten(),
282+
&store,
283+
);
279284
let adapter = AiNoteEnrichAdapter::new(client.clone());
280285
let mock_adapter = AiMockAdapter;
281286
// 切片(长笔记按章节切——REQ-145 基建复用;锚点跨片=全局章节标题)
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
}

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,10 +490,10 @@ pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Re
490490
}
491491
let required = est.est_cost_yuan * BALANCE_SAFETY_FACTOR;
492492
// 余额查询(短超时——余额接口抖动不阻断精修;失败放行宽容降级)
493-
let api_key = std::env::var("SILICONFLOW_API_KEY")
493+
// M1 统一解析口:env 优先 > 默认 Provider per-provider 凭据 > 旧 default scope
494+
let api_key = crate::commands_ai_providers::resolve_default_provider_key(st)
494495
.ok()
495-
.filter(|k| !k.is_empty())
496-
.or(st.ai_credentials.load_key("default").ok().flatten())
496+
.flatten()
497497
.unwrap_or_default();
498498
if api_key.is_empty() {
499499
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());

‎app/src-tauri/src/commands_ai_settings.rs‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,11 @@ fn snapshot_settings(state: &AppState) -> Result<AiSettings, String> {
212212
.map_err(|e| format!("AI 设置锁中毒: {}", e))
213213
}
214214

215-
/// 余额适配器(密钥解析:env > 凭据库;超时/重试走共享默认)。
215+
/// 余额适配器(密钥解析:env 优先 > 默认 Provider 凭据——统一解析口 M1)。
216216
fn balance_adapter(state: &AppState) -> Result<AiBalanceAdapter, String> {
217217
let s = snapshot_settings(state)?;
218-
let api_key = env_api_key()
219-
.or(state.ai_credentials.load_key("default")?)
220-
.unwrap_or_default();
221-
let store = state.ai_providers.lock().map_err(|e| e.to_string())?.clone();
218+
let api_key = crate::commands_ai_providers::resolve_default_provider_key(state)?.unwrap_or_default();
219+
let store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?.clone();
222220
let cfg = crate::ai_client::AiClient::from_settings_with_store(&s, Some(api_key), &store).config;
223221
Ok(AiBalanceAdapter {
224222
base_url: cfg.base_url,

‎app/src-tauri/src/lib.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ mod commands_photo;
9595
mod commands_ai;
9696
// v0.8.0 M1(REQ-138/139/140):AI 使能层命令——密钥管理/余额/授权/审计
9797
mod commands_ai_settings;
98+
// v0.11.6 M1(BYOK 多端点):AI Provider 管理命令——预设/列表/增删改/密钥/默认/测试
99+
mod commands_ai_providers;
98100
mod commands_analysis;
99101
mod commands_artifacts;
100102
mod commands_audio;
@@ -551,6 +553,17 @@ pub fn run() {
551553
commands_ai_settings::ai_get_balance,
552554
commands_ai_settings::ai_audit_list,
553555
commands_ai_settings::ai_audit_clear,
556+
// v0.11.6 M1(BYOK 多端点):AI Provider 管理——预设/列表/增删改/
557+
// 密钥/默认/测试连接(Provider 面板数据源)
558+
commands_ai_providers::ai_provider_presets,
559+
commands_ai_providers::ai_provider_list,
560+
commands_ai_providers::ai_provider_add,
561+
commands_ai_providers::ai_provider_update,
562+
commands_ai_providers::ai_provider_remove,
563+
commands_ai_providers::ai_provider_save_key,
564+
commands_ai_providers::ai_provider_clear_key,
565+
commands_ai_providers::ai_set_default_provider,
566+
commands_ai_providers::ai_provider_test,
554567
// v0.8.0 M2(REQ-141/145 + REQ-143 基础版):会话→笔记 AI 精修——
555568
// 成本预估/异步任务/状态/结果/采纳落库
556569
commands_ai_refine::ai_refine_estimate,

0 commit comments

Comments
 (0)