Skip to content

Commit d2dda73

Browse files
committed
feat(ai): Provider 配置模型与预设模板(BYOK 多端点,v0.11.6 M1)
1 parent efd2fb4 commit d2dda73

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

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

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//! AI Provider 配置(v0.11.6 M1,多 AI 能力接入基础层)。
2+
//!
3+
//! @ai-context: BYOK 多 Provider:用户自带 Key 配置多个 OpenAI 兼容端点
4+
//! (SiliconFlow/DeepSeek/OpenRouter/Ollama 本地),任务按默认
5+
//! Provider + 降级链执行;充值远期留桩(命令空间 ai_quota_*
6+
//! 隔离,本层不带计费语义)。
7+
//! @ai-context: 存储 ai_providers.json(数据目录);沿用 ai_settings.json 的
8+
//! "缺失/损坏回退内置默认"加载模式;密钥不在此层(DPAPI
9+
//! per-provider,ai_credentials.rs)。
10+
//! @ai-context: 旧版(v0.11.5 及之前)单 Provider 配置(ai_settings.json 的
11+
//! base_url/model + 单密钥)首启自动迁移为 SiliconFlow Provider
12+
//! ——用户无感,4 处 AiClient::from_settings 调用点零改动。
13+
14+
use serde::{Deserialize, Serialize};
15+
16+
/// Provider 类型(OpenAI 兼容协议覆盖绝大多数云端 + Ollama 本地)。
17+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18+
#[serde(rename_all = "kebab-case")]
19+
pub enum ProviderKind {
20+
/// OpenAI 兼容端点(SiliconFlow/DeepSeek/OpenRouter/智谱/自定义…)
21+
OpenAiCompat,
22+
/// Ollama 本地推理(OpenAI 兼容端点 /v1/chat/completions——无网降级路径)
23+
Ollama,
24+
}
25+
26+
/// 单个 Provider 配置。
27+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28+
#[serde(rename_all = "camelCase", default)]
29+
pub struct AiProviderConfig {
30+
/// 唯一 id(生成时 uuid/slug;迁移时固定 "legacy-siliconflow")
31+
pub id: String,
32+
/// 显示名("SiliconFlow")
33+
pub name: String,
34+
pub kind: ProviderKind,
35+
/// OpenAI 兼容 base_url(Ollama 为 http://127.0.0.1:11434/v1)
36+
pub base_url: String,
37+
/// 可用模型列表(设置页选择用)
38+
pub models: Vec<String>,
39+
/// 该 Provider 默认模型
40+
pub default_model: String,
41+
pub enabled: bool,
42+
/// 降级链(本 Provider 失败后依次尝试的 Provider id)
43+
pub fallback_order: Vec<String>,
44+
}
45+
46+
impl Default for AiProviderConfig {
47+
fn default() -> Self {
48+
Self {
49+
id: String::new(),
50+
name: String::new(),
51+
kind: ProviderKind::OpenAiCompat,
52+
base_url: String::new(),
53+
models: Vec::new(),
54+
default_model: String::new(),
55+
enabled: true,
56+
fallback_order: Vec::new(),
57+
}
58+
}
59+
}
60+
61+
impl AiProviderConfig {
62+
/// 配置校验(纯函数):id/name 非空、base_url http(s)、模型列表与默认模型非空。
63+
pub fn validate(&self) -> Result<(), String> {
64+
if self.id.trim().is_empty() {
65+
return Err("Provider id 不能为空".to_string());
66+
}
67+
if self.name.trim().is_empty() {
68+
return Err("Provider 名称不能为空".to_string());
69+
}
70+
if !(self.base_url.starts_with("http://") || self.base_url.starts_with("https://")) {
71+
return Err("端点必须是 http(s):// 开头的合法 URL".to_string());
72+
}
73+
if self.models.is_empty() {
74+
return Err("模型列表不能为空".to_string());
75+
}
76+
if self.default_model.trim().is_empty() {
77+
return Err("默认模型不能为空".to_string());
78+
}
79+
if !self.models.iter().any(|m| m == &self.default_model) {
80+
return Err("默认模型不在模型列表中".to_string());
81+
}
82+
Ok(())
83+
}
84+
}
85+
86+
/// Provider 存储(内存态单点;持久化 ai_providers.json——同 ai_settings 模式)。
87+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
88+
#[serde(rename_all = "camelCase", default)]
89+
pub struct AiProviderStore {
90+
pub providers: Vec<AiProviderConfig>,
91+
/// 默认 Provider id(任务无显式指定时使用)
92+
pub default_provider_id: Option<String>,
93+
}
94+
95+
impl AiProviderStore {
96+
pub fn from_json(json: &str) -> Result<Self, String> {
97+
serde_json::from_str(json).map_err(|e| format!("ai_providers.json 解析失败: {}", e))
98+
}
99+
100+
pub fn to_json(&self) -> Result<String, String> {
101+
serde_json::to_string_pretty(self).map_err(|e| format!("ai_providers.json 序列化失败: {}", e))
102+
}
103+
104+
/// 从数据目录加载(缺失/损坏 → 内置默认——不阻断启动)。
105+
pub fn load(path: &std::path::Path) -> Self {
106+
match std::fs::read_to_string(path) {
107+
Ok(raw) => Self::from_json(&raw).unwrap_or_else(|e| {
108+
eprintln!("[AiProvider] 配置加载失败,使用内置默认: {}", e);
109+
Self::default()
110+
}),
111+
Err(_) => Self::default(),
112+
}
113+
}
114+
115+
pub fn save(&self, path: &std::path::Path) -> Result<(), String> {
116+
let raw = self.to_json()?;
117+
std::fs::write(path, raw).map_err(|e| format!("ai_providers.json 写入失败: {}", e))
118+
}
119+
120+
/// 生效默认 Provider id:显式配置 > 第一个 enabled(幽灵 id 回退)。
121+
pub fn effective_default_id(&self) -> Option<String> {
122+
if let Some(id) = &self.default_provider_id {
123+
if self.providers.iter().any(|p| &p.id == id && p.enabled) {
124+
return Some(id.clone());
125+
}
126+
}
127+
self.providers.iter().find(|p| p.enabled).map(|p| p.id.clone())
128+
}
129+
130+
pub fn get(&self, id: &str) -> Option<&AiProviderConfig> {
131+
self.providers.iter().find(|p| p.id == id)
132+
}
133+
134+
pub fn get_mut(&mut self, id: &str) -> Option<&mut AiProviderConfig> {
135+
self.providers.iter_mut().find(|p| p.id == id)
136+
}
137+
}
138+
139+
/// 内置预设模板(编译期捆绑;用户"添加 Provider"时从模板起步可自定义)。
140+
pub fn preset_templates() -> Vec<AiProviderConfig> {
141+
let mk = |id: &str, name: &str, base_url: &str, models: &[&str], default: &str| AiProviderConfig {
142+
id: id.to_string(),
143+
name: name.to_string(),
144+
kind: ProviderKind::OpenAiCompat,
145+
base_url: base_url.to_string(),
146+
models: models.iter().map(|s| s.to_string()).collect(),
147+
default_model: default.to_string(),
148+
enabled: true,
149+
fallback_order: Vec::new(),
150+
};
151+
vec![
152+
mk(
153+
"siliconflow",
154+
"SiliconFlow",
155+
"https://api.siliconflow.cn/v1",
156+
&["deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", "deepseek-ai/DeepSeek-V3.2"],
157+
"deepseek-ai/DeepSeek-R1-0528-Qwen3-8B",
158+
),
159+
mk(
160+
"deepseek",
161+
"DeepSeek",
162+
"https://api.deepseek.com/v1",
163+
&["deepseek-chat", "deepseek-reasoner"],
164+
"deepseek-chat",
165+
),
166+
mk(
167+
"openrouter",
168+
"OpenRouter",
169+
"https://openrouter.ai/api/v1",
170+
&["openai/gpt-4o-mini", "anthropic/claude-3.5-sonnet", "deepseek/deepseek-chat"],
171+
"openai/gpt-4o-mini",
172+
),
173+
AiProviderConfig {
174+
id: "ollama".to_string(),
175+
name: "Ollama(本地)".to_string(),
176+
kind: ProviderKind::Ollama,
177+
base_url: "http://127.0.0.1:11434/v1".to_string(),
178+
models: vec!["qwen2.5:7b".to_string()],
179+
default_model: "qwen2.5:7b".to_string(),
180+
enabled: false,
181+
fallback_order: Vec::new(),
182+
},
183+
]
184+
}
185+
186+
/// 旧配置迁移(首启):旧 ai_settings 的 base_url/model → SiliconFlow Provider。
187+
///
188+
/// @ai-context: 迁移幂等——ai_providers.json 已存在时不触发(调用方判断);
189+
/// 密钥迁移在 command 层(旧凭据条目 → provider:<id> 新条目)。
190+
pub fn migrate_from_legacy(legacy: &crate::ai_settings::AiSettings) -> (Vec<AiProviderConfig>, Option<String>) {
191+
let mut p = AiProviderConfig {
192+
id: "legacy-siliconflow".to_string(),
193+
name: "SiliconFlow".to_string(),
194+
base_url: legacy.base_url.clone(),
195+
default_model: legacy.model.clone(),
196+
models: vec![legacy.model.clone()],
197+
..Default::default()
198+
};
199+
if p.validate().is_err() {
200+
// 旧配置非法 → 回退预设 SiliconFlow(保可用不崩)
201+
p = preset_templates().remove(0);
202+
p.id = "legacy-siliconflow".to_string();
203+
}
204+
let default_id = p.id.clone();
205+
(vec![p], Some(default_id))
206+
}
207+
208+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
209+
#[cfg(test)]
210+
#[path = "ai_provider_tests.rs"]
211+
mod tests;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! ai_provider.rs 单测(AAA 模式)。
2+
//!
3+
//! @ai-context: 覆盖:预设模板完整性(4 Provider)、配置校验(合法/非法 URL
4+
//! 与空模型)、JSON roundtrip(顺序与默认保持)、幽灵 id 回退、
5+
//! 旧版单 Provider 配置迁移(SiliconFlow)。
6+
7+
use crate::ai_provider::*;
8+
9+
#[test]
10+
fn preset_templates_cover_four_providers() {
11+
let presets = preset_templates();
12+
assert_eq!(presets.len(), 4);
13+
assert!(presets.iter().any(|p| p.kind == ProviderKind::Ollama));
14+
let sf = presets.iter().find(|p| p.name == "SiliconFlow").unwrap();
15+
assert!(sf.base_url.starts_with("https://"));
16+
assert!(!sf.models.is_empty());
17+
assert!(!sf.default_model.is_empty());
18+
}
19+
20+
#[test]
21+
fn provider_validate_accepts_valid_config() {
22+
let mut p = preset_templates().remove(0);
23+
p.id = "p1".to_string();
24+
assert!(p.validate().is_ok());
25+
}
26+
27+
#[test]
28+
fn provider_validate_rejects_bad_url_and_empty_model() {
29+
let mut p = preset_templates().remove(0);
30+
p.id = "p1".to_string();
31+
p.base_url = "ftp://bad".to_string();
32+
assert!(p.validate().is_err());
33+
p.base_url = "https://ok".to_string();
34+
p.models.clear();
35+
assert!(p.validate().is_err());
36+
}
37+
38+
#[test]
39+
fn store_roundtrip_keeps_order_and_default() {
40+
let mut store = AiProviderStore::default();
41+
let mut a = preset_templates().remove(0);
42+
a.id = "a".to_string();
43+
let mut b = preset_templates().remove(1);
44+
b.id = "b".to_string();
45+
store.providers.push(a.clone());
46+
store.providers.push(b.clone());
47+
store.default_provider_id = Some("b".to_string());
48+
let json = store.to_json().unwrap();
49+
let back = AiProviderStore::from_json(&json).unwrap();
50+
assert_eq!(back.providers.len(), 2);
51+
assert_eq!(back.default_provider_id.as_deref(), Some("b"));
52+
assert_eq!(back.providers[0].id, "a");
53+
}
54+
55+
#[test]
56+
fn store_missing_default_falls_back_to_first_enabled() {
57+
let mut store = AiProviderStore::default();
58+
let mut a = preset_templates().remove(0);
59+
a.id = "a".to_string();
60+
a.enabled = true;
61+
store.providers.push(a);
62+
store.default_provider_id = Some("ghost".to_string());
63+
assert_eq!(store.effective_default_id(), Some("a".to_string()));
64+
}
65+
66+
#[test]
67+
fn migrate_from_legacy_creates_siliconflow() {
68+
let s = crate::ai_settings::AiSettings {
69+
base_url: "https://api.siliconflow.cn/v1".to_string(),
70+
model: "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B".to_string(),
71+
..Default::default()
72+
};
73+
let (providers, default_id) = migrate_from_legacy(&s);
74+
assert_eq!(providers.len(), 1);
75+
assert_eq!(providers[0].name, "SiliconFlow");
76+
assert_eq!(providers[0].base_url, s.base_url);
77+
assert_eq!(providers[0].default_model, s.model);
78+
assert_eq!(default_id, Some(providers[0].id.clone()));
79+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ mod ai_balance;
1616
mod ai_client;
1717
mod ai_credentials;
1818
mod ai_settings;
19+
// v0.11.6 M1:AI Provider 配置模型——BYOK 多端点(SiliconFlow/DeepSeek/
20+
// OpenRouter/Ollama),预设模板 + 旧版迁移 + 降级链数据层
21+
mod ai_provider;
1922
// v0.11.5 spec 7️⃣(2026-08-22):AI 精修输入锚点剥离(段落锚点剥除省 token;
2023
// 章节锚点记录映射、精修输出回挂)——纯函数,只依赖 std
2124
mod anchor_strip;

0 commit comments

Comments
 (0)