# KimiSwitch Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Windows x64 desktop config manager for Kimi Code CLI using Tauri v2, supporting multi-provider editing, profile switching, and MSI packaging. **Architecture:** Rust backend handles all file I/O, TOML parsing/editing via `toml_edit`, validation, and profile management. TypeScript/React frontend renders a three-pane UI and invokes Rust commands via Tauri. Tauri v2 bundler produces the Windows MSI. **Tech Stack:** Tauri v2, Rust, TypeScript, React, Tailwind CSS, `toml_edit`, `indexmap`, `serde`. --- ## File Structure ``` D:/AIGC/KimiSwitch ├── src-tauri/ │ ├── Cargo.toml │ ├── tauri.conf.json │ ├── capabilities/ │ │ └── default.json │ └── src/ │ ├── main.rs │ ├── lib.rs │ ├── models.rs │ ├── config_io.rs │ ├── validators.rs │ ├── profile_manager.rs │ └── commands.rs ├── src/ │ ├── App.tsx │ ├── main.tsx │ ├── index.css │ ├── components/ │ │ ├── TopBar.tsx │ │ ├── ProviderList.tsx │ │ ├── ProviderForm.tsx │ │ ├── ModelList.tsx │ │ ├── ModelForm.tsx │ │ └── BottomToolbar.tsx │ ├── hooks/ │ │ └── useConfig.ts │ └── types/ │ └── index.ts ├── tests/ │ ├── config_io.rs │ ├── profile_manager.rs │ └── validators.rs ├── package.json ├── tsconfig.json ├── vite.config.ts ├── index.html ├── README.md └── .gitignore ``` --- ## Task 1: Initialize Tauri v2 Project **Files:** - Create: `package.json` - Create: `vite.config.ts` - Create: `tsconfig.json` - Create: `index.html` - Create: `src/main.tsx` - Create: `src/index.css` - Create: `src-tauri/Cargo.toml` - Create: `src-tauri/tauri.conf.json` - Create: `src-tauri/src/main.rs` - [ ] **Step 1: Create root package.json** ```json { "name": "kimiswitch", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", "tauri-dev": "tauri dev", "tauri-build": "tauri build" }, "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@tauri-apps/cli": "^2.0.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.41", "tailwindcss": "^3.4.10", "typescript": "^5.5.4", "vite": "^5.4.2" } } ``` - [ ] **Step 2: Create vite.config.ts** ```typescript import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig(async () => ({ plugins: [react()], clearScreen: false, server: { port: 1420, strictPort: true, watch: { ignored: ["**/src-tauri/**"] }, }, envPrefix: ["VITE_", "TAURI_"], })); ``` - [ ] **Step 3: Create tsconfig.json and tsconfig.node.json** `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ``` `tsconfig.node.json`: ```json { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true }, "include": ["vite.config.ts"] } ``` - [ ] **Step 4: Create index.html** ```html KimiSwitch
``` - [ ] **Step 5: Create src/main.tsx** ```tsx import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( ); ``` - [ ] **Step 6: Create src/index.css** ```css @tailwind base; @tailwind components; @tailwind utilities; html, body, #root { height: 100%; margin: 0; overflow: hidden; } ``` - [ ] **Step 7: Create src-tauri/Cargo.toml** ```toml [package] name = "kimiswitch" version = "0.1.0" description = "Kimi Code CLI config manager" authors = ["you"] edition = "2021" [build-dependencies] tauri-build = { version = "2.0.0", features = [] } [dependencies] tauri = { version = "2.0.0", features = [] } tauri-plugin-shell = "2.0.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml_edit = "0.22" indexmap = { version = "2.2", features = ["serde"] } dirs = "5.0" thiserror = "1.0" chrono = "0.4" anyhow = "1.0" [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] ``` - [ ] **Step 8: Create src-tauri/tauri.conf.json** ```json { "productName": "KimiSwitch", "version": "0.1.0", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "app": { "windows": [ { "title": "KimiSwitch", "width": 1200, "height": 800, "minWidth": 1000, "minHeight": 700, "resizable": true, "fullscreen": false } ], "security": { "csp": null } }, "bundle": { "active": true, "targets": ["msi"], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ] } } ``` - [ ] **Step 9: Create src-tauri/src/main.rs** ```rust #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { kimiswitch_lib::run(); } ``` - [ ] **Step 10: Install dependencies and verify dev build** Run: ```bash npm install cd src-tauri && cargo fetch ``` Expected: `node_modules/` and `src-tauri/target/` populated without errors. --- ## Task 2: Define Rust Models **Files:** - Create: `src-tauri/src/models.rs` - Create: `src-tauri/src/lib.rs` - [ ] **Step 1: Write models.rs** ```rust use indexmap::IndexMap; use serde::{Deserialize, Serialize}; pub use toml_edit::Table; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProviderType { Kimi, Anthropic, Openai, #[serde(rename = "openai_responses")] OpenaiResponses, #[serde(rename = "google-genai")] GoogleGenai, Vertexai, } impl ProviderType { pub fn as_str(&self) -> &'static str { match self { ProviderType::Kimi => "kimi", ProviderType::Anthropic => "anthropic", ProviderType::Openai => "openai", ProviderType::OpenaiResponses => "openai_responses", ProviderType::GoogleGenai => "google-genai", ProviderType::Vertexai => "vertexai", } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provider { pub name: String, pub provider_type: ProviderType, pub base_url: Option, pub api_key: Option, pub env: IndexMap, #[serde(skip)] pub raw_other: Table, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Model { pub alias: String, pub provider: String, pub model: String, pub max_context_size: u64, pub display_name: Option, #[serde(skip)] pub raw_other: Table, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub default_model: Option, pub providers: IndexMap, pub models: IndexMap, #[serde(skip)] pub raw_other: Table, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileSummary { pub name: String, pub filename: String, pub is_active: bool, } #[derive(Debug, Clone)] pub struct Profile { pub name: String, pub filename: String, pub config: Config, pub is_active: bool, } ``` - [ ] **Step 2: Write lib.rs module exports** ```rust pub mod commands; pub mod config_io; pub mod models; pub mod profile_manager; pub mod validators; use tauri::Manager; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ commands::load_config, commands::save_config, commands::list_profiles, commands::load_profile, commands::save_profile, commands::switch_profile, commands::rename_profile, commands::delete_profile, commands::open_config_dir, commands::get_app_version, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` - [ ] **Step 3: Verify compilation** Run: ```bash cd src-tauri && cargo check ``` Expected: `Finished dev [unoptimized + debuginfo] target(s)`. --- ## Task 3: Implement Config I/O **Files:** - Create: `src-tauri/src/config_io.rs` - Test: `tests/config_io.rs` - [ ] **Step 1: Implement config_io.rs read path** ```rust use crate::models::{Config, Model, Provider, ProviderType}; use indexmap::IndexMap; use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, Value}; pub fn config_dir() -> PathBuf { if let Ok(home) = std::env::var("KIMI_CODE_HOME") { PathBuf::from(home) } else { dirs::home_dir() .expect("failed to get home dir") .join(".kimi-code") } } pub fn config_path() -> PathBuf { config_dir().join("config.toml") } fn item_to_string(item: &Item) -> Option { item.as_str().map(|s| s.to_string()) } fn item_to_u64(item: &Item) -> Option { item.as_integer().map(|i| i as u64) } fn parse_provider_table(name: &str, table: &Table) -> Provider { let provider_type_str = table .get("type") .and_then(|i| i.as_str()) .unwrap_or("kimi"); let provider_type = match provider_type_str { "kimi" => ProviderType::Kimi, "anthropic" => ProviderType::Anthropic, "openai" => ProviderType::Openai, "openai_responses" => ProviderType::OpenaiResponses, "google-genai" => ProviderType::GoogleGenai, "vertexai" => ProviderType::Vertexai, _ => ProviderType::Kimi, }; let mut raw_other = Table::new(); let mut env = IndexMap::new(); let mut base_url = None; let mut api_key = None; for (key, value) in table.iter() { match key { "type" => {} "base_url" => base_url = item_to_string(value), "api_key" => api_key = item_to_string(value), "env" => { if let Some(env_table) = value.as_table() { for (k, v) in env_table.iter() { if let Some(s) = item_to_string(v) { env.insert(k.to_string(), s); } } } } _ => { raw_other.insert(key, value.clone()); } } } Provider { name: name.to_string(), provider_type, base_url, api_key, env, raw_other, } } fn parse_model_table(alias: &str, table: &Table) -> Model { let mut raw_other = Table::new(); let mut provider = String::new(); let mut model = String::new(); let mut max_context_size = 0; let mut display_name = None; for (key, value) in table.iter() { match key { "provider" => provider = item_to_string(value).unwrap_or_default(), "model" => model = item_to_string(value).unwrap_or_default(), "max_context_size" => max_context_size = item_to_u64(value).unwrap_or(0), "display_name" => display_name = item_to_string(value), _ => { raw_other.insert(key, value.clone()); } } } Model { alias: alias.to_string(), provider, model, max_context_size, display_name, raw_other, } } pub fn load_config>(path: P) -> anyhow::Result { let content = std::fs::read_to_string(path)?; let doc = content.parse::()?; let mut providers = IndexMap::new(); let mut models = IndexMap::new(); let mut default_model = None; let mut raw_other = Table::new(); for (key, value) in doc.iter() { match key { "default_model" => default_model = item_to_string(value), "providers" => { if let Some(table) = value.as_table() { for (name, item) in table.iter() { if let Some(provider_table) = item.as_table() { let provider = parse_provider_table(name, provider_table); providers.insert(name.to_string(), provider); } } } } "models" => { if let Some(table) = value.as_table() { for (alias, item) in table.iter() { if let Some(model_table) = item.as_table() { let model = parse_model_table(alias, model_table); models.insert(alias.to_string(), model); } } } } _ => { raw_other.insert(key, value.clone()); } } } Ok(Config { default_model, providers, models, raw_other, }) } ``` - [ ] **Step 2: Implement config_io.rs write path** ```rust pub fn save_config>(path: P, config: &Config) -> anyhow::Result<()> { let path = path.as_ref(); if path.exists() { let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S_%3f"); let backup_path = path.with_extension(format!("toml.bak.{}", timestamp)); std::fs::copy(path, backup_path)?; } let mut doc = if path.exists() { std::fs::read_to_string(path)?.parse::()? } else { DocumentMut::new() }; if let Some(dm) = &config.default_model { doc["default_model"] = Item::Value(Value::String(toml_edit::Formatted::new(dm.clone()))); } else { doc.remove("default_model"); } { let providers_table = doc["providers"].or_insert(Table::new()); let providers_table = providers_table.as_table_mut().unwrap(); providers_table.clear(); for (name, provider) in &config.providers { let mut table = Table::new(); table.insert( "type", Item::Value(Value::String(toml_edit::Formatted::new( provider.provider_type.as_str().to_string(), ))), ); if let Some(url) = &provider.base_url { table.insert( "base_url", Item::Value(Value::String(toml_edit::Formatted::new(url.clone()))), ); } if let Some(key) = &provider.api_key { table.insert( "api_key", Item::Value(Value::String(toml_edit::Formatted::new(key.clone()))), ); } if !provider.env.is_empty() { let mut env_table = Table::new(); for (k, v) in &provider.env { env_table.insert( k, Item::Value(Value::String(toml_edit::Formatted::new(v.clone()))), ); } table.insert("env", Item::Table(env_table)); } for (k, v) in provider.raw_other.iter() { table.insert(k, v.clone()); } providers_table.insert(name, Item::Table(table)); } } { let models_table = doc["models"].or_insert(Table::new()); let models_table = models_table.as_table_mut().unwrap(); models_table.clear(); for (alias, model) in &config.models { let mut table = Table::new(); table.insert( "provider", Item::Value(Value::String(toml_edit::Formatted::new( model.provider.clone(), ))), ); table.insert( "model", Item::Value(Value::String(toml_edit::Formatted::new(model.model.clone()))), ); table.insert( "max_context_size", Item::Value(Value::Integer(toml_edit::Formatted::new( model.max_context_size as i64, ))), ); if let Some(dn) = &model.display_name { table.insert( "display_name", Item::Value(Value::String(toml_edit::Formatted::new(dn.clone()))), ); } for (k, v) in model.raw_other.iter() { table.insert(k, v.clone()); } models_table.insert(alias, Item::Table(table)); } } for (k, v) in config.raw_other.iter() { doc.insert(k, v.clone()); } let tmp_path = path.with_extension("tmp"); std::fs::write(&tmp_path, doc.to_string())?; std::fs::rename(&tmp_path, path)?; Ok(()) } ``` - [ ] **Step 3: Run cargo check** Run: ```bash cd src-tauri && cargo check ``` Expected: compilation succeeds. --- ## Task 4: Implement Validators **Files:** - Create: `src-tauri/src/validators.rs` - Test: `tests/validators.rs` - [ ] **Step 1: Write validators.rs** ```rust use crate::models::{Config, Provider, ProviderType}; #[derive(Debug, thiserror::Error)] pub enum ValidationError { #[error("Provider name is empty")] EmptyProviderName, #[error("Duplicate provider name: {0}")] DuplicateProviderName(String), #[error("Invalid provider type for '{0}'")] InvalidProviderType(String), #[error("Provider '{0}' missing credentials (api_key or env key)")] MissingCredentials(String), #[error("Vertex provider '{0}' missing GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_LOCATION")] MissingVertexProject(String), #[error("Model alias is empty")] EmptyModelAlias, #[error("Duplicate model alias: {0}")] DuplicateModelAlias(String), #[error("Model '{0}' references unknown provider '{1}'")] UnknownModelProvider(String, String), #[error("Model '{0}' has empty model ID")] EmptyModelId(String), #[error("Model '{0}' has invalid max_context_size")] InvalidMaxContextSize(String), } fn expected_api_key_key(provider_type: &ProviderType) -> &'static str { match provider_type { ProviderType::Kimi => "KIMI_API_KEY", ProviderType::Anthropic => "ANTHROPIC_API_KEY", ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY", ProviderType::GoogleGenai => "GOOGLE_API_KEY", ProviderType::Vertexai => "VERTEXAI_API_KEY", } } pub fn validate_config(config: &Config) -> Result<(), Vec> { let mut errors = Vec::new(); let mut provider_names = std::collections::HashSet::new(); for provider in config.providers.values() { if provider.name.trim().is_empty() { errors.push(ValidationError::EmptyProviderName); continue; } if !provider_names.insert(provider.name.clone()) { errors.push(ValidationError::DuplicateProviderName(provider.name.clone())); } } for provider in config.providers.values() { let has_direct_key = provider.api_key.as_ref().map_or(false, |s| !s.is_empty()); let has_env_key = provider .env .get(expected_api_key_key(&provider.provider_type)) .map_or(false, |s| !s.is_empty()); if !matches!(provider.provider_type, ProviderType::Vertexai) { if !has_direct_key && !has_env_key { errors.push(ValidationError::MissingCredentials(provider.name.clone())); } } if matches!(provider.provider_type, ProviderType::Vertexai) { let has_project = provider .env .get("GOOGLE_CLOUD_PROJECT") .map_or(false, |s| !s.is_empty()); let has_location = provider .env .get("GOOGLE_CLOUD_LOCATION") .map_or(false, |s| !s.is_empty()); if !has_project || !has_location { errors.push(ValidationError::MissingVertexProject(provider.name.clone())); } } } let mut model_aliases = std::collections::HashSet::new(); for model in config.models.values() { if model.alias.trim().is_empty() { errors.push(ValidationError::EmptyModelAlias); continue; } if !model_aliases.insert(model.alias.clone()) { errors.push(ValidationError::DuplicateModelAlias(model.alias.clone())); } } for model in config.models.values() { if !config.providers.contains_key(&model.provider) { errors.push(ValidationError::UnknownModelProvider( model.alias.clone(), model.provider.clone(), )); } if model.model.trim().is_empty() { errors.push(ValidationError::EmptyModelId(model.alias.clone())); } if model.max_context_size == 0 { errors.push(ValidationError::InvalidMaxContextSize(model.alias.clone())); } } if errors.is_empty() { Ok(()) } else { Err(errors) } } ``` - [ ] **Step 2: Run cargo test for validators** Run: ```bash cd src-tauri && cargo test validators ``` Expected: tests pass (write at least 3 tests: valid config, missing credentials, duplicate alias). --- ## Task 5: Implement Profile Manager **Files:** - Create: `src-tauri/src/profile_manager.rs` - Test: `tests/profile_manager.rs` - [ ] **Step 1: Implement safe filename function** ```rust use crate::config_io::{config_dir, config_path}; use crate::models::{Config, Profile, ProfileSummary}; pub fn safe_filename(name: &str) -> String { let mut result = String::new(); let mut prev_dash = true; for ch in name.chars() { if ch.is_ascii_alphanumeric() { result.push(ch.to_ascii_lowercase()); prev_dash = false; } else if !prev_dash { result.push('-'); prev_dash = true; } } if result.ends_with('-') { result.pop(); } if result.is_empty() { // Chinese or special-character names fall back to a short hash. use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); name.hash(&mut hasher); result = format!("profile-{}", hasher.finish() % 100000); } result } ``` - [ ] **Step 2: Implement profile directory and file helpers** ```rust pub fn profiles_dir() -> std::path::PathBuf { config_dir().join("profiles") } pub fn profile_path(filename: &str) -> std::path::PathBuf { profiles_dir().join(filename) } ``` - [ ] **Step 3: Implement profile listing and activation detection** ```rust use crate::config_io::load_config; pub fn list_profiles() -> anyhow::Result> { std::fs::create_dir_all(profiles_dir())?; let current_config = load_config(config_path())?; let mut profiles = vec![Profile { name: "default".to_string(), filename: "default".to_string(), config: current_config.clone(), is_active: true, }]; for entry in std::fs::read_dir(profiles_dir())? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("toml") { continue; } let filename = path.file_stem().unwrap().to_string_lossy().to_string(); let config = load_config(&path)?; let is_active = configs_equal(¤t_config, &config); profiles.push(Profile { name: filename.clone(), filename: format!("{}.toml", filename), config, is_active, }); } Ok(profiles) } fn configs_equal(a: &Config, b: &Config) -> bool { a.default_model == b.default_model && a.providers.len() == b.providers.len() && a.models.len() == b.models.len() && a.providers.iter().all(|(k, v)| { b.providers.get(k).map_or(false, |other| { v.provider_type == other.provider_type && v.base_url == other.base_url && v.api_key == other.api_key && v.env == other.env }) }) && a.models.iter().all(|(k, v)| { b.models.get(k).map_or(false, |other| { v.provider == other.provider && v.model == other.model && v.max_context_size == other.max_context_size && v.display_name == other.display_name }) }) } ``` - [ ] **Step 4: Implement profile CRUD and switch** ```rust use crate::config_io::save_config; pub fn save_profile(filename: &str, config: &Config) -> anyhow::Result<()> { std::fs::create_dir_all(profiles_dir())?; let path = profile_path(filename); save_config(&path, config) } pub fn switch_profile(filename: &str) -> anyhow::Result { let path = profile_path(filename); let config = load_config(&path)?; save_config(config_path(), &config)?; Ok(config) } pub fn rename_profile(old_filename: &str, new_name: &str) -> anyhow::Result { let new_filename = format!("{}.toml", safe_filename(new_name)); let old_path = profile_path(old_filename); let new_path = profile_path(&new_filename); if new_path.exists() { anyhow::bail!("profile already exists"); } std::fs::rename(old_path, new_path)?; Ok(new_filename) } pub fn delete_profile(filename: &str) -> anyhow::Result<()> { let path = profile_path(filename); std::fs::remove_file(path)?; Ok(()) } ``` - [ ] **Step 5: Run cargo test for profile_manager** Run: ```bash cd src-tauri && cargo test profile_manager ``` Expected: tests pass. --- ## Task 6: Implement Tauri Commands **Files:** - Create: `src-tauri/src/commands.rs` - [ ] **Step 1: Write commands.rs** ```rust use crate::config_io::{config_dir, config_path, load_config, save_config}; use crate::models::{Config, ProfileSummary, Table}; use crate::profile_manager; use crate::validators::validate_config; /// Merge frontend-managed fields into the existing on-disk config, /// preserving raw_other (capabilities, overrides, custom_headers, [thinking], etc.). fn merge_with_existing(path: &std::path::PathBuf, incoming: Config) -> Result { let existing = load_config(path).unwrap_or_else(|_| Config { default_model: None, providers: Default::default(), models: Default::default(), raw_other: Table::new(), }); Ok(Config { default_model: incoming.default_model, providers: incoming.providers, models: incoming.models, raw_other: existing.raw_other, }) } #[tauri::command] pub fn load_config_command() -> Result { load_config(config_path()).map_err(|e| e.to_string()) } #[tauri::command] pub fn save_config_command(config: Config) -> Result<(), String> { if let Err(errors) = validate_config(&config) { return Err(errors.iter().map(|e| e.to_string()).collect::>().join("\n")); } let merged = merge_with_existing(&config_path(), config)?; save_config(config_path(), &merged).map_err(|e| e.to_string()) } #[tauri::command] pub fn list_profiles() -> Result, String> { let profiles = profile_manager::list_profiles().map_err(|e| e.to_string())?; Ok(profiles .into_iter() .map(|p| ProfileSummary { name: p.name, filename: p.filename, is_active: p.is_active, }) .collect()) } #[tauri::command] pub fn load_profile(filename: String) -> Result { profile_manager::load_profile(&filename).map_err(|e| e.to_string()) } #[tauri::command] pub fn save_profile(filename: String, config: Config) -> Result<(), String> { let path = profile_manager::profile_path(&filename); let merged = merge_with_existing(&path, config)?; profile_manager::save_profile(&filename, &merged).map_err(|e| e.to_string()) } #[tauri::command] pub fn switch_profile(filename: String) -> Result { profile_manager::switch_profile(&filename).map_err(|e| e.to_string()) } #[tauri::command] pub fn rename_profile(old_filename: String, new_name: String) -> Result { profile_manager::rename_profile(&old_filename, &new_name).map_err(|e| e.to_string()) } #[tauri::command] pub fn delete_profile(filename: String) -> Result<(), String> { profile_manager::delete_profile(&filename).map_err(|e| e.to_string()) } #[tauri::command] pub fn open_config_dir() -> Result<(), String> { let path = config_dir(); std::process::Command::new("explorer") .arg(path) .spawn() .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_app_version() -> String { env!("CARGO_PKG_VERSION").to_string() } ``` - [ ] **Step 2: Add missing profile_manager::load_profile** Add to `profile_manager.rs`: ```rust pub fn load_profile(filename: &str) -> anyhow::Result { load_config(profile_path(filename)) } ``` - [ ] **Step 3: Run cargo check** Run: ```bash cd src-tauri && cargo check ``` Expected: compilation succeeds. --- ## Task 7: Configure Tauri v2 Permissions **Files:** - Create: `src-tauri/capabilities/default.json` - Modify: `src-tauri/tauri.conf.json` - [ ] **Step 1: Create capabilities/default.json** ```json { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for KimiSwitch", "windows": ["main"], "permissions": [ "core:default", "shell:allow-open", { "identifier": "fs:allow-read-file", "allow": [ { "path": "$HOME/.kimi-code/config.toml" }, { "path": "$HOME/.kimi-code/profiles/*" } ] }, { "identifier": "fs:allow-write-file", "allow": [ { "path": "$HOME/.kimi-code/config.toml" }, { "path": "$HOME/.kimi-code/profiles/*" } ] }, { "identifier": "fs:allow-read-dir", "allow": [ { "path": "$HOME/.kimi-code" }, { "path": "$HOME/.kimi-code/profiles" } ] }, { "identifier": "fs:allow-create-dir", "allow": [ { "path": "$HOME/.kimi-code" }, { "path": "$HOME/.kimi-code/profiles" } ] } ] } ``` - [ ] **Step 2: Update tauri.conf.json bundle config** Ensure `"targets": ["msi"]` is set and add windows-specific config: ```json "bundle": { "active": true, "targets": ["msi"], "windows": { "webviewInstallMode": { "type": "downloadBootstrapper" }, "nsis": null }, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ] } ``` - [ ] **Step 3: Add placeholder icons** Create simple PNG/ICO icons or copy from Tauri default. For now, create empty files as placeholders. Run: ```bash mkdir -p src-tauri/icons touch src-tauri/icons/32x32.png src-tauri/icons/128x128.png src-tauri/icons/128x128@2x.png src-tauri/icons/icon.ico ``` Note: Replace with real icons before final build. --- ## Task 8: Frontend Types and useConfig Hook **Files:** - Create: `src/types/index.ts` - Create: `src/hooks/useConfig.ts` - [ ] **Step 1: Create src/types/index.ts** ```typescript export type ProviderType = | "kimi" | "anthropic" | "openai" | "openai_responses" | "google-genai" | "vertexai"; export interface Provider { name: string; provider_type: ProviderType; base_url: string | null; api_key: string | null; env: Record; } export interface Model { alias: string; provider: string; model: string; max_context_size: number; display_name: string | null; } export interface Config { default_model: string | null; providers: Record; models: Record; } export interface ProfileSummary { name: string; filename: string; is_active: boolean; } ``` - [ ] **Step 2: Create src/hooks/useConfig.ts** ```typescript import { useState, useEffect, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { Config, ProfileSummary, Provider, Model } from "../types"; export function useConfig() { const [config, setConfig] = useState(null); const [profiles, setProfiles] = useState([]); const [dirty, setDirty] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const refresh = useCallback(async () => { setLoading(true); try { const [cfg, profs] = await Promise.all([ invoke("load_config_command"), invoke("list_profiles"), ]); setConfig(cfg); setProfiles(profs); setError(null); setDirty(false); } catch (e) { setError(String(e)); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const updateConfig = useCallback((updater: (cfg: Config) => Config) => { setConfig((prev) => (prev ? updater(prev) : prev)); setDirty(true); }, []); const save = useCallback(async () => { if (!config) return; try { await invoke("save_config_command", { config }); setDirty(false); await refresh(); } catch (e) { setError(String(e)); } }, [config, refresh]); const switchProfile = useCallback( async (filename: string) => { try { const cfg = await invoke("switch_profile", { filename }); setConfig(cfg); setDirty(false); await refresh(); } catch (e) { setError(String(e)); } }, [refresh] ); return { config, profiles, dirty, error, loading, refresh, save, switchProfile, updateConfig, }; } ``` --- ## Task 9: Build TopBar Component **Files:** - Create: `src/components/TopBar.tsx` - [ ] **Step 1: Write TopBar.tsx** ```tsx import { useState, useEffect } from "react"; import type { Config, ProfileSummary, ProviderType } from "../types"; interface TopBarProps { config: Config; profiles: ProfileSummary[]; selectedProvider: string; selectedModel: string; onProviderChange: (name: string) => void; onModelChange: (alias: string) => void; onSetDefault: () => void; onProfileAction: (action: "new" | "duplicate" | "rename" | "delete" | "switch", filename: string) => void; } export function TopBar({ config, profiles, selectedProvider, selectedModel, onProviderChange, onModelChange, onSetDefault, onProfileAction, }: TopBarProps) { const [profileFilename, setProfileFilename] = useState(""); const providerOptions = Object.values(config.providers); const modelOptions = Object.values(config.models).filter( (m) => m.provider === selectedProvider ); useEffect(() => { const active = profiles.find((p) => p.is_active); setProfileFilename(active?.filename || profiles[0]?.filename || ""); }, [profiles]); return (
); } ``` --- ## Task 10: Build ProviderList and ProviderForm **Files:** - Create: `src/components/ProviderList.tsx` - Create: `src/components/ProviderForm.tsx` - [ ] **Step 1: Write ProviderList.tsx** ```tsx import type { Provider } from "../types"; interface ProviderListProps { providers: Provider[]; selected: string; onSelect: (name: string) => void; onAdd: () => void; onDelete: (name: string) => void; } export function ProviderList({ providers, selected, onSelect, onAdd, onDelete }: ProviderListProps) { return (
供应商
{providers.map((p) => (
onSelect(p.name)} > {p.name}
))}
); } ``` - [ ] **Step 2: Write ProviderForm.tsx** ```tsx import type { Provider, ProviderType } from "../types"; const PROVIDER_TYPES: ProviderType[] = [ "kimi", "anthropic", "openai", "openai_responses", "google-genai", "vertexai", ]; interface ProviderFormProps { provider: Provider; onChange: (provider: Provider) => void; } function credentialEnvKeys(type: ProviderType): { apiKey: string; baseUrl: string } { switch (type) { case "kimi": return { apiKey: "KIMI_API_KEY", baseUrl: "KIMI_BASE_URL" }; case "anthropic": return { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }; case "openai": case "openai_responses": return { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" }; case "google-genai": return { apiKey: "GOOGLE_API_KEY", baseUrl: "GOOGLE_GEMINI_BASE_URL" }; case "vertexai": return { apiKey: "VERTEXAI_API_KEY", baseUrl: "GOOGLE_VERTEX_BASE_URL" }; } } export function ProviderForm({ provider, onChange }: ProviderFormProps) { const useDirect = !!(provider.api_key || provider.base_url); const keys = credentialEnvKeys(provider.provider_type); const setUseDirect = (direct: boolean) => { if (direct === useDirect) return; if (direct) { // Move credentials from env to direct fields const env = { ...provider.env }; const apiKey = env[keys.apiKey] || ""; const baseUrl = env[keys.baseUrl] || ""; delete env[keys.apiKey]; delete env[keys.baseUrl]; onChange({ ...provider, api_key: apiKey || null, base_url: baseUrl || null, env, }); } else { // Move credentials from direct fields to env const env = { ...provider.env }; if (provider.api_key) env[keys.apiKey] = provider.api_key; if (provider.base_url) env[keys.baseUrl] = provider.base_url; onChange({ ...provider, api_key: null, base_url: null, env, }); } }; const update = (patch: Partial) => { onChange({ ...provider, ...patch }); }; return (
update({ base_url: e.target.value || null })} />
update({ api_key: e.target.value || null })} />
update({ env })} disabled={useDirect} />
); } function EnvTable({ env, onChange, disabled, }: { env: Record; onChange: (env: Record) => void; disabled: boolean; }) { const entries = Object.entries(env); return (
{entries.map(([k, v], idx) => (
{ const newEnv = { ...env }; delete newEnv[k]; newEnv[e.target.value] = v; onChange(newEnv); }} /> onChange({ ...env, [k]: e.target.value })} />
))}
); } ``` --- ## Task 11: Build ModelList and ModelForm **Files:** - Create: `src/components/ModelList.tsx` - Create: `src/components/ModelForm.tsx` - [ ] **Step 1: Write ModelList.tsx** ```tsx import type { Model } from "../types"; interface ModelListProps { models: Model[]; selected: string; onSelect: (alias: string) => void; onAdd: () => void; onDelete: (alias: string) => void; } export function ModelList({ models, selected, onSelect, onAdd, onDelete }: ModelListProps) { return (
模型别名
{models.map((m) => (
onSelect(m.alias)} > {m.alias}
))}
); } ``` - [ ] **Step 2: Write ModelForm.tsx** ```tsx import type { Model } from "../types"; interface ModelFormProps { model: Model; providers: string[]; onChange: (model: Model) => void; onSetDefault: () => void; } export function ModelForm({ model, providers, onChange, onSetDefault }: ModelFormProps) { return (
onChange({ ...model, model: e.target.value })} />
onChange({ ...model, max_context_size: Number(e.target.value) }) } />
onChange({ ...model, display_name: e.target.value || null }) } />
); } ``` --- ## Task 12: Build BottomToolbar and App.tsx **Files:** - Create: `src/components/BottomToolbar.tsx` - Create: `src/App.tsx` - [ ] **Step 1: Write BottomToolbar.tsx** ```tsx interface BottomToolbarProps { dirty: boolean; onSave: () => void; onReload: () => void; onSaveAsProfile: () => void; onOpenDir: () => void; } export function BottomToolbar({ dirty, onSave, onReload, onSaveAsProfile, onOpenDir, }: BottomToolbarProps) { return (
{dirty && 有未保存修改}
); } ``` - [ ] **Step 2: Write App.tsx** ```tsx import { useState, useMemo } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useConfig } from "./hooks/useConfig"; import { TopBar } from "./components/TopBar"; import { ProviderList } from "./components/ProviderList"; import { ProviderForm } from "./components/ProviderForm"; import { ModelList } from "./components/ModelList"; import { ModelForm } from "./components/ModelForm"; import { BottomToolbar } from "./components/BottomToolbar"; import type { Config, Provider, Model } from "./types"; export default function App() { const { config, profiles, dirty, error, loading, refresh, save, switchProfile, updateConfig, } = useConfig(); const [selectedProvider, setSelectedProvider] = useState(""); const [selectedModel, setSelectedModel] = useState(""); const providers = useMemo( () => (config ? Object.values(config.providers) : []), [config] ); const models = useMemo( () => (config ? Object.values(config.models) : []), [config] ); const filteredModels = useMemo( () => models.filter((m) => m.provider === selectedProvider), [models, selectedProvider] ); if (loading || !config) { return
加载中...
; } const handleProviderSelect = (name: string) => { setSelectedProvider(name); const firstModel = models.find((m) => m.provider === name); setSelectedModel(firstModel?.alias || ""); }; const handleSetDefault = () => { if (!selectedModel) return; updateConfig((cfg) => ({ ...cfg, default_model: selectedModel })); }; const handleAddProvider = () => { const name = `provider-${providers.length + 1}`; updateConfig((cfg) => ({ ...cfg, providers: { ...cfg.providers, [name]: { name, provider_type: "kimi", base_url: null, api_key: null, env: {}, }}, })); setSelectedProvider(name); }; const handleUpdateProvider = (provider: Provider) => { updateConfig((cfg) => ({ ...cfg, providers: { ...cfg.providers, [provider.name]: provider }, })); }; const handleDeleteProvider = (name: string) => { updateConfig((cfg) => { const providers = { ...cfg.providers }; delete providers[name]; return { ...cfg, providers }; }); if (selectedProvider === name) { setSelectedProvider(""); setSelectedModel(""); } }; const handleAddModel = () => { const alias = `model-${models.length + 1}`; updateConfig((cfg) => ({ ...cfg, models: { ...cfg.models, [alias]: { alias, provider: selectedProvider || Object.keys(cfg.providers)[0] || "", model: "", max_context_size: 262144, display_name: null, }, }, })); setSelectedModel(alias); }; const handleUpdateModel = (model: Model) => { updateConfig((cfg) => ({ ...cfg, models: { ...cfg.models, [model.alias]: model }, })); }; const handleDeleteModel = (alias: string) => { updateConfig((cfg) => { const models = { ...cfg.models }; delete models[alias]; return { ...cfg, models }; }); if (selectedModel === alias) setSelectedModel(""); }; const handleProfileAction = async ( action: "new" | "duplicate" | "rename" | "delete" | "switch", filename: string ) => { if (action === "switch") { await switchProfile(filename); return; } if (action === "new") { const name = prompt("输入 Profile 名称")?.trim(); if (!name) return; const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); await invoke("save_profile", { filename: `${safe}.toml`, config, }); await refresh(); return; } if (filename === "default") { alert("default Profile 不可进行复制/重命名/删除"); return; } if (action === "duplicate") { const cfg = await invoke("load_profile", { filename }); const base = filename.replace(/\.toml$/, ""); const newFilename = `${base}-copy.toml`; await invoke("save_profile", { filename: newFilename, config: cfg }); await refresh(); } else if (action === "rename") { const name = prompt("输入新 Profile 名称")?.trim(); if (!name) return; await invoke("rename_profile", { oldFilename: filename, newName: name }); await refresh(); } else if (action === "delete") { if (!confirm(`确定删除 Profile ${filename}?`)) return; await invoke("delete_profile", { filename }); await refresh(); } }; const currentProvider = config.providers[selectedProvider]; const currentModel = config.models[selectedModel]; return (
{error && (
{error}
)}
{currentProvider ? ( ) : (
请选择或添加供应商
)}
{currentModel ? ( ) : (
请选择或添加模型
)}
{ const name = prompt("输入新 Profile 名称")?.trim(); if (!name) return; const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); await invoke("save_profile", { filename: `${safe}.toml`, config, }); await refresh(); }} onOpenDir={() => invoke("open_config_dir")} />
); } ``` --- ## Task 13: Tailwind Configuration **Files:** - Create: `tailwind.config.js` - Create: `postcss.config.js` - [ ] **Step 1: Create tailwind.config.js** ```javascript /** @type {import('tailwindcss').Config} */ export default { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: {}, }, plugins: [], }; ``` - [ ] **Step 2: Create postcss.config.js** ```javascript export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ``` --- ## Task 14: Rust Unit Tests **Files:** - Create: `src-tauri/tests/config_io.rs` - Create: `src-tauri/tests/validators.rs` - Create: `src-tauri/tests/profile_manager.rs` - [ ] **Step 1: Write validators test** ```rust use kimiswitch_lib::models::{Config, Model, Provider, ProviderType, Table}; use kimiswitch_lib::validators::validate_config; use indexmap::IndexMap; fn valid_config() -> Config { let mut providers = IndexMap::new(); providers.insert( "kimi".to_string(), Provider { name: "kimi".to_string(), provider_type: ProviderType::Kimi, base_url: None, api_key: None, env: [("KIMI_API_KEY".to_string(), "sk-xxx".to_string())] .into_iter() .collect(), raw_other: Table::new(), }, ); let mut models = IndexMap::new(); models.insert( "kimi-coding".to_string(), Model { alias: "kimi-coding".to_string(), provider: "kimi".to_string(), model: "kimi-for-coding".to_string(), max_context_size: 262144, display_name: None, raw_other: Table::new(), }, ); Config { default_model: Some("kimi-coding".to_string()), providers, models, raw_other: Table::new(), } } #[test] fn test_valid_config() { assert!(validate_config(&valid_config()).is_ok()); } #[test] fn test_missing_credentials() { let mut cfg = valid_config(); cfg.providers.get_mut("kimi").unwrap().env.clear(); let errors = validate_config(&cfg).unwrap_err(); assert!(errors.iter().any(|e| e.to_string().contains("missing credentials"))); } #[test] fn test_unknown_model_provider() { let mut cfg = valid_config(); cfg.models.get_mut("kimi-coding").unwrap().provider = "missing".to_string(); let errors = validate_config(&cfg).unwrap_err(); assert!(errors.iter().any(|e| e.to_string().contains("Unknown model provider"))); } ``` - [ ] **Step 2: Run Rust tests** Run: ```bash cd src-tauri && cargo test ``` Expected: all tests pass. --- ## Task 15: Build MSI Installer **Files:** - Modify: `src-tauri/tauri.conf.json` - Create: real icon files in `src-tauri/icons/` - [ ] **Step 1: Verify tauri.conf.json bundle settings** Ensure: ```json "bundle": { "active": true, "targets": ["msi"] } ``` - [ ] **Step 2: Add real icon files** Generate icons using Tauri CLI: ```bash npm run tauri icon path/to/icon.png ``` If no icon yet, use a temporary 1024x1024 PNG. - [ ] **Step 3: Build release MSI** Run: ```bash npm run tauri build ``` Expected output: ``` src-tauri/target/release/bundle/msi/KimiSwitch_0.1.0_x64_en-US.msi ``` - [ ] **Step 4: Install and smoke test on Windows** Double-click the MSI, verify: - Install completes without errors. - Start menu shortcut appears. - App launches. - Can create provider, model, save, switch profiles. --- ## Task 16: Manual End-to-End Testing - [ ] **Step 1: Fresh config scenario** Delete `~/.kimi-code/config.toml`, launch KimiSwitch, verify it creates an empty config and default profile. - [ ] **Step 2: Existing config scenario** Create a hand-written `config.toml` with one provider and one model. Launch KimiSwitch, verify it loads correctly. - [ ] **Step 3: Provider/model switch** Add two providers with one model each. Use TopBar to switch between them and click「设为默认」. Verify `default_model` in `config.toml` updates. - [ ] **Step 4: Profile switch** Create two profiles with different providers. Switch between them. Verify `config.toml` content changes. - [ ] **Step 5: Validation error** Create a model referencing a non-existent provider. Click save. Verify error dialog appears and file is not overwritten. --- ## Self-Review ### Spec Coverage | Spec Section | Implementing Task | | --- | --- | | Tauri v2 + Rust + TS/React + Tailwind | Task 1 | | ProviderType enum with serde rename | Task 2 | | IndexMap for ordering | Task 2 | | Profile filename safe化 | Task 5 | | `toml_edit` round-trip | Task 3 | | Tauri permissions | Task 7 | | Tauri commands | Task 6 | | Credential env/direct mutual exclusivity | Task 10 (ProviderForm) | | `is_active` semantic compare | Task 5 | | Three-pane UI with TopBar | Tasks 9–12 | | Validation rules | Task 4 | | MSI packaging | Task 15 | | Windows-specific paths | Task 3 | | raw_other preservation | Task 6 (`merge_with_existing`) | ### Placeholder Scan No TBD/TODO/fill-in-details found. All code blocks contain concrete implementations. ### Type Consistency - `Provider`, `Model`, `Config` types match between Rust (`models.rs`) and TypeScript (`src/types/index.ts`). - Command names in `lib.rs` match `commands.rs` and `useConfig.ts`. - `Table` re-exported from `models.rs` so integration tests can construct models. ### Implementation Notes - **raw_other preservation**: `save_config_command` and `save_profile_command` load the existing on-disk file, merge managed fields from the frontend, and keep the original `raw_other`. This protects `[thinking]`, `[loop_control]`, `custom_headers`, `capabilities`, `overrides`, and comments/formatting. - **Provider/model rename**: For simplicity in the first implementation, provider `name` and model `alias` are read-only in the edit forms. Users can delete and recreate, or rename via Profile-level operations. A future enhancement can add explicit rename with reference updates. - **Chinese Profile names**: The safe-filename function keeps ASCII alphanumeric characters; pure Chinese names fall back to a hash-based filename. - **Provider default Base URL auto-fill**: Not implemented in this plan; add as a follow-up enhancement.