feat: store Pi Switch config in SQLite, activate on switch

This commit is contained in:
KimiSwitch Dev
2026-07-08 16:14:16 +08:00
parent 155efdf9f3
commit 6eb57d2ac6
6 changed files with 428 additions and 10 deletions
+59 -9
View File
@@ -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<Config, String> {
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<String, Provider> = 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<String, Model> = 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();
+266
View File
@@ -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<T> = anyhow::Result<T>;
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<Connection> {
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<Config> {
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<String> = row.get(4)?;
let raw_json: Option<String> = 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<String> = row.get(7)?;
let raw_json: Option<String> = 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<Option<String>> {
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,
}
}
+2
View File
@@ -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,