63 KiB
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(¤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
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 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,Configtypes match between Rust (models.rs) and TypeScript (src/types/index.ts).- Command names in
lib.rsmatchcommands.rsanduseConfig.ts. Tablere-exported frommodels.rsso integration tests can construct models.
Implementation Notes
- raw_other preservation:
save_config_commandandsave_profile_commandload the existing on-disk file, merge managed fields from the frontend, and keep the originalraw_other. This protects[thinking],[loop_control],custom_headers,capabilities,overrides, and comments/formatting. - Provider/model rename: For simplicity in the first implementation, provider
nameand modelaliasare 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.