From 6eb57d2ac64500990cc55005f853e5a18397158f Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Wed, 8 Jul 2026 16:14:16 +0800 Subject: [PATCH] feat: store Pi Switch config in SQLite, activate on switch --- src-tauri/Cargo.lock | 95 ++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 68 ++++++++-- src-tauri/src/db.rs | 266 ++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/App.tsx | 6 +- 6 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/db.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 10a703c..b5db297 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -978,6 +990,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1453,12 +1477,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1917,6 +1959,7 @@ dependencies = [ "dirs 5.0.1", "indexmap 2.14.0", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "tauri", @@ -1983,6 +2026,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2940,6 +2994,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4332,6 +4401,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5211,6 +5286,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6a25c86..d768329 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ anyhow = "1.0" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } dirs = "5.0" toml = "0.8" +rusqlite = { version = "0.32", features = ["bundled", "chrono"] } [lib] name = "kimiswitch_lib" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 53894c9..850cbab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,5 +1,7 @@ -use crate::models::{Agent, Config, DiscoveredModel, Provider, ProviderType}; +use crate::db; +use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType}; use crate::pi_io; +use indexmap::IndexMap; use tauri_plugin_opener::OpenerExt; fn fmt_anyhow(err: anyhow::Error) -> String { @@ -16,27 +18,75 @@ pub fn debug_log(message: String) { #[tauri::command] pub fn load_agent_config_command(agent: Agent) -> Result { - match agent { - Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config() - .map_err(fmt_anyhow), - Agent::Pi => { - let file = pi_io::load_pi_models().map_err(fmt_anyhow)?; - Ok(pi_io::pi_file_to_config(&file)) + // Load from Pi Switch's own SQLite database first. + match db::load_config(&agent) { + Ok(config) if !config.providers.is_empty() => Ok(config), + _ => { + // Fallback to the agent's native config on first use. + match agent { + Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config() + .map_err(fmt_anyhow), + Agent::Pi => { + let file = pi_io::load_pi_models().map_err(fmt_anyhow)?; + Ok(pi_io::pi_file_to_config(&file)) + } + } } } } #[tauri::command] pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> { + // Save the full Pi Switch configuration to local SQLite. + db::save_config(&agent, &config).map_err(fmt_anyhow) +} + +#[tauri::command] +pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> { + // Load the full config from SQLite and write only the active provider + // to the agent's native config file. + let config = db::load_config(&agent).map_err(fmt_anyhow)?; + let active_config = build_active_config(&config); match agent { - Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow), + Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&active_config) + .map_err(fmt_anyhow), Agent::Pi => { - let file = pi_io::config_to_pi_file(&config); + let file = pi_io::config_to_pi_file(&active_config); pi_io::save_pi_models(&file).map_err(fmt_anyhow) } } } +fn build_active_config(config: &Config) -> Config { + let is_kimi_native = |p: &Provider| p.managed; + + let providers: IndexMap = config + .providers + .iter() + .filter(|(_, p)| is_kimi_native(p) || p.active) + .map(|(k, p)| (k.clone(), p.clone())) + .collect(); + + let active_provider_names: std::collections::HashSet<&str> = providers + .values() + .map(|p| p.name.as_str()) + .collect(); + + let models: IndexMap = config + .models + .iter() + .filter(|(_, m)| active_provider_names.contains(m.provider.as_str())) + .map(|(k, m)| (k.clone(), m.clone())) + .collect(); + + Config { + default_model: config.default_model.clone(), + providers, + models, + raw_other: config.raw_other.clone(), + } +} + #[tauri::command] pub fn open_agent_config_dir(app: tauri::AppHandle, agent: Agent) -> Result<(), String> { let path = agent.config_dir(); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..6ee7a1e --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,266 @@ +//! Pi Switch local SQLite storage. +//! +//! This module stores the full Pi Switch configuration (all providers and models +//! for both Kimi Code and Pi agents) in a local SQLite database. It is separate +//! from the agent-specific config files that are written when the user activates +//! a provider. + +use std::path::PathBuf; + +use anyhow::Context; +use indexmap::IndexMap; +use rusqlite::{params, Connection}; +use serde_json::Value; + +use crate::models::{Agent, Config, Model, Provider, ProviderType}; + +pub type DbResult = anyhow::Result; + +pub fn pi_switch_data_dir() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".pi-switch")) + .expect("failed to resolve home directory") +} + +pub fn db_path() -> PathBuf { + pi_switch_data_dir().join("pi-switch.db") +} + +pub fn init_db() -> DbResult { + let path = db_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + let conn = Connection::open(&path) + .with_context(|| format!("failed to open database {}", path.display()))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + name TEXT NOT NULL, + provider_type TEXT NOT NULL, + base_url TEXT, + api_key TEXT, + env TEXT, + note TEXT, + official_url TEXT, + managed INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + active INTEGER NOT NULL DEFAULT 0, + raw_other TEXT, + UNIQUE(agent, name) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + alias TEXT NOT NULL, + provider_name TEXT NOT NULL, + model TEXT NOT NULL, + max_context_size INTEGER NOT NULL, + display_name TEXT, + role TEXT, + supports_1m INTEGER NOT NULL DEFAULT 0, + capabilities TEXT, + raw_other TEXT, + UNIQUE(agent, alias) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; + + Ok(conn) +} + +pub fn load_config(agent: &Agent) -> DbResult { + let mut conn = init_db()?; + let tx = conn.transaction()?; + + let default_model = get_setting_tx(&tx, &default_model_key(agent))?; + + let mut providers = IndexMap::new(); + { + let mut stmt = tx.prepare( + "SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, raw_other + FROM providers WHERE agent = ?1 ORDER BY id", + )?; + let provider_rows = stmt.query_map(params![agent.as_str()], |row| { + let provider_type: String = row.get(1)?; + let env_json: Option = row.get(4)?; + let raw_json: Option = row.get(10)?; + Ok(Provider { + name: row.get(0)?, + provider_type: provider_type_for_str(&provider_type), + base_url: row.get(2)?, + api_key: row.get(3)?, + env: env_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + note: row.get(5)?, + official_url: row.get(6)?, + managed: row.get::<_, i32>(7)? != 0, + enabled: row.get::<_, i32>(8)? != 0, + active: row.get::<_, i32>(9)? != 0, + raw_other: raw_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null), + }) + })?; + + for provider in provider_rows { + let p = provider?; + providers.insert(p.name.clone(), p); + } + } + + let mut models = IndexMap::new(); + { + let mut stmt = tx.prepare( + "SELECT alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other + FROM models WHERE agent = ?1 ORDER BY id", + )?; + let model_rows = stmt.query_map(params![agent.as_str()], |row| { + let caps_json: Option = row.get(7)?; + let raw_json: Option = row.get(8)?; + Ok(Model { + alias: row.get(0)?, + provider: row.get(1)?, + model: row.get(2)?, + max_context_size: row.get::<_, i64>(3)? as u64, + display_name: row.get(4)?, + role: row.get(5)?, + supports_1m: row.get::<_, i32>(6)? != 0, + capabilities: caps_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + raw_other: raw_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null), + }) + })?; + + for model in model_rows { + let m = model?; + models.insert(m.alias.clone(), m); + } + } + + tx.commit()?; + + Ok(Config { + default_model, + providers, + models, + raw_other: Value::Null, + }) +} + +pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> { + let mut conn = init_db()?; + let tx = conn.transaction()?; + + tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?; + tx.execute("DELETE FROM models WHERE agent = ?1", params![agent.as_str()])?; + + { + let mut insert_provider = tx.prepare( + "INSERT INTO providers + (agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, raw_other) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + + for provider in config.providers.values() { + insert_provider.execute(params![ + agent.as_str(), + provider.name, + provider.provider_type.as_str(), + provider.base_url, + provider.api_key, + serde_json::to_string(&provider.env).ok(), + provider.note, + provider.official_url, + provider.managed as i32, + provider.enabled as i32, + provider.active as i32, + serde_json::to_string(&provider.raw_other).ok(), + ])?; + } + } + + { + let mut insert_model = tx.prepare( + "INSERT INTO models + (agent, alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + )?; + + for model in config.models.values() { + insert_model.execute(params![ + agent.as_str(), + model.alias, + model.provider, + model.model, + model.max_context_size as i64, + model.display_name, + model.role, + model.supports_1m as i32, + serde_json::to_string(&model.capabilities).ok(), + serde_json::to_string(&model.raw_other).ok(), + ])?; + } + } + + if let Some(default_model) = &config.default_model { + set_setting_tx(&tx, &default_model_key(agent), default_model)?; + } else { + tx.execute("DELETE FROM settings WHERE key = ?1", params![default_model_key(agent)])?; + } + + tx.commit()?; + Ok(()) +} + +fn default_model_key(agent: &Agent) -> String { + format!("default_model:{}", agent.as_str()) +} + +fn get_setting_tx(tx: &rusqlite::Transaction, key: &str) -> DbResult> { + let mut stmt = tx.prepare("SELECT value FROM settings WHERE key = ?1")?; + let mut rows = stmt.query(params![key])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } +} + +fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResult<()> { + tx.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) +} + +fn provider_type_for_str(s: &str) -> ProviderType { + match s { + "anthropic" => ProviderType::Anthropic, + "openai_responses" => ProviderType::OpenaiResponses, + "google-genai" => ProviderType::GoogleGenai, + "vertexai" => ProviderType::Vertexai, + _ => ProviderType::Kimi, + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index daa46bd..6e300f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod db; pub mod kimi_code_io; pub mod models; pub mod pi_io; @@ -9,6 +10,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::load_agent_config_command, commands::save_agent_config_command, + commands::activate_agent_config_command, commands::open_agent_config_dir, commands::get_app_version, commands::list_provider_models, diff --git a/src/App.tsx b/src/App.tsx index 7e51bbd..5453f62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -191,7 +191,7 @@ export default function App() { updateConfig((cfg) => ({ ...cfg, default_model: alias })); }; - const handleSwitchProvider = (name: string) => { + const handleSwitchProvider = async (name: string) => { updateConfig((cfg) => { const target = cfg.providers[name]; if (!target || target.managed) return cfg; @@ -216,6 +216,10 @@ export default function App() { return { ...cfg, providers, default_model }; }); + + // Persist the full config to Pi Switch's SQLite and activate the selected provider. + await save(); + await invoke("activate_agent_config_command", { agent }); }; const handleApplyProviderJson = (provider: Provider, models: Model[]) => {