feat(providers): seed an official preset on startup for Claude/Codex/Gemini

New and existing users now see a built-in "Claude Official" / "OpenAI
Official" / "Google Official" entry in their provider list, so switching
back to the official endpoint is one click away instead of buried in the
README.

- New providers_seed.rs holds the three seeds (id, name, settings_config,
  icon) keyed by AppType, with a single is_official_seed_id() helper that
  scans OFFICIAL_SEEDS so the id list has one source of truth.
- Database::init_default_official_providers() runs once per database
  (gated by an official_providers_seeded setting flag), appends each seed
  to the end of the sort order, and never touches is_current.
- Startup also auto-imports the live config (settings.json / auth.json /
  .env) as a "default" provider before seeding, so users with an existing
  manual config don't lose it when they click the official preset.
- Database::has_non_official_seed_provider() replaces the get_all_providers
  call in import_default_config's gating check with an id-only scan,
  dropping the N+1 endpoint sub-queries from every startup.
This commit is contained in:
Jason
2026-04-08 23:17:09 +08:00
parent df5cb6d771
commit 5dbea70eb1
5 changed files with 207 additions and 5 deletions
+1
View File
@@ -6,6 +6,7 @@ pub mod failover;
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod providers_seed;
pub mod proxy;
pub mod settings;
pub mod skills;
+99
View File
@@ -501,4 +501,103 @@ impl Database {
in_failover_queue: false,
}))
}
/// 判断指定 app 下是否存在非官方种子的供应商。
///
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
/// 用于 `import_default_config` 决定是否跳过 live 导入。
pub fn has_non_official_seed_provider(&self, app_type: &str) -> Result<bool, AppError> {
use crate::database::dao::providers_seed::is_official_seed_id;
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let id: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
if !is_official_seed_id(&id) {
return Ok(true);
}
}
Ok(false)
}
/// 计算指定 app 下一个可用的 sort_index(追加到末尾)。
fn next_sort_index_for_app(&self, app_type: &str) -> Result<usize, AppError> {
let conn = lock_conn!(self.conn);
let max: Option<i64> = conn
.query_row(
"SELECT MAX(sort_index) FROM providers WHERE app_type = ?1",
params![app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(max.map(|v| (v + 1) as usize).unwrap_or(0))
}
/// 启动时调用:补齐缺失的官方预设供应商(Claude / Codex / Gemini)。
///
/// 使用 settings flag `official_providers_seeded` 保证每个数据库只执行一次:
/// - 全新用户:seed 三条官方预设
/// - 老用户升级:同样会触发一次(flag 不存在),追加到末尾,不影响已有排序
/// - 用户删除 seed 后:不再重建(flag 已为 true),尊重用户意图
///
/// 与 `Database::save_provider` 的 UPSERT 语义配合,即使被意外重复调用
/// 也不会覆盖用户当前激活的供应商(is_current 字段会被保留)。
pub fn init_default_official_providers(&self) -> Result<usize, AppError> {
use crate::database::dao::providers_seed::OFFICIAL_SEEDS;
// flag 检查:已执行过则跳过
if let Ok(Some(flag)) = self.get_setting("official_providers_seeded") {
if flag == "true" || flag == "1" {
return Ok(0);
}
}
let mut inserted = 0_usize;
let now_ms = chrono::Utc::now().timestamp_millis();
for seed in OFFICIAL_SEEDS {
let app_type_str = seed.app_type.as_str();
// 若该 id 已存在(极端情况:用户曾手动用过同 id),跳过
if self.get_provider_by_id(seed.id, app_type_str)?.is_some() {
continue;
}
let next_sort_index = self.next_sort_index_for_app(app_type_str)?;
let settings_config: serde_json::Value =
serde_json::from_str(seed.settings_config_json).map_err(|e| {
AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id))
})?;
let mut provider = Provider::with_id(
seed.id.to_string(),
seed.name.to_string(),
settings_config,
Some(seed.website_url.to_string()),
);
provider.category = Some("official".to_string());
provider.icon = Some(seed.icon.to_string());
provider.icon_color = Some(seed.icon_color.to_string());
provider.sort_index = Some(next_sort_index);
provider.created_at = Some(now_ms);
self.save_provider(app_type_str, &provider)?;
inserted += 1;
log::info!(
"✓ Seeded official provider: {} ({})",
seed.name,
app_type_str
);
}
// 即使 inserted=0(例如用户手动创建过同 id)也设置 flag 防止反复检查
self.set_setting("official_providers_seeded", "true")?;
Ok(inserted)
}
}
@@ -0,0 +1,66 @@
//! 官方供应商种子数据
//!
//! 启动时调用 `Database::init_default_official_providers` 把这些条目
//! 写入 `providers` 表,让所有用户都能看到一个"一键切回官方"的入口。
//!
//! 字段与前端预设保持一致,参见:
//! - `src/config/claudeProviderPresets.ts`"Claude Official"
//! - `src/config/codexProviderPresets.ts`"OpenAI Official"
//! - `src/config/geminiProviderPresets.ts`"Google Official"
use crate::app_config::AppType;
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
pub id: &'static str,
pub app_type: AppType,
pub name: &'static str,
pub website_url: &'static str,
pub icon: &'static str,
pub icon_color: &'static str,
/// settings_config 的 JSON 字符串,每个 app 结构不同。
pub settings_config_json: &'static str,
}
/// Claude / Codex / Gemini 三个应用的官方预设。
///
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
OfficialProviderSeed {
id: "claude-official",
app_type: AppType::Claude,
name: "Claude Official",
website_url: "https://www.anthropic.com/claude-code",
icon: "anthropic",
icon_color: "#D4915D",
// 空 env 让用户走 Claude CLI 默认认证流程
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: "codex-official",
app_type: AppType::Codex,
name: "OpenAI Official",
website_url: "https://chatgpt.com/codex",
icon: "openai",
icon_color: "#00A67E",
// 空 auth + 空 config 让用户走 ChatGPT Plus/Pro OAuth
settings_config_json: r#"{"auth":{},"config":""}"#,
},
OfficialProviderSeed {
id: "gemini-official",
app_type: AppType::Gemini,
name: "Google Official",
website_url: "https://ai.google.dev/",
icon: "gemini",
icon_color: "#4285F4",
// 空 env + 空 config 让用户走 Google OAuth
settings_config_json: r#"{"env":{},"config":{}}"#,
},
];
/// 判断给定的 provider id 是否属于内置官方种子。
///
/// 单一事实源:直接扫描 `OFFICIAL_SEEDS`,避免在多处重复维护 id 列表。
pub(crate) fn is_official_seed_id(id: &str) -> bool {
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
}
+35
View File
@@ -467,6 +467,41 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 1.5. 自动导入 live 配置 + seed 官方预设供应商(Claude / Codex / Gemini
//
// 先 import 后 seed 是有意为之:先把用户手动配置的 settings.json / auth.json / .env
// 落成 "default" provider 设为 current,再追加官方预设(is_current=false)。
// 这样用户切到官方预设时,回填机制会保护原 live 配置不丢失。
for app_type in
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
{
match crate::services::provider::import_default_config(
&app_state,
app_type.clone(),
) {
Ok(true) => log::info!(
"✓ Imported live config for {} as default provider",
app_type.as_str()
),
Ok(false) => log::debug!(
"○ {} already has providers; live import skipped",
app_type.as_str()
),
Err(e) => log::debug!(
"○ No live config to import for {}: {e}",
app_type.as_str()
),
}
}
match app_state.db.init_default_official_providers() {
Ok(count) if count > 0 => {
log::info!("✓ Seeded {count} official provider(s)");
}
Ok(_) => {}
Err(e) => log::warn!("✗ Failed to seed official providers: {e}"),
}
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
+6 -5
View File
@@ -999,11 +999,12 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
return Ok(false);
}
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(false); // 已有供应商,跳过
}
// 允许 "只有官方 seed 预设" 的情况下继续导入 live:
// - 启动编排顺序是先 import 后 seed,新用户启动时 providers 为空,导入照常
// - 老用户已有非 seed provider,跳过导入(正确)
// - 用户手动点 ProviderEmptyState 的导入按钮时,与官方 seed 共存而不被阻塞
if state.db.has_non_official_seed_provider(app_type.as_str())? {
return Ok(false);
}
let settings_config = match app_type {