From ebf2dd28d5b5f9e7f721e260e783c0e70db757a9 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Tue, 28 Jul 2026 16:01:11 +0800 Subject: [PATCH] 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 --- README.md | 2 + README_EN.md | 2 + package-lock.json | 14 +- package.json | 3 +- src-tauri/Cargo.lock | 21 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/commands.rs | 177 +++++ src-tauri/src/db.rs | 47 +- src-tauri/src/kimi_code_io.rs | 21 + src-tauri/src/lib.rs | 5 + src-tauri/src/models.rs | 9 + src-tauri/src/pi_io.rs | 2 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 64 +- src/components/AgentSettingsPanel.tsx | 42 +- src/components/IconPicker.tsx | 70 ++ src/components/ProviderEdit.tsx | 186 +++-- src/components/ProviderIcon.tsx | 137 ++++ src/components/ProviderList.tsx | 77 +- src/components/SettingsModal.tsx | 346 +++++++++ src/components/dashboard/DailyBars.tsx | 10 +- src/components/dashboard/DailyDetailModal.tsx | 34 +- src/components/dashboard/DashboardPage.tsx | 106 +-- src/components/dashboard/Heatmap.tsx | 18 +- src/components/sessions/SessionsPage.tsx | 146 ++-- src/hooks/useTheme.ts | 62 ++ src/hooks/useUpdateCheck.ts | 58 ++ src/i18n/en.ts | 31 + src/i18n/zh.ts | 31 + src/icons/brands.ts | 146 ++++ src/icons/extracted/ClaudeApi.png | Bin 0 -> 17658 bytes src/icons/extracted/TeamoRouter-icon-dark.png | Bin 0 -> 1744 bytes src/icons/extracted/aicodemirror.svg | 19 + src/icons/extracted/aicoding.svg | 20 + src/icons/extracted/aihubmix-color.svg | 1 + src/icons/extracted/algocode.svg | 51 ++ src/icons/extracted/alibaba.svg | 1 + src/icons/extracted/amuxapi-icon.svg | 13 + src/icons/extracted/anthropic.svg | 1 + src/icons/extracted/apikeyfun.png | Bin 0 -> 1094 bytes src/icons/extracted/apinebula_icon.png | Bin 0 -> 6632 bytes src/icons/extracted/atlascloud_icon.png | Bin 0 -> 9604 bytes src/icons/extracted/aws.svg | 1 + src/icons/extracted/azure.svg | 1 + src/icons/extracted/baidu.svg | 1 + src/icons/extracted/bailian.svg | 1 + src/icons/extracted/bytedance.svg | 1 + src/icons/extracted/byteplus.png | Bin 0 -> 27347 bytes src/icons/extracted/catcoder.svg | 1 + src/icons/extracted/ccsub.svg | 4 + src/icons/extracted/chatglm.svg | 1 + src/icons/extracted/cherryin.png | Bin 0 -> 11327 bytes src/icons/extracted/claude.svg | 1 + src/icons/extracted/claudecn.png | Bin 0 -> 47341 bytes src/icons/extracted/claw.svg | 22 + src/icons/extracted/cloudflare.svg | 1 + src/icons/extracted/code0.png | Bin 0 -> 2323 bytes src/icons/extracted/cohere.svg | 1 + src/icons/extracted/copilot.svg | 1 + src/icons/extracted/crazyrouter.svg | 1 + src/icons/extracted/cubence.svg | 9 + src/icons/extracted/deepseek.svg | 1 + src/icons/extracted/doubao.svg | 1 + src/icons/extracted/eflowcode.png | Bin 0 -> 63679 bytes src/icons/extracted/etok.png | Bin 0 -> 42576 bytes src/icons/extracted/fenno-icon.webp | Bin 0 -> 33198 bytes src/icons/extracted/gemini.svg | 1 + src/icons/extracted/gemma.svg | 1 + src/icons/extracted/github.svg | 1 + src/icons/extracted/githubcopilot.svg | 1 + src/icons/extracted/google.svg | 1 + src/icons/extracted/googlecloud.svg | 1 + src/icons/extracted/grok.svg | 1 + src/icons/extracted/hermes.png | Bin 0 -> 39304 bytes src/icons/extracted/huawei.svg | 1 + src/icons/extracted/huggingface.svg | 1 + src/icons/extracted/hunyuan.svg | 1 + src/icons/extracted/huoshan.png | Bin 0 -> 35396 bytes src/icons/extracted/index.ts | 157 ++++ src/icons/extracted/kimi.svg | 1 + src/icons/extracted/lioncc.svg | 6 + src/icons/extracted/longcat-color.svg | 1 + src/icons/extracted/mcp.svg | 1 + src/icons/extracted/meta.svg | 1 + src/icons/extracted/metadata.ts | 715 ++++++++++++++++++ src/icons/extracted/micu.svg | 23 + src/icons/extracted/midjourney.svg | 1 + src/icons/extracted/minimax.svg | 1 + src/icons/extracted/mistral.svg | 1 + src/icons/extracted/modelscope-color.svg | 1 + src/icons/extracted/nekocode-icon.png | Bin 0 -> 2852 bytes src/icons/extracted/newapi.svg | 1 + src/icons/extracted/notion.svg | 1 + src/icons/extracted/novita.svg | 11 + src/icons/extracted/nvidia.svg | 1 + src/icons/extracted/ollama.svg | 1 + src/icons/extracted/openai.svg | 1 + src/icons/extracted/opencode-logo-light.svg | 1 + src/icons/extracted/openrouter.svg | 1 + src/icons/extracted/packycode.svg | 1 + src/icons/extracted/palm.svg | 1 + src/icons/extracted/pateway.jpg | Bin 0 -> 7283 bytes src/icons/extracted/perplexity.svg | 1 + src/icons/extracted/pipellm.png | Bin 0 -> 1969 bytes src/icons/extracted/qiniu.png | Bin 0 -> 70218 bytes src/icons/extracted/qwen.svg | 1 + src/icons/extracted/rc.svg | 1 + src/icons/extracted/relaxcode.png | Bin 0 -> 41716 bytes src/icons/extracted/runapi.jpg | Bin 0 -> 9544 bytes src/icons/extracted/shengsuanyun.svg | 4 + src/icons/extracted/siliconflow.svg | 1 + src/icons/extracted/sssaicode.svg | 73 ++ src/icons/extracted/stability.svg | 1 + src/icons/extracted/stepfun.svg | 10 + src/icons/extracted/subrouter.svg | 30 + src/icons/extracted/sudocode-us.png | Bin 0 -> 37023 bytes src/icons/extracted/sudocode.png | Bin 0 -> 5437 bytes src/icons/extracted/tencent.svg | 1 + src/icons/extracted/ucloud.svg | 1 + src/icons/extracted/unity2.png | Bin 0 -> 61909 bytes src/icons/extracted/vercel.svg | 1 + src/icons/extracted/wenxin.svg | 1 + src/icons/extracted/xai.svg | 1 + src/icons/extracted/xiaomimimo.svg | 1 + src/icons/extracted/yi.svg | 1 + src/icons/extracted/zeroone.svg | 1 + src/icons/extracted/zetaapi-icon.png | Bin 0 -> 40569 bytes src/icons/extracted/zhipu.svg | 1 + src/icons/inference.ts | 31 + src/index.css | 50 +- src/types/icon.ts | 16 + src/types/index.ts | 4 + src/vite-env.d.ts | 1 + tailwind.config.js | 23 +- 134 files changed, 2898 insertions(+), 326 deletions(-) create mode 100644 src/components/IconPicker.tsx create mode 100644 src/components/ProviderIcon.tsx create mode 100644 src/components/SettingsModal.tsx create mode 100644 src/hooks/useTheme.ts create mode 100644 src/hooks/useUpdateCheck.ts create mode 100644 src/icons/brands.ts create mode 100644 src/icons/extracted/ClaudeApi.png create mode 100644 src/icons/extracted/TeamoRouter-icon-dark.png create mode 100644 src/icons/extracted/aicodemirror.svg create mode 100644 src/icons/extracted/aicoding.svg create mode 100644 src/icons/extracted/aihubmix-color.svg create mode 100644 src/icons/extracted/algocode.svg create mode 100644 src/icons/extracted/alibaba.svg create mode 100644 src/icons/extracted/amuxapi-icon.svg create mode 100644 src/icons/extracted/anthropic.svg create mode 100644 src/icons/extracted/apikeyfun.png create mode 100644 src/icons/extracted/apinebula_icon.png create mode 100644 src/icons/extracted/atlascloud_icon.png create mode 100644 src/icons/extracted/aws.svg create mode 100644 src/icons/extracted/azure.svg create mode 100644 src/icons/extracted/baidu.svg create mode 100644 src/icons/extracted/bailian.svg create mode 100644 src/icons/extracted/bytedance.svg create mode 100644 src/icons/extracted/byteplus.png create mode 100644 src/icons/extracted/catcoder.svg create mode 100644 src/icons/extracted/ccsub.svg create mode 100644 src/icons/extracted/chatglm.svg create mode 100644 src/icons/extracted/cherryin.png create mode 100644 src/icons/extracted/claude.svg create mode 100644 src/icons/extracted/claudecn.png create mode 100644 src/icons/extracted/claw.svg create mode 100644 src/icons/extracted/cloudflare.svg create mode 100644 src/icons/extracted/code0.png create mode 100644 src/icons/extracted/cohere.svg create mode 100644 src/icons/extracted/copilot.svg create mode 100644 src/icons/extracted/crazyrouter.svg create mode 100644 src/icons/extracted/cubence.svg create mode 100644 src/icons/extracted/deepseek.svg create mode 100644 src/icons/extracted/doubao.svg create mode 100644 src/icons/extracted/eflowcode.png create mode 100644 src/icons/extracted/etok.png create mode 100644 src/icons/extracted/fenno-icon.webp create mode 100644 src/icons/extracted/gemini.svg create mode 100644 src/icons/extracted/gemma.svg create mode 100644 src/icons/extracted/github.svg create mode 100644 src/icons/extracted/githubcopilot.svg create mode 100644 src/icons/extracted/google.svg create mode 100644 src/icons/extracted/googlecloud.svg create mode 100644 src/icons/extracted/grok.svg create mode 100644 src/icons/extracted/hermes.png create mode 100644 src/icons/extracted/huawei.svg create mode 100644 src/icons/extracted/huggingface.svg create mode 100644 src/icons/extracted/hunyuan.svg create mode 100644 src/icons/extracted/huoshan.png create mode 100644 src/icons/extracted/index.ts create mode 100644 src/icons/extracted/kimi.svg create mode 100644 src/icons/extracted/lioncc.svg create mode 100644 src/icons/extracted/longcat-color.svg create mode 100644 src/icons/extracted/mcp.svg create mode 100644 src/icons/extracted/meta.svg create mode 100644 src/icons/extracted/metadata.ts create mode 100644 src/icons/extracted/micu.svg create mode 100644 src/icons/extracted/midjourney.svg create mode 100644 src/icons/extracted/minimax.svg create mode 100644 src/icons/extracted/mistral.svg create mode 100644 src/icons/extracted/modelscope-color.svg create mode 100644 src/icons/extracted/nekocode-icon.png create mode 100644 src/icons/extracted/newapi.svg create mode 100644 src/icons/extracted/notion.svg create mode 100644 src/icons/extracted/novita.svg create mode 100644 src/icons/extracted/nvidia.svg create mode 100644 src/icons/extracted/ollama.svg create mode 100644 src/icons/extracted/openai.svg create mode 100644 src/icons/extracted/opencode-logo-light.svg create mode 100644 src/icons/extracted/openrouter.svg create mode 100644 src/icons/extracted/packycode.svg create mode 100644 src/icons/extracted/palm.svg create mode 100644 src/icons/extracted/pateway.jpg create mode 100644 src/icons/extracted/perplexity.svg create mode 100644 src/icons/extracted/pipellm.png create mode 100644 src/icons/extracted/qiniu.png create mode 100644 src/icons/extracted/qwen.svg create mode 100644 src/icons/extracted/rc.svg create mode 100644 src/icons/extracted/relaxcode.png create mode 100644 src/icons/extracted/runapi.jpg create mode 100644 src/icons/extracted/shengsuanyun.svg create mode 100644 src/icons/extracted/siliconflow.svg create mode 100644 src/icons/extracted/sssaicode.svg create mode 100644 src/icons/extracted/stability.svg create mode 100644 src/icons/extracted/stepfun.svg create mode 100644 src/icons/extracted/subrouter.svg create mode 100644 src/icons/extracted/sudocode-us.png create mode 100644 src/icons/extracted/sudocode.png create mode 100644 src/icons/extracted/tencent.svg create mode 100644 src/icons/extracted/ucloud.svg create mode 100644 src/icons/extracted/unity2.png create mode 100644 src/icons/extracted/vercel.svg create mode 100644 src/icons/extracted/wenxin.svg create mode 100644 src/icons/extracted/xai.svg create mode 100644 src/icons/extracted/xiaomimimo.svg create mode 100644 src/icons/extracted/yi.svg create mode 100644 src/icons/extracted/zeroone.svg create mode 100644 src/icons/extracted/zetaapi-icon.png create mode 100644 src/icons/extracted/zhipu.svg create mode 100644 src/icons/inference.ts create mode 100644 src/types/icon.ts create mode 100644 src/vite-env.d.ts diff --git a/README.md b/README.md index 31a85b8..6413b26 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,8 @@ A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标 用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。原始项目实现了 Kimi Code CLI 的本地 Token 用量统计与会话管理,本项目的 Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)以及会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。 +供应商品牌图标资源与图标选择器实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),本项目复制了其 `src/icons/extracted/` 图标库与 `IconPicker` 交互设计,感谢原作者的开源贡献。 + --- 许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site \ No newline at end of file diff --git a/README_EN.md b/README_EN.md index a02ee2f..659e39b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -341,6 +341,8 @@ A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targ The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The original project implements local token usage analytics and session management for Kimi Code CLI. The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author. +Provider brand icons and the icon picker are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). This project copies its `src/icons/extracted/` icon library and `IconPicker` interaction design. Many thanks to the original author. + --- License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7e58c71..b945b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "kimiswitch", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kimiswitch", - "version": "0.1.0", + "version": "0.3.0", "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-shell": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -1437,6 +1438,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.0.tgz", + "integrity": "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.5", "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", diff --git a/package.json b/package.json index d75e3c1..1a7fd1b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", @@ -15,6 +15,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-shell": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cbe7660..2ea0d7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 085d767..3c469f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 12d6e86..f13d2d7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { 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 { + 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, +} + +fn parse_version(s: &str) -> Vec { + s.trim_start_matches('v') + .split('.') + .filter_map(|p| p.parse::().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 { + 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 { + 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()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 03223cd..3233985 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -106,6 +106,20 @@ pub fn init_db() -> DbResult { [], )?; + // 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 { 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 = row.get(4)?; - let raw_json: Option = row.get(10)?; + let raw_json: Option = 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 { 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> { + 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, diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index 6d90cb0..deeb06d 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -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, }, ); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45f62b2..ce33692 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 6ff5eba..750361b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_color: Option, #[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", &"") .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", &"") .finish() } diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index 7f8cb1a..e67dc60 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -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, }; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a27a74e..5ea39f0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.3.0", + "version": "0.4.0", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 910898e..2dbc390 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useConfig } from "./hooks/useConfig"; +import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { ProviderList } from "./components/ProviderList"; import { ProviderEdit } from "./components/ProviderEdit"; import { DashboardPage } from "./components/dashboard/DashboardPage"; import { SessionsPage } from "./components/sessions/SessionsPage"; +import { SettingsModal } from "./components/SettingsModal"; import { useTranslation } from "./i18n"; import { getDefaultMaxContextSize } from "./lib/model-defaults"; import { getModelRef } from "./lib/models-dev"; @@ -49,6 +51,7 @@ function getInitialAgent(): Agent { export default function App() { const { t, lang, setLang } = useTranslation(); + const { updateInfo, checking, checkNow, lastChecked } = useUpdateCheck(); const [agent] = useState(getInitialAgent); const { config, @@ -64,6 +67,7 @@ export default function App() { const [editingProvider, setEditingProvider] = useState(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); + const [showSettings, setShowSettings] = useState(false); useEffect(() => { @@ -336,10 +340,10 @@ export default function App() { if (loading || !config) { return ( -
+
{t("loading")}
{loadTimeout && ( -
{t("loadTimeout")}
+
{t("loadTimeout")}
)} {error && (
@@ -361,16 +365,16 @@ export default function App() { const currentProvider = config.providers[editingProvider]; return ( -
-
-
+
+
+
+ {/* Language */} + {/* Settings gear */} +
@@ -418,7 +438,7 @@ export default function App() { )} {switchMessage && ( -
+
{switchMessage}
)} @@ -488,7 +508,8 @@ export default function App() { }); }} onModelAdd={() => { - const alias = `新模型[${currentProvider.name}]`; + const safeProvider = currentProvider.name.replace(/\//g, "-"); + const alias = `${safeProvider}/新模型`; updateConfig((cfg) => ({ ...cfg, models: { @@ -525,7 +546,7 @@ export default function App() { onSave={save} /> ) : ( -
+
{t("providerNotFound")}
@@ -580,13 +601,22 @@ export default function App() { alert(err instanceof Error ? err.message : String(err)); } }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none" + className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" > {t("openConfigDir")}
)} + + setShowSettings(false)} + updateInfo={updateInfo} + checking={checking} + onCheckUpdate={checkNow} + lastChecked={lastChecked} + />
); } diff --git a/src/components/AgentSettingsPanel.tsx b/src/components/AgentSettingsPanel.tsx index dcc0f34..26acd57 100644 --- a/src/components/AgentSettingsPanel.tsx +++ b/src/components/AgentSettingsPanel.tsx @@ -58,7 +58,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro return (
-

{t("agentSettings")}

+

{t("agentSettings")}

updateThinking({ enabled: checked })} />
- {t("thinkingLevel")} + {t("thinkingLevel")} ({ key: lvl, @@ -86,7 +86,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro disabled={!thinkingEnabled} onChange={(checked) => updateThinking({ keep: checked ? "all" : false })} /> -

{t("thinkingContextHint")}

+

{t("thinkingContextHint")}

@@ -120,7 +120,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro {(settings.permission?.rules ?? []).map((rule, idx) => (
{ @@ -171,7 +171,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro { decision: "allow", pattern: "" }, ]); }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" + className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2" > {t("addRule")} @@ -184,7 +184,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro { decision: "deny", pattern: "Bash(rm -rf*)" }, ]); }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" + className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2" > {t("addCommonRules")} @@ -198,7 +198,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro { @@ -214,7 +214,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro { @@ -225,7 +225,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro /> { @@ -236,7 +236,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro /> { @@ -267,7 +267,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro onClick={() => { setHooks([...(settings.hooks ?? []), { event: "", matcher: "", command: "" }]); }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" + className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2" > {t("addHook")} @@ -278,8 +278,8 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro function Card({ title, children }: { title: string; children: ReactNode }) { return ( -
-

{title}

+
+

{title}

{children}
); @@ -299,7 +299,7 @@ function Checkbox({ return ( @@ -324,8 +324,8 @@ function NumberField({ onChange: (value: number | undefined) => void; }) { return ( -