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

2389 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# KimiSwitch Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Windows x64 desktop config manager for Kimi Code CLI using Tauri v2, supporting multi-provider editing, profile switching, and MSI packaging.
**Architecture:** Rust backend handles all file I/O, TOML parsing/editing via `toml_edit`, validation, and profile management. TypeScript/React frontend renders a three-pane UI and invokes Rust commands via Tauri. Tauri v2 bundler produces the Windows MSI.
**Tech Stack:** Tauri v2, Rust, TypeScript, React, Tailwind CSS, `toml_edit`, `indexmap`, `serde`.
---
## File Structure
```
D:/AIGC/KimiSwitch
├── src-tauri/
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ ├── capabilities/
│ │ └── default.json
│ └── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── models.rs
│ ├── config_io.rs
│ ├── validators.rs
│ ├── profile_manager.rs
│ └── commands.rs
├── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── index.css
│ ├── components/
│ │ ├── TopBar.tsx
│ │ ├── ProviderList.tsx
│ │ ├── ProviderForm.tsx
│ │ ├── ModelList.tsx
│ │ ├── ModelForm.tsx
│ │ └── BottomToolbar.tsx
│ ├── hooks/
│ │ └── useConfig.ts
│ └── types/
│ └── index.ts
├── tests/
│ ├── config_io.rs
│ ├── profile_manager.rs
│ └── validators.rs
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
├── README.md
└── .gitignore
```
---
## Task 1: Initialize Tauri v2 Project
**Files:**
- Create: `package.json`
- Create: `vite.config.ts`
- Create: `tsconfig.json`
- Create: `index.html`
- Create: `src/main.tsx`
- Create: `src/index.css`
- Create: `src-tauri/Cargo.toml`
- Create: `src-tauri/tauri.conf.json`
- Create: `src-tauri/src/main.rs`
- [ ] **Step 1: Create root package.json**
```json
{
"name": "kimiswitch",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri-dev": "tauri dev",
"tauri-build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}
```
- [ ] **Step 2: Create vite.config.ts**
```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: { ignored: ["**/src-tauri/**"] },
},
envPrefix: ["VITE_", "TAURI_"],
}));
```
- [ ] **Step 3: Create tsconfig.json and tsconfig.node.json**
`tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
```
`tsconfig.node.json`:
```json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
```
- [ ] **Step 4: Create index.html**
```html
<!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**
```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**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
margin: 0;
overflow: hidden;
}
```
- [ ] **Step 7: Create src-tauri/Cargo.toml**
```toml
[package]
name = "kimiswitch"
version = "0.1.0"
description = "Kimi Code CLI config manager"
authors = ["you"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
tauri = { version = "2.0.0", features = [] }
tauri-plugin-shell = "2.0.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml_edit = "0.22"
indexmap = { version = "2.2", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
chrono = "0.4"
anyhow = "1.0"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
```
- [ ] **Step 8: Create src-tauri/tauri.conf.json**
```json
{
"productName": "KimiSwitch",
"version": "0.1.0",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "KimiSwitch",
"width": 1200,
"height": 800,
"minWidth": 1000,
"minHeight": 700,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}
```
- [ ] **Step 9: Create src-tauri/src/main.rs**
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kimiswitch_lib::run();
}
```
- [ ] **Step 10: Install dependencies and verify dev build**
Run:
```bash
npm install
cd src-tauri && cargo fetch
```
Expected: `node_modules/` and `src-tauri/target/` populated without errors.
---
## Task 2: Define Rust Models
**Files:**
- Create: `src-tauri/src/models.rs`
- Create: `src-tauri/src/lib.rs`
- [ ] **Step 1: Write models.rs**
```rust
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
pub use toml_edit::Table;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
Kimi,
Anthropic,
Openai,
#[serde(rename = "openai_responses")]
OpenaiResponses,
#[serde(rename = "google-genai")]
GoogleGenai,
Vertexai,
}
impl ProviderType {
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Kimi => "kimi",
ProviderType::Anthropic => "anthropic",
ProviderType::Openai => "openai",
ProviderType::OpenaiResponses => "openai_responses",
ProviderType::GoogleGenai => "google-genai",
ProviderType::Vertexai => "vertexai",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provider {
pub name: String,
pub provider_type: ProviderType,
pub base_url: Option<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**
```rust
pub mod commands;
pub mod config_io;
pub mod models;
pub mod profile_manager;
pub mod validators;
use tauri::Manager;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
commands::load_config,
commands::save_config,
commands::list_profiles,
commands::load_profile,
commands::save_profile,
commands::switch_profile,
commands::rename_profile,
commands::delete_profile,
commands::open_config_dir,
commands::get_app_version,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
- [ ] **Step 3: Verify compilation**
Run:
```bash
cd src-tauri && cargo check
```
Expected: `Finished dev [unoptimized + debuginfo] target(s)`.
---
## Task 3: Implement Config I/O
**Files:**
- Create: `src-tauri/src/config_io.rs`
- Test: `tests/config_io.rs`
- [ ] **Step 1: Implement config_io.rs read path**
```rust
use crate::models::{Config, Model, Provider, ProviderType};
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, Value};
pub fn config_dir() -> PathBuf {
if let Ok(home) = std::env::var("KIMI_CODE_HOME") {
PathBuf::from(home)
} else {
dirs::home_dir()
.expect("failed to get home dir")
.join(".kimi-code")
}
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
fn item_to_string(item: &Item) -> Option<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**
```rust
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:
```bash
cd src-tauri && cargo check
```
Expected: compilation succeeds.
---
## Task 4: Implement Validators
**Files:**
- Create: `src-tauri/src/validators.rs`
- Test: `tests/validators.rs`
- [ ] **Step 1: Write validators.rs**
```rust
use crate::models::{Config, Provider, ProviderType};
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Provider name is empty")]
EmptyProviderName,
#[error("Duplicate provider name: {0}")]
DuplicateProviderName(String),
#[error("Invalid provider type for '{0}'")]
InvalidProviderType(String),
#[error("Provider '{0}' missing credentials (api_key or env key)")]
MissingCredentials(String),
#[error("Vertex provider '{0}' missing GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_LOCATION")]
MissingVertexProject(String),
#[error("Model alias is empty")]
EmptyModelAlias,
#[error("Duplicate model alias: {0}")]
DuplicateModelAlias(String),
#[error("Model '{0}' references unknown provider '{1}'")]
UnknownModelProvider(String, String),
#[error("Model '{0}' has empty model ID")]
EmptyModelId(String),
#[error("Model '{0}' has invalid max_context_size")]
InvalidMaxContextSize(String),
}
fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
match provider_type {
ProviderType::Kimi => "KIMI_API_KEY",
ProviderType::Anthropic => "ANTHROPIC_API_KEY",
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
ProviderType::GoogleGenai => "GOOGLE_API_KEY",
ProviderType::Vertexai => "VERTEXAI_API_KEY",
}
}
pub fn validate_config(config: &Config) -> Result<(), Vec<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:
```bash
cd src-tauri && cargo test validators
```
Expected: tests pass (write at least 3 tests: valid config, missing credentials, duplicate alias).
---
## Task 5: Implement Profile Manager
**Files:**
- Create: `src-tauri/src/profile_manager.rs`
- Test: `tests/profile_manager.rs`
- [ ] **Step 1: Implement safe filename function**
```rust
use crate::config_io::{config_dir, config_path};
use crate::models::{Config, Profile, ProfileSummary};
pub fn safe_filename(name: &str) -> String {
let mut result = String::new();
let mut prev_dash = true;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
result.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
result.push('-');
prev_dash = true;
}
}
if result.ends_with('-') {
result.pop();
}
if result.is_empty() {
// Chinese or special-character names fall back to a short hash.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
result = format!("profile-{}", hasher.finish() % 100000);
}
result
}
```
- [ ] **Step 2: Implement profile directory and file helpers**
```rust
pub fn profiles_dir() -> std::path::PathBuf {
config_dir().join("profiles")
}
pub fn profile_path(filename: &str) -> std::path::PathBuf {
profiles_dir().join(filename)
}
```
- [ ] **Step 3: Implement profile listing and activation detection**
```rust
use crate::config_io::load_config;
pub fn list_profiles() -> anyhow::Result<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**
```rust
use crate::config_io::save_config;
pub fn save_profile(filename: &str, config: &Config) -> anyhow::Result<()> {
std::fs::create_dir_all(profiles_dir())?;
let path = profile_path(filename);
save_config(&path, config)
}
pub fn switch_profile(filename: &str) -> anyhow::Result<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:
```bash
cd src-tauri && cargo test profile_manager
```
Expected: tests pass.
---
## Task 6: Implement Tauri Commands
**Files:**
- Create: `src-tauri/src/commands.rs`
- [ ] **Step 1: Write commands.rs**
```rust
use crate::config_io::{config_dir, config_path, load_config, save_config};
use crate::models::{Config, ProfileSummary, Table};
use crate::profile_manager;
use crate::validators::validate_config;
/// Merge frontend-managed fields into the existing on-disk config,
/// preserving raw_other (capabilities, overrides, custom_headers, [thinking], etc.).
fn merge_with_existing(path: &std::path::PathBuf, incoming: Config) -> Result<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`:
```rust
pub fn load_profile(filename: &str) -> anyhow::Result<Config> {
load_config(profile_path(filename))
}
```
- [ ] **Step 3: Run cargo check**
Run:
```bash
cd src-tauri && cargo check
```
Expected: compilation succeeds.
---
## Task 7: Configure Tauri v2 Permissions
**Files:**
- Create: `src-tauri/capabilities/default.json`
- Modify: `src-tauri/tauri.conf.json`
- [ ] **Step 1: Create capabilities/default.json**
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for KimiSwitch",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
{
"identifier": "fs:allow-read-file",
"allow": [
{ "path": "$HOME/.kimi-code/config.toml" },
{ "path": "$HOME/.kimi-code/profiles/*" }
]
},
{
"identifier": "fs:allow-write-file",
"allow": [
{ "path": "$HOME/.kimi-code/config.toml" },
{ "path": "$HOME/.kimi-code/profiles/*" }
]
},
{
"identifier": "fs:allow-read-dir",
"allow": [
{ "path": "$HOME/.kimi-code" },
{ "path": "$HOME/.kimi-code/profiles" }
]
},
{
"identifier": "fs:allow-create-dir",
"allow": [
{ "path": "$HOME/.kimi-code" },
{ "path": "$HOME/.kimi-code/profiles" }
]
}
]
}
```
- [ ] **Step 2: Update tauri.conf.json bundle config**
Ensure `"targets": ["msi"]` is set and add windows-specific config:
```json
"bundle": {
"active": true,
"targets": ["msi"],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": null
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
```
- [ ] **Step 3: Add placeholder icons**
Create simple PNG/ICO icons or copy from Tauri default. For now, create empty files as placeholders.
Run:
```bash
mkdir -p src-tauri/icons
touch src-tauri/icons/32x32.png src-tauri/icons/128x128.png src-tauri/icons/128x128@2x.png src-tauri/icons/icon.ico
```
Note: Replace with real icons before final build.
---
## Task 8: Frontend Types and useConfig Hook
**Files:**
- Create: `src/types/index.ts`
- Create: `src/hooks/useConfig.ts`
- [ ] **Step 1: Create src/types/index.ts**
```typescript
export type ProviderType =
| "kimi"
| "anthropic"
| "openai"
| "openai_responses"
| "google-genai"
| "vertexai";
export interface Provider {
name: string;
provider_type: ProviderType;
base_url: string | null;
api_key: string | null;
env: Record<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**
```typescript
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Config, ProfileSummary, Provider, Model } from "../types";
export function useConfig() {
const [config, setConfig] = useState<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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```javascript
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
```
- [ ] **Step 2: Create postcss.config.js**
```javascript
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
---
## Task 14: Rust Unit Tests
**Files:**
- Create: `src-tauri/tests/config_io.rs`
- Create: `src-tauri/tests/validators.rs`
- Create: `src-tauri/tests/profile_manager.rs`
- [ ] **Step 1: Write validators test**
```rust
use kimiswitch_lib::models::{Config, Model, Provider, ProviderType, Table};
use kimiswitch_lib::validators::validate_config;
use indexmap::IndexMap;
fn valid_config() -> Config {
let mut providers = IndexMap::new();
providers.insert(
"kimi".to_string(),
Provider {
name: "kimi".to_string(),
provider_type: ProviderType::Kimi,
base_url: None,
api_key: None,
env: [("KIMI_API_KEY".to_string(), "sk-xxx".to_string())]
.into_iter()
.collect(),
raw_other: Table::new(),
},
);
let mut models = IndexMap::new();
models.insert(
"kimi-coding".to_string(),
Model {
alias: "kimi-coding".to_string(),
provider: "kimi".to_string(),
model: "kimi-for-coding".to_string(),
max_context_size: 262144,
display_name: None,
raw_other: Table::new(),
},
);
Config {
default_model: Some("kimi-coding".to_string()),
providers,
models,
raw_other: Table::new(),
}
}
#[test]
fn test_valid_config() {
assert!(validate_config(&valid_config()).is_ok());
}
#[test]
fn test_missing_credentials() {
let mut cfg = valid_config();
cfg.providers.get_mut("kimi").unwrap().env.clear();
let errors = validate_config(&cfg).unwrap_err();
assert!(errors.iter().any(|e| e.to_string().contains("missing credentials")));
}
#[test]
fn test_unknown_model_provider() {
let mut cfg = valid_config();
cfg.models.get_mut("kimi-coding").unwrap().provider = "missing".to_string();
let errors = validate_config(&cfg).unwrap_err();
assert!(errors.iter().any(|e| e.to_string().contains("Unknown model provider")));
}
```
- [ ] **Step 2: Run Rust tests**
Run:
```bash
cd src-tauri && cargo test
```
Expected: all tests pass.
---
## Task 15: Build MSI Installer
**Files:**
- Modify: `src-tauri/tauri.conf.json`
- Create: real icon files in `src-tauri/icons/`
- [ ] **Step 1: Verify tauri.conf.json bundle settings**
Ensure:
```json
"bundle": {
"active": true,
"targets": ["msi"]
}
```
- [ ] **Step 2: Add real icon files**
Generate icons using Tauri CLI:
```bash
npm run tauri icon path/to/icon.png
```
If no icon yet, use a temporary 1024x1024 PNG.
- [ ] **Step 3: Build release MSI**
Run:
```bash
npm run tauri build
```
Expected output:
```
src-tauri/target/release/bundle/msi/KimiSwitch_0.1.0_x64_en-US.msi
```
- [ ] **Step 4: Install and smoke test on Windows**
Double-click the MSI, verify:
- Install completes without errors.
- Start menu shortcut appears.
- App launches.
- Can create provider, model, save, switch profiles.
---
## Task 16: Manual End-to-End Testing
- [ ] **Step 1: Fresh config scenario**
Delete `~/.kimi-code/config.toml`, launch KimiSwitch, verify it creates an empty config and default profile.
- [ ] **Step 2: Existing config scenario**
Create a hand-written `config.toml` with one provider and one model. Launch KimiSwitch, verify it loads correctly.
- [ ] **Step 3: Provider/model switch**
Add two providers with one model each. Use TopBar to switch between them and click「设为默认」. Verify `default_model` in `config.toml` updates.
- [ ] **Step 4: Profile switch**
Create two profiles with different providers. Switch between them. Verify `config.toml` content changes.
- [ ] **Step 5: Validation error**
Create a model referencing a non-existent provider. Click save. Verify error dialog appears and file is not overwritten.
---
## Self-Review
### Spec Coverage
| Spec Section | Implementing Task |
| --- | --- |
| Tauri v2 + Rust + TS/React + Tailwind | Task 1 |
| ProviderType enum with serde rename | Task 2 |
| IndexMap for ordering | Task 2 |
| Profile filename safe化 | Task 5 |
| `toml_edit` round-trip | Task 3 |
| Tauri permissions | Task 7 |
| Tauri commands | Task 6 |
| Credential env/direct mutual exclusivity | Task 10 (ProviderForm) |
| `is_active` semantic compare | Task 5 |
| Three-pane UI with TopBar | Tasks 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.