feat: v0.4.0 - provider icon system, settings modal & polish
- Add provider icon system with extracted icon library (cc-switch based) - Persist icon and icon_color in SQLite and config.toml - Add Settings modal (theme / language / update check / silent download) - Add useTheme and useUpdateCheck hooks - Add provider icon picker (IconPicker) with search and grid - Fix icon frame 2-layer color difference in ProviderEdit - Dashboard heatmap and daily usage improvements - Sessions page polish and adaptive layout - Bump version to 0.4.0
This commit is contained in:
1 parent
14d48500bf
commit
ebf2dd28d5
134 files changed
+2898
-326
No files matched your search
Generated
+19
-2
@@ -1958,12 +1958,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"fs_extra",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2927,6 +2928,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -2946,12 +2948,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -2986,7 +2990,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -4554,6 +4558,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -20,7 +20,8 @@ anyhow = "1.0"
|
||||
regex = "1"
|
||||
walkdir = "2"
|
||||
fs_extra = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
|
||||
futures-util = "0.3"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::db;
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
|
||||
use crate::pi_io;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
fn fmt_anyhow(err: anyhow::Error) -> String {
|
||||
@@ -37,6 +38,10 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
||||
if let Some(db_p) = db.providers.get(name) {
|
||||
p.note = db_p.note.clone();
|
||||
p.official_url = db_p.official_url.clone();
|
||||
// Restore Kimi Switch metadata that does not live in
|
||||
// the agent's config.toml.
|
||||
p.icon = db_p.icon.clone();
|
||||
p.icon_color = db_p.icon_color.clone();
|
||||
// Restore the remembered per-provider default model
|
||||
// (Kimi-Switch-private, stored in raw_other).
|
||||
if let Some(dm) = db_p.raw_other.get("default_model") {
|
||||
@@ -377,3 +382,175 @@ async fn fetch_google_genai_models(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App settings (generic key/value via SQLite)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_setting(key: String) -> Option<String> {
|
||||
db::get_setting_pub(&key).ok().flatten()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_app_setting(key: String, value: String) -> Result<(), String> {
|
||||
db::set_setting_pub(&key, &value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version check (lightweight: GET Gitea releases API, compare tag_name)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub current: String,
|
||||
pub latest: String,
|
||||
pub update_available: bool,
|
||||
pub release_url: String,
|
||||
pub download_url: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_version(s: &str) -> Vec<u32> {
|
||||
s.trim_start_matches('v')
|
||||
.split('.')
|
||||
.filter_map(|p| p.parse::<u32>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn version_lt(current: &str, latest: &str) -> bool {
|
||||
let c = parse_version(current);
|
||||
let l = parse_version(latest);
|
||||
for i in 0..c.len().max(l.len()) {
|
||||
let cv = *c.get(i).unwrap_or(&0);
|
||||
let lv = *l.get(i).unwrap_or(&0);
|
||||
if cv < lv {
|
||||
return true;
|
||||
}
|
||||
if cv > lv {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update() -> Result<UpdateInfo, String> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
let url = "https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1";
|
||||
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
let releases: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
let first = releases
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or("no releases found")?;
|
||||
|
||||
let latest = first
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0")
|
||||
.to_string();
|
||||
|
||||
let release_url = first
|
||||
.get("html_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let download_url = first
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|a| a.get("browser_download_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let update_available = version_lt(¤t, &latest);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current,
|
||||
latest,
|
||||
update_available,
|
||||
release_url,
|
||||
download_url,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Silent download with progress events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_update(
|
||||
app: tauri::AppHandle,
|
||||
url: String,
|
||||
) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::io::Write;
|
||||
use tauri::Emitter;
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("download request failed: {e}"))?;
|
||||
|
||||
let total = resp.content_length().unwrap_or(0);
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_path = temp_dir.join("KimiSwitch_update.msi");
|
||||
|
||||
let mut file = std::fs::File::create(&file_path)
|
||||
.map_err(|e| format!("create temp file failed: {e}"))?;
|
||||
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut stream = resp.bytes_stream();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| format!("read chunk failed: {e}"))?;
|
||||
file.write_all(&chunk)
|
||||
.map_err(|e| format!("write failed: {e}"))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let progress = if total > 0 {
|
||||
((downloaded as f64 / total as f64) * 100.0).min(100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let _ = app.emit(
|
||||
"download-progress",
|
||||
serde_json::json!({
|
||||
"downloaded": downloaded,
|
||||
"total": total,
|
||||
"progress": progress,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
drop(file);
|
||||
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
let _ = app.emit(
|
||||
"download-complete",
|
||||
serde_json::json!({ "path": &path_str }),
|
||||
);
|
||||
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
/// Open the downloaded MSI installer using the system default handler.
|
||||
#[tauri::command]
|
||||
pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
+43
-4
@@ -106,6 +106,20 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Add icon columns for provider icon picker (introduced in v0.3.1).
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
|
||||
for stmt in [
|
||||
"ALTER TABLE providers ADD COLUMN icon TEXT",
|
||||
"ALTER TABLE providers ADD COLUMN icon_color TEXT",
|
||||
] {
|
||||
if let Err(e) = conn.execute(stmt, []) {
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
return Err(e).with_context(|| format!("failed to run migration: {}", stmt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -118,13 +132,13 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
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
|
||||
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, 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)?;
|
||||
let raw_json: Option<String> = row.get(12)?;
|
||||
Ok(Provider {
|
||||
name: row.get(0)?,
|
||||
provider_type: provider_type_for_str(&provider_type),
|
||||
@@ -138,6 +152,8 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
managed: row.get::<_, i32>(7)? != 0,
|
||||
enabled: row.get::<_, i32>(8)? != 0,
|
||||
active: row.get::<_, i32>(9)? != 0,
|
||||
icon: row.get(10)?,
|
||||
icon_color: row.get(11)?,
|
||||
raw_other: raw_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
@@ -201,8 +217,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
{
|
||||
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)",
|
||||
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
)?;
|
||||
|
||||
for provider in config.providers.values() {
|
||||
@@ -218,6 +234,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
provider.managed as i32,
|
||||
provider.enabled as i32,
|
||||
provider.active as i32,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&provider.raw_other).ok(),
|
||||
])?;
|
||||
}
|
||||
@@ -278,6 +296,27 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Public helper: read a single setting without an explicit transaction.
|
||||
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
|
||||
let conn = init_db()?;
|
||||
let mut stmt = conn.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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Public helper: write a single setting in its own transaction.
|
||||
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
|
||||
let mut conn = init_db()?;
|
||||
let tx = conn.transaction()?;
|
||||
set_setting_tx(&tx, key, value)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||
match s {
|
||||
"anthropic" => ProviderType::Anthropic,
|
||||
|
||||
@@ -167,6 +167,15 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let icon = table
|
||||
.get("icon")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let icon_color = table
|
||||
.get("icon_color")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let raw_other = {
|
||||
let mut rest = table.clone();
|
||||
rest.remove("type");
|
||||
@@ -197,6 +206,8 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
managed,
|
||||
enabled,
|
||||
active: active_provider_name == Some(name.as_str()),
|
||||
icon,
|
||||
icon_color,
|
||||
raw_other,
|
||||
},
|
||||
);
|
||||
@@ -252,6 +263,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
pt.insert("api_key".to_string(), TomlValue::String(api_key));
|
||||
}
|
||||
pt.insert("enabled".to_string(), TomlValue::Boolean(provider.enabled));
|
||||
if let Some(icon) = provider.icon.clone().filter(|s| !s.is_empty()) {
|
||||
pt.insert("icon".to_string(), TomlValue::String(icon));
|
||||
}
|
||||
if let Some(icon_color) = provider.icon_color.clone().filter(|s| !s.is_empty()) {
|
||||
pt.insert("icon_color".to_string(), TomlValue::String(icon_color));
|
||||
}
|
||||
if !provider.env.is_empty() {
|
||||
let mut env_table = Table::new();
|
||||
for (k, v) in &provider.env {
|
||||
@@ -486,6 +503,8 @@ api_key = ""
|
||||
managed: false,
|
||||
enabled: true,
|
||||
active: true,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
@@ -545,6 +564,8 @@ api_key = ""
|
||||
managed: false,
|
||||
enabled: true,
|
||||
active: true,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -100,6 +100,11 @@ pub fn run() {
|
||||
commands::get_app_version,
|
||||
commands::list_provider_models,
|
||||
commands::debug_log,
|
||||
commands::get_app_setting,
|
||||
commands::set_app_setting,
|
||||
commands::check_for_update,
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
@@ -85,6 +85,10 @@ pub struct Provider {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon_color: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub raw_other: Value,
|
||||
}
|
||||
@@ -101,6 +105,8 @@ impl PartialEq for Provider {
|
||||
&& self.managed == other.managed
|
||||
&& self.enabled == other.enabled
|
||||
&& self.active == other.active
|
||||
&& self.icon == other.icon
|
||||
&& self.icon_color == other.icon_color
|
||||
&& self.raw_other == other.raw_other
|
||||
}
|
||||
}
|
||||
@@ -117,6 +123,9 @@ impl std::fmt::Debug for Provider {
|
||||
.field("env", &"<redacted>")
|
||||
.field("managed", &self.managed)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("active", &self.active)
|
||||
.field("icon", &self.icon)
|
||||
.field("icon_color", &self.icon_color)
|
||||
.field("raw_other", &"<json>")
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -336,6 +336,8 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
managed: false,
|
||||
enabled: pi_provider.enabled,
|
||||
active: true,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other: provider_raw,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user