Files
KimiCodeSwitch/docs/superpowers/plans/2026-07-06-kimiswitch.md

63 KiB
Raw Permalink Blame History

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

{
  "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
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:

{
  "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:

{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "strict": true
  },
  "include": ["vite.config.ts"]
}
  • Step 4: Create index.html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>KimiSwitch</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Step 5: Create src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
  • Step 6: Create src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root {
  height: 100%;
  margin: 0;
  overflow: hidden;
}
  • Step 7: Create src-tauri/Cargo.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
{
  "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
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    kimiswitch_lib::run();
}
  • Step 10: Install dependencies and verify dev build

Run:

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

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<String>,
    pub api_key: Option<String>,
    pub env: IndexMap<String, String>,
    #[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<String>,
    #[serde(skip)]
    pub raw_other: Table,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub default_model: Option<String>,
    pub providers: IndexMap<String, Provider>,
    pub models: IndexMap<String, Model>,
    #[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
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:

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

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<String> {
    item.as_str().map(|s| s.to_string())
}

fn item_to_u64(item: &Item) -> Option<u64> {
    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<P: AsRef<Path>>(path: P) -> anyhow::Result<Config> {
    let content = std::fs::read_to_string(path)?;
    let doc = content.parse::<DocumentMut>()?;

    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
pub fn save_config<P: AsRef<Path>>(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::<DocumentMut>()?
    } 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:

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

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<ValidationError>> {
    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:

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

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
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
use crate::config_io::load_config;

pub fn list_profiles() -> anyhow::Result<Vec<Profile>> {
    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(&current_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
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<Config> {
    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<String> {
    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:

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

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<Config, String> {
    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<Config, String> {
    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::<Vec<_>>().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<Vec<ProfileSummary>, 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<Config, String> {
    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<Config, String> {
    profile_manager::switch_profile(&filename).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn rename_profile(old_filename: String, new_name: String) -> Result<String, String> {
    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:

pub fn load_profile(filename: &str) -> anyhow::Result<Config> {
    load_config(profile_path(filename))
}
  • Step 3: Run cargo check

Run:

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

{
  "$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:

"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:

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

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<string, string>;
}

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<string, Provider>;
  models: Record<string, Model>;
}

export interface ProfileSummary {
  name: string;
  filename: string;
  is_active: boolean;
}
  • Step 2: Create src/hooks/useConfig.ts
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<Config | null>(null);
  const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
  const [dirty, setDirty] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const refresh = useCallback(async () => {
    setLoading(true);
    try {
      const [cfg, profs] = await Promise.all([
        invoke<Config>("load_config_command"),
        invoke<ProfileSummary[]>("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<Config>("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

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 (
    <div className="flex items-center gap-3 p-3 border-b bg-gray-50">
      <select
        className="border rounded px-2 py-1"
        value={profileFilename}
        onChange={(e) => {
          setProfileFilename(e.target.value);
          onProfileAction("switch", e.target.value);
        }}
      >
        {profiles.map((p) => (
          <option key={p.filename} value={p.filename}>
            {p.is_active ? "● " : ""}
            {p.name}
          </option>
        ))}
      </select>

      <button className="px-2 py-1 border rounded" onClick={() => onProfileAction("new", profileFilename)}>
        新建
      </button>
      <button className="px-2 py-1 border rounded" onClick={() => onProfileAction("duplicate", profileFilename)}>
        复制
      </button>
      <button className="px-2 py-1 border rounded" onClick={() => onProfileAction("rename", profileFilename)}>
        重命名
      </button>
      <button className="px-2 py-1 border rounded" onClick={() => onProfileAction("delete", profileFilename)}>
        删除
      </button>

      <div className="w-px h-6 bg-gray-300 mx-2" />

      <select
        className="border rounded px-2 py-1"
        value={selectedProvider}
        onChange={(e) => onProviderChange(e.target.value)}
      >
        {providerOptions.map((p) => (
          <option key={p.name} value={p.name}>
            {p.name}
          </option>
        ))}
      </select>

      <select
        className="border rounded px-2 py-1"
        value={selectedModel}
        onChange={(e) => onModelChange(e.target.value)}
        disabled={modelOptions.length === 0}
      >
        {modelOptions.length === 0 ? (
          <option>未绑定模型</option>
        ) : (
          modelOptions.map((m) => (
            <option key={m.alias} value={m.alias}>
              {m.alias}
            </option>
          ))
        )}
      </select>

      <button
        className="px-3 py-1 bg-blue-600 text-white rounded disabled:opacity-50"
        onClick={onSetDefault}
        disabled={!selectedModel}
      >
        设为默认
      </button>
    </div>
  );
}

Task 10: Build ProviderList and ProviderForm

Files:

  • Create: src/components/ProviderList.tsx

  • Create: src/components/ProviderForm.tsx

  • Step 1: Write ProviderList.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 (
    <div className="flex flex-col h-full border-r">
      <div className="p-2 border-b font-semibold">供应商</div>
      <div className="flex-1 overflow-auto">
        {providers.map((p) => (
          <div
            key={p.name}
            className={`p-2 cursor-pointer hover:bg-gray-100 flex justify-between ${
              selected === p.name ? "bg-blue-50" : ""
            }`}
            onClick={() => onSelect(p.name)}
          >
            <span>{p.name}</span>
            <button
              className="text-red-500 text-xs"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(p.name);
              }}
            >
              删除
            </button>
          </div>
        ))}
      </div>
      <button className="p-2 border-t bg-gray-50" onClick={onAdd}>
        + 添加供应商
      </button>
    </div>
  );
}
  • Step 2: Write ProviderForm.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<Provider>) => {
    onChange({ ...provider, ...patch });
  };

  return (
    <div className="p-4 space-y-4">
      <div>
        <label className="block text-sm font-medium">类型</label>
        <select
          className="border rounded w-full px-2 py-1"
          value={provider.provider_type}
          onChange={(e) =>
            update({ provider_type: e.target.value as ProviderType })
          }
        >
          {PROVIDER_TYPES.map((t) => (
            <option key={t} value={t}>
              {t}
            </option>
          ))}
        </select>
      </div>

      <div className="flex gap-4">
        <label className="flex items-center gap-1">
          <input
            type="radio"
            checked={!useDirect}
            onChange={() => setUseDirect(false)}
          />
          使用 env 子表
        </label>
        <label className="flex items-center gap-1">
          <input
            type="radio"
            checked={useDirect}
            onChange={() => setUseDirect(true)}
          />
          使用直接字段
        </label>
      </div>

      <div>
        <label className="block text-sm font-medium">Base URL</label>
        <input
          className="border rounded w-full px-2 py-1"
          value={provider.base_url || ""}
          disabled={!useDirect}
          onChange={(e) => update({ base_url: e.target.value || null })}
        />
      </div>

      <div>
        <label className="block text-sm font-medium">API Key</label>
        <input
          type="password"
          className="border rounded w-full px-2 py-1"
          value={provider.api_key || ""}
          disabled={!useDirect}
          onChange={(e) => update({ api_key: e.target.value || null })}
        />
      </div>

      <div>
        <label className="block text-sm font-medium">Env 键值对</label>
        <EnvTable
          env={provider.env}
          onChange={(env) => update({ env })}
          disabled={useDirect}
        />
      </div>
    </div>
  );
}

function EnvTable({
  env,
  onChange,
  disabled,
}: {
  env: Record<string, string>;
  onChange: (env: Record<string, string>) => void;
  disabled: boolean;
}) {
  const entries = Object.entries(env);

  return (
    <div className={`space-y-1 ${disabled ? "opacity-50" : ""}`}>
      {entries.map(([k, v], idx) => (
        <div key={idx} className="flex gap-2">
          <input
            className="border rounded flex-1 px-2 py-1"
            value={k}
            disabled={disabled}
            onChange={(e) => {
              const newEnv = { ...env };
              delete newEnv[k];
              newEnv[e.target.value] = v;
              onChange(newEnv);
            }}
          />
          <input
            className="border rounded flex-1 px-2 py-1"
            value={v}
            disabled={disabled}
            onChange={(e) => onChange({ ...env, [k]: e.target.value })}
          />
          <button
            className="text-red-500"
            disabled={disabled}
            onClick={() => {
              const newEnv = { ...env };
              delete newEnv[k];
              onChange(newEnv);
            }}
          >
            ×
          </button>
        </div>
      ))}
      <button
        className="text-sm text-blue-600"
        disabled={disabled}
        onClick={() => onChange({ ...env, "": "" })}
      >
        + 添加
      </button>
    </div>
  );
}

Task 11: Build ModelList and ModelForm

Files:

  • Create: src/components/ModelList.tsx

  • Create: src/components/ModelForm.tsx

  • Step 1: Write ModelList.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 (
    <div className="flex flex-col h-full border-l">
      <div className="p-2 border-b font-semibold">模型别名</div>
      <div className="flex-1 overflow-auto">
        {models.map((m) => (
          <div
            key={m.alias}
            className={`p-2 cursor-pointer hover:bg-gray-100 flex justify-between ${
              selected === m.alias ? "bg-blue-50" : ""
            }`}
            onClick={() => onSelect(m.alias)}
          >
            <span>{m.alias}</span>
            <button
              className="text-red-500 text-xs"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(m.alias);
              }}
            >
              删除
            </button>
          </div>
        ))}
      </div>
      <button className="p-2 border-t bg-gray-50" onClick={onAdd}>
        + 添加模型
      </button>
    </div>
  );
}
  • Step 2: Write ModelForm.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 (
    <div className="p-4 space-y-4">
      <div>
        <label className="block text-sm font-medium">别名(只读)</label>
        <input
          className="border rounded w-full px-2 py-1 bg-gray-100"
          value={model.alias}
          readOnly
        />
      </div>

      <div>
        <label className="block text-sm font-medium">供应商</label>
        <select
          className="border rounded w-full px-2 py-1"
          value={model.provider}
          onChange={(e) => onChange({ ...model, provider: e.target.value })}
        >
          {providers.map((p) => (
            <option key={p} value={p}>
              {p}
            </option>
          ))}
        </select>
      </div>

      <div>
        <label className="block text-sm font-medium">模型 ID</label>
        <input
          className="border rounded w-full px-2 py-1"
          value={model.model}
          onChange={(e) => onChange({ ...model, model: e.target.value })}
        />
      </div>

      <div>
        <label className="block text-sm font-medium">最大上下文长度</label>
        <input
          type="number"
          className="border rounded w-full px-2 py-1"
          value={model.max_context_size}
          onChange={(e) =>
            onChange({ ...model, max_context_size: Number(e.target.value) })
          }
        />
      </div>

      <div>
        <label className="block text-sm font-medium">显示名称(可选)</label>
        <input
          className="border rounded w-full px-2 py-1"
          value={model.display_name || ""}
          onChange={(e) =>
            onChange({ ...model, display_name: e.target.value || null })
          }
        />
      </div>

      <button
        className="px-3 py-1 bg-blue-600 text-white rounded"
        onClick={onSetDefault}
      >
        设为默认模型
      </button>
    </div>
  );
}

Task 12: Build BottomToolbar and App.tsx

Files:

  • Create: src/components/BottomToolbar.tsx

  • Create: src/App.tsx

  • Step 1: Write BottomToolbar.tsx

interface BottomToolbarProps {
  dirty: boolean;
  onSave: () => void;
  onReload: () => void;
  onSaveAsProfile: () => void;
  onOpenDir: () => void;
}

export function BottomToolbar({
  dirty,
  onSave,
  onReload,
  onSaveAsProfile,
  onOpenDir,
}: BottomToolbarProps) {
  return (
    <div className="flex items-center gap-3 p-3 border-t bg-gray-50">
      <button
        className={`px-3 py-1 rounded ${
          dirty ? "bg-blue-600 text-white" : "border"
        }`}
        onClick={onSave}
      >
        保存配置
      </button>
      <button className="px-3 py-1 border rounded" onClick={onReload}>
        读取配置
      </button>
      <button className="px-3 py-1 border rounded" onClick={onSaveAsProfile}>
        保存为新 Profile
      </button>
      <button className="px-3 py-1 border rounded" onClick={onOpenDir}>
        打开配置目录
      </button>
      {dirty && <span className="text-orange-600 text-sm">有未保存修改</span>}
    </div>
  );
}
  • Step 2: Write App.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 <div className="p-4">加载中...</div>;
  }

  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<Config>("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 (
    <div className="flex flex-col h-full">
      <TopBar
        config={config}
        profiles={profiles}
        selectedProvider={selectedProvider}
        selectedModel={selectedModel}
        onProviderChange={handleProviderSelect}
        onModelChange={setSelectedModel}
        onSetDefault={handleSetDefault}
        onProfileAction={handleProfileAction}
      />

      {error && (
        <div className="bg-red-100 text-red-800 p-2 text-sm">{error}</div>
      )}

      <div className="flex-1 grid grid-cols-12 overflow-hidden">
        <div className="col-span-3">
          <ProviderList
            providers={providers}
            selected={selectedProvider}
            onSelect={handleProviderSelect}
            onAdd={handleAddProvider}
            onDelete={handleDeleteProvider}
          />
        </div>

        <div className="col-span-4 overflow-auto">
          {currentProvider ? (
            <ProviderForm
              provider={currentProvider}
              onChange={handleUpdateProvider}
            />
          ) : (
            <div className="p-4 text-gray-500">请选择或添加供应商</div>
          )}
        </div>

        <div className="col-span-2">
          <ModelList
            models={filteredModels}
            selected={selectedModel}
            onSelect={setSelectedModel}
            onAdd={handleAddModel}
            onDelete={handleDeleteModel}
          />
        </div>

        <div className="col-span-3 overflow-auto">
          {currentModel ? (
            <ModelForm
              model={currentModel}
              providers={Object.keys(config.providers)}
              onChange={handleUpdateModel}
              onSetDefault={handleSetDefault}
            />
          ) : (
            <div className="p-4 text-gray-500">请选择或添加模型</div>
          )}
        </div>
      </div>

      <BottomToolbar
        dirty={dirty}
        onSave={save}
        onReload={refresh}
        onSaveAsProfile={async () => {
          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")}
      />
    </div>
  );
}

Task 13: Tailwind Configuration

Files:

  • Create: tailwind.config.js

  • Create: postcss.config.js

  • Step 1: Create tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};
  • Step 2: Create postcss.config.js
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

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:

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:

"bundle": {
  "active": true,
  "targets": ["msi"]
}
  • Step 2: Add real icon files

Generate icons using Tauri CLI:

npm run tauri icon path/to/icon.png

If no icon yet, use a temporary 1024x1024 PNG.

  • Step 3: Build release MSI

Run:

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 912
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.