diff --git a/.gitignore b/.gitignore
index b01c278..0942c13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,6 @@ src-tauri/wix314-binaries/
Thumbs.db
Snipaste_*.png
.worktrees/
+
+# 内部设计规范与实施计划,仅供本地查阅,不入版本库
+docs/superpowers/
diff --git a/README.md b/README.md
index 6413b26..21eb266 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
- [这是什么](#这是什么)
- [核心特性](#核心特性)
+- [界面预览](#界面预览)
- [架构总览](#架构总览)
- [支持的供应商类型](#支持的供应商类型)
- [数据存储位置](#数据存储位置)
@@ -64,6 +65,38 @@ Kimi Switch 提供统一的图形界面:
| **会话管理** | 按工作区浏览、预览、归档、批量删除会话,逐行流式读取防崩溃(20MB 上限 + 500 字符折叠) |
| **未保存提示** | 关闭窗口前检测未保存修改,标题栏加 `*` 前缀 |
+## 界面预览
+
+**供应商列表**(浅色主题)
+
+
+
+清晰展示当前激活的供应商、默认模型、延迟和可用模型数量,支持快速切换、复制、跳转官网、编辑和删除操作。
+
+**编辑供应商 - 基本信息**
+
+
+
+包含供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key 与请求地址等基础字段。
+
+**编辑供应商 - 模型映射**
+
+
+
+一张表管理全部模型映射:显示名称、实际请求模型、上下文长度、1M 上下文声明、能力标签(思考/图像/视频/工具调用/其他),支持「一键设置」「获取模型列表」批量填充。
+
+**用量仪表盘**
+
+
+
+8 项核心 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图)+ 模型用量明细(请求、Token、缓存命中、费用)。
+
+**会话管理**
+
+
+
+按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选,流式逐行预览会话内容(20MB 字节上限,500 字符折叠),可归档或批量删除。
+
## 架构总览
```
@@ -209,7 +242,9 @@ npm run dev
│
├── scripts/generate-icons.py # 从 SVG 生成各尺寸图标
├── public/kimi.svg # 应用图标源(蓝紫渐变 π)
-└── docs/superpowers/ # 设计规范与实施计划
+├── docs/
+│ ├── screenshots/ # README 引用的界面截图
+│ └── superpowers/ # 设计规范与实施计划
```
### Tauri Commands(前端 ↔ 后端)
diff --git a/README_EN.md b/README_EN.md
index 659e39b..1ca7d12 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -17,6 +17,7 @@
- [What Is This](#what-is-this)
- [Key Features](#key-features)
+- [Screenshots](#screenshots)
- [Architecture Overview](#architecture-overview)
- [Supported Provider Types](#supported-provider-types)
- [Data Storage Locations](#data-storage-locations)
@@ -63,6 +64,38 @@ Kimi Switch provides a unified GUI:
| **Validation (reserved)** | i18n error strings are defined (duplicate names, missing credentials, missing Vertex fields, etc.), but the backend `validators.rs` is a stub and not yet wired |
| **Unsaved-changes prompt** | Detects unsaved edits before closing the window and prefixes the title bar with `*` |
+## Screenshots
+
+**Provider list** (light theme)
+
+
+
+Shows the active provider, default model, latency and model count at a glance, with quick-switch, copy, open-website, edit and delete actions.
+
+**Edit provider — basic info**
+
+
+
+Provider name, notes, official URL, managed-provider toggle, API format, API key and base URL — the essentials in one panel.
+
+**Edit provider — model mapping**
+
+
+
+A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability tags (thinking / image / video / tools / other). One-click setup and "fetch model list" actions are available.
+
+**Usage dashboard**
+
+
+
+Eight core KPIs (requests, non-cached input, output, cache read / write / hit, total tokens, estimated cost), a full-year heatmap, a per-model stacked daily usage bar chart, and a per-model usage breakdown (requests, tokens, cache hit, cost).
+
+**Session management**
+
+
+
+Browse Kimi Code sessions per workspace, filter by active / archived / all, stream-preview contents (20MB byte cap, 500-char collapse), archive or bulk-delete.
+
## Architecture Overview
```
@@ -208,7 +241,9 @@ Good for pure UI debugging.
│
├── scripts/generate-icons.py # Generate all icon sizes from SVG
├── public/kimi.svg # App icon source (blue-purple gradient π)
-└── docs/superpowers/ # Design specs & implementation plans
+├── docs/
+│ ├── screenshots/ # UI screenshots referenced by the README
+│ └── superpowers/ # Design specs & implementation plans
```
### Tauri commands (frontend ↔ backend)
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
new file mode 100644
index 0000000..958030a
Binary files /dev/null and b/docs/screenshots/dashboard.png differ
diff --git a/docs/screenshots/provider-basic-info.png b/docs/screenshots/provider-basic-info.png
new file mode 100644
index 0000000..558c0bb
Binary files /dev/null and b/docs/screenshots/provider-basic-info.png differ
diff --git a/docs/screenshots/provider-model-mapping.png b/docs/screenshots/provider-model-mapping.png
new file mode 100644
index 0000000..4013bea
Binary files /dev/null and b/docs/screenshots/provider-model-mapping.png differ
diff --git a/docs/screenshots/providers.png b/docs/screenshots/providers.png
new file mode 100644
index 0000000..8db158e
Binary files /dev/null and b/docs/screenshots/providers.png differ
diff --git a/docs/screenshots/sessions.png b/docs/screenshots/sessions.png
new file mode 100644
index 0000000..93aa93c
Binary files /dev/null and b/docs/screenshots/sessions.png differ
diff --git a/docs/superpowers/plans/2026-07-06-kimiswitch.md b/docs/superpowers/plans/2026-07-06-kimiswitch.md
deleted file mode 100644
index 47f40bc..0000000
--- a/docs/superpowers/plans/2026-07-06-kimiswitch.md
+++ /dev/null
@@ -1,2388 +0,0 @@
-# KimiSwitch Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Build a Windows x64 desktop config manager for Kimi Code CLI using Tauri v2, supporting multi-provider editing, profile switching, and MSI packaging.
-
-**Architecture:** Rust backend handles all file I/O, TOML parsing/editing via `toml_edit`, validation, and profile management. TypeScript/React frontend renders a three-pane UI and invokes Rust commands via Tauri. Tauri v2 bundler produces the Windows MSI.
-
-**Tech Stack:** Tauri v2, Rust, TypeScript, React, Tailwind CSS, `toml_edit`, `indexmap`, `serde`.
-
----
-
-## File Structure
-
-```
-D:/AIGC/KimiSwitch
-├── src-tauri/
-│ ├── Cargo.toml
-│ ├── tauri.conf.json
-│ ├── capabilities/
-│ │ └── default.json
-│ └── src/
-│ ├── main.rs
-│ ├── lib.rs
-│ ├── models.rs
-│ ├── config_io.rs
-│ ├── validators.rs
-│ ├── profile_manager.rs
-│ └── commands.rs
-├── src/
-│ ├── App.tsx
-│ ├── main.tsx
-│ ├── index.css
-│ ├── components/
-│ │ ├── TopBar.tsx
-│ │ ├── ProviderList.tsx
-│ │ ├── ProviderForm.tsx
-│ │ ├── ModelList.tsx
-│ │ ├── ModelForm.tsx
-│ │ └── BottomToolbar.tsx
-│ ├── hooks/
-│ │ └── useConfig.ts
-│ └── types/
-│ └── index.ts
-├── tests/
-│ ├── config_io.rs
-│ ├── profile_manager.rs
-│ └── validators.rs
-├── package.json
-├── tsconfig.json
-├── vite.config.ts
-├── index.html
-├── README.md
-└── .gitignore
-```
-
----
-
-## Task 1: Initialize Tauri v2 Project
-
-**Files:**
-- Create: `package.json`
-- Create: `vite.config.ts`
-- Create: `tsconfig.json`
-- Create: `index.html`
-- Create: `src/main.tsx`
-- Create: `src/index.css`
-- Create: `src-tauri/Cargo.toml`
-- Create: `src-tauri/tauri.conf.json`
-- Create: `src-tauri/src/main.rs`
-
-- [ ] **Step 1: Create root package.json**
-
-```json
-{
- "name": "kimiswitch",
- "private": true,
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "tsc && vite build",
- "preview": "vite preview",
- "tauri": "tauri",
- "tauri-dev": "tauri dev",
- "tauri-build": "tauri build"
- },
- "dependencies": {
- "@tauri-apps/api": "^2.0.0",
- "@tauri-apps/plugin-shell": "^2.0.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
- },
- "devDependencies": {
- "@tauri-apps/cli": "^2.0.0",
- "@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
- "@vitejs/plugin-react": "^4.3.1",
- "autoprefixer": "^10.4.20",
- "postcss": "^8.4.41",
- "tailwindcss": "^3.4.10",
- "typescript": "^5.5.4",
- "vite": "^5.4.2"
- }
-}
-```
-
-- [ ] **Step 2: Create vite.config.ts**
-
-```typescript
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-
-export default defineConfig(async () => ({
- plugins: [react()],
- clearScreen: false,
- server: {
- port: 1420,
- strictPort: true,
- watch: { ignored: ["**/src-tauri/**"] },
- },
- envPrefix: ["VITE_", "TAURI_"],
-}));
-```
-
-- [ ] **Step 3: Create tsconfig.json and tsconfig.node.json**
-
-`tsconfig.json`:
-```json
-{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true,
- "baseUrl": ".",
- "paths": { "@/*": ["src/*"] }
- },
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
-```
-
-`tsconfig.node.json`:
-```json
-{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true,
- "strict": true
- },
- "include": ["vite.config.ts"]
-}
-```
-
-- [ ] **Step 4: Create index.html**
-
-```html
-
-
-
-
-
-
- KimiSwitch
-
-
-
-
-
-
-```
-
-- [ ] **Step 5: Create src/main.tsx**
-
-```tsx
-import React from "react";
-import ReactDOM from "react-dom/client";
-import App from "./App";
-import "./index.css";
-
-ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
-
-);
-```
-
-- [ ] **Step 6: Create src/index.css**
-
-```css
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-html, body, #root {
- height: 100%;
- margin: 0;
- overflow: hidden;
-}
-```
-
-- [ ] **Step 7: Create src-tauri/Cargo.toml**
-
-```toml
-[package]
-name = "kimiswitch"
-version = "0.1.0"
-description = "Kimi Code CLI config manager"
-authors = ["you"]
-edition = "2021"
-
-[build-dependencies]
-tauri-build = { version = "2.0.0", features = [] }
-
-[dependencies]
-tauri = { version = "2.0.0", features = [] }
-tauri-plugin-shell = "2.0.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-toml_edit = "0.22"
-indexmap = { version = "2.2", features = ["serde"] }
-dirs = "5.0"
-thiserror = "1.0"
-chrono = "0.4"
-anyhow = "1.0"
-
-[features]
-default = ["custom-protocol"]
-custom-protocol = ["tauri/custom-protocol"]
-```
-
-- [ ] **Step 8: Create src-tauri/tauri.conf.json**
-
-```json
-{
- "productName": "KimiSwitch",
- "version": "0.1.0",
- "identifier": "com.kimiswitch.app",
- "build": {
- "beforeDevCommand": "npm run dev",
- "beforeBuildCommand": "npm run build",
- "devUrl": "http://localhost:1420",
- "frontendDist": "../dist"
- },
- "app": {
- "windows": [
- {
- "title": "KimiSwitch",
- "width": 1200,
- "height": 800,
- "minWidth": 1000,
- "minHeight": 700,
- "resizable": true,
- "fullscreen": false
- }
- ],
- "security": {
- "csp": null
- }
- },
- "bundle": {
- "active": true,
- "targets": ["msi"],
- "icon": [
- "icons/32x32.png",
- "icons/128x128.png",
- "icons/128x128@2x.png",
- "icons/icon.ico"
- ]
- }
-}
-```
-
-- [ ] **Step 9: Create src-tauri/src/main.rs**
-
-```rust
-#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
-
-fn main() {
- kimiswitch_lib::run();
-}
-```
-
-- [ ] **Step 10: Install dependencies and verify dev build**
-
-Run:
-```bash
-npm install
-cd src-tauri && cargo fetch
-```
-
-Expected: `node_modules/` and `src-tauri/target/` populated without errors.
-
----
-
-## Task 2: Define Rust Models
-
-**Files:**
-- Create: `src-tauri/src/models.rs`
-- Create: `src-tauri/src/lib.rs`
-
-- [ ] **Step 1: Write models.rs**
-
-```rust
-use indexmap::IndexMap;
-use serde::{Deserialize, Serialize};
-pub use toml_edit::Table;
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ProviderType {
- Kimi,
- Anthropic,
- Openai,
- #[serde(rename = "openai_responses")]
- OpenaiResponses,
- #[serde(rename = "google-genai")]
- GoogleGenai,
- Vertexai,
-}
-
-impl ProviderType {
- pub fn as_str(&self) -> &'static str {
- match self {
- ProviderType::Kimi => "kimi",
- ProviderType::Anthropic => "anthropic",
- ProviderType::Openai => "openai",
- ProviderType::OpenaiResponses => "openai_responses",
- ProviderType::GoogleGenai => "google-genai",
- ProviderType::Vertexai => "vertexai",
- }
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Provider {
- pub name: String,
- pub provider_type: ProviderType,
- pub base_url: Option,
- pub api_key: Option,
- pub env: IndexMap,
- #[serde(skip)]
- pub raw_other: Table,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Model {
- pub alias: String,
- pub provider: String,
- pub model: String,
- pub max_context_size: u64,
- pub display_name: Option,
- #[serde(skip)]
- pub raw_other: Table,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Config {
- pub default_model: Option,
- pub providers: IndexMap,
- pub models: IndexMap,
- #[serde(skip)]
- pub raw_other: Table,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ProfileSummary {
- pub name: String,
- pub filename: String,
- pub is_active: bool,
-}
-
-#[derive(Debug, Clone)]
-pub struct Profile {
- pub name: String,
- pub filename: String,
- pub config: Config,
- pub is_active: bool,
-}
-```
-
-- [ ] **Step 2: Write lib.rs module exports**
-
-```rust
-pub mod commands;
-pub mod config_io;
-pub mod models;
-pub mod profile_manager;
-pub mod validators;
-
-use tauri::Manager;
-
-pub fn run() {
- tauri::Builder::default()
- .plugin(tauri_plugin_shell::init())
- .invoke_handler(tauri::generate_handler![
- commands::load_config,
- commands::save_config,
- commands::list_profiles,
- commands::load_profile,
- commands::save_profile,
- commands::switch_profile,
- commands::rename_profile,
- commands::delete_profile,
- commands::open_config_dir,
- commands::get_app_version,
- ])
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
-}
-```
-
-- [ ] **Step 3: Verify compilation**
-
-Run:
-```bash
-cd src-tauri && cargo check
-```
-
-Expected: `Finished dev [unoptimized + debuginfo] target(s)`.
-
----
-
-## Task 3: Implement Config I/O
-
-**Files:**
-- Create: `src-tauri/src/config_io.rs`
-- Test: `tests/config_io.rs`
-
-- [ ] **Step 1: Implement config_io.rs read path**
-
-```rust
-use crate::models::{Config, Model, Provider, ProviderType};
-use indexmap::IndexMap;
-use std::path::{Path, PathBuf};
-use toml_edit::{DocumentMut, Item, Table, Value};
-
-pub fn config_dir() -> PathBuf {
- if let Ok(home) = std::env::var("KIMI_CODE_HOME") {
- PathBuf::from(home)
- } else {
- dirs::home_dir()
- .expect("failed to get home dir")
- .join(".kimi-code")
- }
-}
-
-pub fn config_path() -> PathBuf {
- config_dir().join("config.toml")
-}
-
-fn item_to_string(item: &Item) -> Option {
- item.as_str().map(|s| s.to_string())
-}
-
-fn item_to_u64(item: &Item) -> Option {
- item.as_integer().map(|i| i as u64)
-}
-
-fn parse_provider_table(name: &str, table: &Table) -> Provider {
- let provider_type_str = table
- .get("type")
- .and_then(|i| i.as_str())
- .unwrap_or("kimi");
- let provider_type = match provider_type_str {
- "kimi" => ProviderType::Kimi,
- "anthropic" => ProviderType::Anthropic,
- "openai" => ProviderType::Openai,
- "openai_responses" => ProviderType::OpenaiResponses,
- "google-genai" => ProviderType::GoogleGenai,
- "vertexai" => ProviderType::Vertexai,
- _ => ProviderType::Kimi,
- };
-
- let mut raw_other = Table::new();
- let mut env = IndexMap::new();
- let mut base_url = None;
- let mut api_key = None;
-
- for (key, value) in table.iter() {
- match key {
- "type" => {}
- "base_url" => base_url = item_to_string(value),
- "api_key" => api_key = item_to_string(value),
- "env" => {
- if let Some(env_table) = value.as_table() {
- for (k, v) in env_table.iter() {
- if let Some(s) = item_to_string(v) {
- env.insert(k.to_string(), s);
- }
- }
- }
- }
- _ => {
- raw_other.insert(key, value.clone());
- }
- }
- }
-
- Provider {
- name: name.to_string(),
- provider_type,
- base_url,
- api_key,
- env,
- raw_other,
- }
-}
-
-fn parse_model_table(alias: &str, table: &Table) -> Model {
- let mut raw_other = Table::new();
- let mut provider = String::new();
- let mut model = String::new();
- let mut max_context_size = 0;
- let mut display_name = None;
-
- for (key, value) in table.iter() {
- match key {
- "provider" => provider = item_to_string(value).unwrap_or_default(),
- "model" => model = item_to_string(value).unwrap_or_default(),
- "max_context_size" => max_context_size = item_to_u64(value).unwrap_or(0),
- "display_name" => display_name = item_to_string(value),
- _ => {
- raw_other.insert(key, value.clone());
- }
- }
- }
-
- Model {
- alias: alias.to_string(),
- provider,
- model,
- max_context_size,
- display_name,
- raw_other,
- }
-}
-
-pub fn load_config>(path: P) -> anyhow::Result {
- let content = std::fs::read_to_string(path)?;
- let doc = content.parse::()?;
-
- let mut providers = IndexMap::new();
- let mut models = IndexMap::new();
- let mut default_model = None;
- let mut raw_other = Table::new();
-
- for (key, value) in doc.iter() {
- match key {
- "default_model" => default_model = item_to_string(value),
- "providers" => {
- if let Some(table) = value.as_table() {
- for (name, item) in table.iter() {
- if let Some(provider_table) = item.as_table() {
- let provider = parse_provider_table(name, provider_table);
- providers.insert(name.to_string(), provider);
- }
- }
- }
- }
- "models" => {
- if let Some(table) = value.as_table() {
- for (alias, item) in table.iter() {
- if let Some(model_table) = item.as_table() {
- let model = parse_model_table(alias, model_table);
- models.insert(alias.to_string(), model);
- }
- }
- }
- }
- _ => {
- raw_other.insert(key, value.clone());
- }
- }
- }
-
- Ok(Config {
- default_model,
- providers,
- models,
- raw_other,
- })
-}
-```
-
-- [ ] **Step 2: Implement config_io.rs write path**
-
-```rust
-pub fn save_config>(path: P, config: &Config) -> anyhow::Result<()> {
- let path = path.as_ref();
- if path.exists() {
- let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S_%3f");
- let backup_path = path.with_extension(format!("toml.bak.{}", timestamp));
- std::fs::copy(path, backup_path)?;
- }
-
- let mut doc = if path.exists() {
- std::fs::read_to_string(path)?.parse::()?
- } else {
- DocumentMut::new()
- };
-
- if let Some(dm) = &config.default_model {
- doc["default_model"] = Item::Value(Value::String(toml_edit::Formatted::new(dm.clone())));
- } else {
- doc.remove("default_model");
- }
-
- {
- let providers_table = doc["providers"].or_insert(Table::new());
- let providers_table = providers_table.as_table_mut().unwrap();
- providers_table.clear();
- for (name, provider) in &config.providers {
- let mut table = Table::new();
- table.insert(
- "type",
- Item::Value(Value::String(toml_edit::Formatted::new(
- provider.provider_type.as_str().to_string(),
- ))),
- );
- if let Some(url) = &provider.base_url {
- table.insert(
- "base_url",
- Item::Value(Value::String(toml_edit::Formatted::new(url.clone()))),
- );
- }
- if let Some(key) = &provider.api_key {
- table.insert(
- "api_key",
- Item::Value(Value::String(toml_edit::Formatted::new(key.clone()))),
- );
- }
- if !provider.env.is_empty() {
- let mut env_table = Table::new();
- for (k, v) in &provider.env {
- env_table.insert(
- k,
- Item::Value(Value::String(toml_edit::Formatted::new(v.clone()))),
- );
- }
- table.insert("env", Item::Table(env_table));
- }
- for (k, v) in provider.raw_other.iter() {
- table.insert(k, v.clone());
- }
- providers_table.insert(name, Item::Table(table));
- }
- }
-
- {
- let models_table = doc["models"].or_insert(Table::new());
- let models_table = models_table.as_table_mut().unwrap();
- models_table.clear();
- for (alias, model) in &config.models {
- let mut table = Table::new();
- table.insert(
- "provider",
- Item::Value(Value::String(toml_edit::Formatted::new(
- model.provider.clone(),
- ))),
- );
- table.insert(
- "model",
- Item::Value(Value::String(toml_edit::Formatted::new(model.model.clone()))),
- );
- table.insert(
- "max_context_size",
- Item::Value(Value::Integer(toml_edit::Formatted::new(
- model.max_context_size as i64,
- ))),
- );
- if let Some(dn) = &model.display_name {
- table.insert(
- "display_name",
- Item::Value(Value::String(toml_edit::Formatted::new(dn.clone()))),
- );
- }
- for (k, v) in model.raw_other.iter() {
- table.insert(k, v.clone());
- }
- models_table.insert(alias, Item::Table(table));
- }
- }
-
- for (k, v) in config.raw_other.iter() {
- doc.insert(k, v.clone());
- }
-
- let tmp_path = path.with_extension("tmp");
- std::fs::write(&tmp_path, doc.to_string())?;
- std::fs::rename(&tmp_path, path)?;
-
- Ok(())
-}
-```
-
-- [ ] **Step 3: Run cargo check**
-
-Run:
-```bash
-cd src-tauri && cargo check
-```
-
-Expected: compilation succeeds.
-
----
-
-## Task 4: Implement Validators
-
-**Files:**
-- Create: `src-tauri/src/validators.rs`
-- Test: `tests/validators.rs`
-
-- [ ] **Step 1: Write validators.rs**
-
-```rust
-use crate::models::{Config, Provider, ProviderType};
-
-#[derive(Debug, thiserror::Error)]
-pub enum ValidationError {
- #[error("Provider name is empty")]
- EmptyProviderName,
- #[error("Duplicate provider name: {0}")]
- DuplicateProviderName(String),
- #[error("Invalid provider type for '{0}'")]
- InvalidProviderType(String),
- #[error("Provider '{0}' missing credentials (api_key or env key)")]
- MissingCredentials(String),
- #[error("Vertex provider '{0}' missing GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_LOCATION")]
- MissingVertexProject(String),
- #[error("Model alias is empty")]
- EmptyModelAlias,
- #[error("Duplicate model alias: {0}")]
- DuplicateModelAlias(String),
- #[error("Model '{0}' references unknown provider '{1}'")]
- UnknownModelProvider(String, String),
- #[error("Model '{0}' has empty model ID")]
- EmptyModelId(String),
- #[error("Model '{0}' has invalid max_context_size")]
- InvalidMaxContextSize(String),
-}
-
-fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
- match provider_type {
- ProviderType::Kimi => "KIMI_API_KEY",
- ProviderType::Anthropic => "ANTHROPIC_API_KEY",
- ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
- ProviderType::GoogleGenai => "GOOGLE_API_KEY",
- ProviderType::Vertexai => "VERTEXAI_API_KEY",
- }
-}
-
-pub fn validate_config(config: &Config) -> Result<(), Vec> {
- let mut errors = Vec::new();
-
- let mut provider_names = std::collections::HashSet::new();
- for provider in config.providers.values() {
- if provider.name.trim().is_empty() {
- errors.push(ValidationError::EmptyProviderName);
- continue;
- }
- if !provider_names.insert(provider.name.clone()) {
- errors.push(ValidationError::DuplicateProviderName(provider.name.clone()));
- }
- }
-
- for provider in config.providers.values() {
- let has_direct_key = provider.api_key.as_ref().map_or(false, |s| !s.is_empty());
- let has_env_key = provider
- .env
- .get(expected_api_key_key(&provider.provider_type))
- .map_or(false, |s| !s.is_empty());
-
- if !matches!(provider.provider_type, ProviderType::Vertexai) {
- if !has_direct_key && !has_env_key {
- errors.push(ValidationError::MissingCredentials(provider.name.clone()));
- }
- }
-
- if matches!(provider.provider_type, ProviderType::Vertexai) {
- let has_project = provider
- .env
- .get("GOOGLE_CLOUD_PROJECT")
- .map_or(false, |s| !s.is_empty());
- let has_location = provider
- .env
- .get("GOOGLE_CLOUD_LOCATION")
- .map_or(false, |s| !s.is_empty());
- if !has_project || !has_location {
- errors.push(ValidationError::MissingVertexProject(provider.name.clone()));
- }
- }
- }
-
- let mut model_aliases = std::collections::HashSet::new();
- for model in config.models.values() {
- if model.alias.trim().is_empty() {
- errors.push(ValidationError::EmptyModelAlias);
- continue;
- }
- if !model_aliases.insert(model.alias.clone()) {
- errors.push(ValidationError::DuplicateModelAlias(model.alias.clone()));
- }
- }
-
- for model in config.models.values() {
- if !config.providers.contains_key(&model.provider) {
- errors.push(ValidationError::UnknownModelProvider(
- model.alias.clone(),
- model.provider.clone(),
- ));
- }
- if model.model.trim().is_empty() {
- errors.push(ValidationError::EmptyModelId(model.alias.clone()));
- }
- if model.max_context_size == 0 {
- errors.push(ValidationError::InvalidMaxContextSize(model.alias.clone()));
- }
- }
-
- if errors.is_empty() {
- Ok(())
- } else {
- Err(errors)
- }
-}
-```
-
-- [ ] **Step 2: Run cargo test for validators**
-
-Run:
-```bash
-cd src-tauri && cargo test validators
-```
-
-Expected: tests pass (write at least 3 tests: valid config, missing credentials, duplicate alias).
-
----
-
-## Task 5: Implement Profile Manager
-
-**Files:**
-- Create: `src-tauri/src/profile_manager.rs`
-- Test: `tests/profile_manager.rs`
-
-- [ ] **Step 1: Implement safe filename function**
-
-```rust
-use crate::config_io::{config_dir, config_path};
-use crate::models::{Config, Profile, ProfileSummary};
-
-pub fn safe_filename(name: &str) -> String {
- let mut result = String::new();
- let mut prev_dash = true;
- for ch in name.chars() {
- if ch.is_ascii_alphanumeric() {
- result.push(ch.to_ascii_lowercase());
- prev_dash = false;
- } else if !prev_dash {
- result.push('-');
- prev_dash = true;
- }
- }
- if result.ends_with('-') {
- result.pop();
- }
- if result.is_empty() {
- // Chinese or special-character names fall back to a short hash.
- use std::collections::hash_map::DefaultHasher;
- use std::hash::{Hash, Hasher};
- let mut hasher = DefaultHasher::new();
- name.hash(&mut hasher);
- result = format!("profile-{}", hasher.finish() % 100000);
- }
- result
-}
-```
-
-- [ ] **Step 2: Implement profile directory and file helpers**
-
-```rust
-pub fn profiles_dir() -> std::path::PathBuf {
- config_dir().join("profiles")
-}
-
-pub fn profile_path(filename: &str) -> std::path::PathBuf {
- profiles_dir().join(filename)
-}
-```
-
-- [ ] **Step 3: Implement profile listing and activation detection**
-
-```rust
-use crate::config_io::load_config;
-
-pub fn list_profiles() -> anyhow::Result> {
- std::fs::create_dir_all(profiles_dir())?;
- let current_config = load_config(config_path())?;
-
- let mut profiles = vec![Profile {
- name: "default".to_string(),
- filename: "default".to_string(),
- config: current_config.clone(),
- is_active: true,
- }];
-
- for entry in std::fs::read_dir(profiles_dir())? {
- let entry = entry?;
- let path = entry.path();
- if path.extension().and_then(|s| s.to_str()) != Some("toml") {
- continue;
- }
- let filename = path.file_stem().unwrap().to_string_lossy().to_string();
- let config = load_config(&path)?;
- let is_active = configs_equal(¤t_config, &config);
- profiles.push(Profile {
- name: filename.clone(),
- filename: format!("{}.toml", filename),
- config,
- is_active,
- });
- }
-
- Ok(profiles)
-}
-
-fn configs_equal(a: &Config, b: &Config) -> bool {
- a.default_model == b.default_model
- && a.providers.len() == b.providers.len()
- && a.models.len() == b.models.len()
- && a.providers.iter().all(|(k, v)| {
- b.providers.get(k).map_or(false, |other| {
- v.provider_type == other.provider_type
- && v.base_url == other.base_url
- && v.api_key == other.api_key
- && v.env == other.env
- })
- })
- && a.models.iter().all(|(k, v)| {
- b.models.get(k).map_or(false, |other| {
- v.provider == other.provider
- && v.model == other.model
- && v.max_context_size == other.max_context_size
- && v.display_name == other.display_name
- })
- })
-}
-```
-
-- [ ] **Step 4: Implement profile CRUD and switch**
-
-```rust
-use crate::config_io::save_config;
-
-pub fn save_profile(filename: &str, config: &Config) -> anyhow::Result<()> {
- std::fs::create_dir_all(profiles_dir())?;
- let path = profile_path(filename);
- save_config(&path, config)
-}
-
-pub fn switch_profile(filename: &str) -> anyhow::Result {
- let path = profile_path(filename);
- let config = load_config(&path)?;
- save_config(config_path(), &config)?;
- Ok(config)
-}
-
-pub fn rename_profile(old_filename: &str, new_name: &str) -> anyhow::Result {
- let new_filename = format!("{}.toml", safe_filename(new_name));
- let old_path = profile_path(old_filename);
- let new_path = profile_path(&new_filename);
- if new_path.exists() {
- anyhow::bail!("profile already exists");
- }
- std::fs::rename(old_path, new_path)?;
- Ok(new_filename)
-}
-
-pub fn delete_profile(filename: &str) -> anyhow::Result<()> {
- let path = profile_path(filename);
- std::fs::remove_file(path)?;
- Ok(())
-}
-```
-
-- [ ] **Step 5: Run cargo test for profile_manager**
-
-Run:
-```bash
-cd src-tauri && cargo test profile_manager
-```
-
-Expected: tests pass.
-
----
-
-## Task 6: Implement Tauri Commands
-
-**Files:**
-- Create: `src-tauri/src/commands.rs`
-
-- [ ] **Step 1: Write commands.rs**
-
-```rust
-use crate::config_io::{config_dir, config_path, load_config, save_config};
-use crate::models::{Config, ProfileSummary, Table};
-use crate::profile_manager;
-use crate::validators::validate_config;
-
-/// Merge frontend-managed fields into the existing on-disk config,
-/// preserving raw_other (capabilities, overrides, custom_headers, [thinking], etc.).
-fn merge_with_existing(path: &std::path::PathBuf, incoming: Config) -> Result {
- let existing = load_config(path).unwrap_or_else(|_| Config {
- default_model: None,
- providers: Default::default(),
- models: Default::default(),
- raw_other: Table::new(),
- });
- Ok(Config {
- default_model: incoming.default_model,
- providers: incoming.providers,
- models: incoming.models,
- raw_other: existing.raw_other,
- })
-}
-
-#[tauri::command]
-pub fn load_config_command() -> Result {
- load_config(config_path()).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn save_config_command(config: Config) -> Result<(), String> {
- if let Err(errors) = validate_config(&config) {
- return Err(errors.iter().map(|e| e.to_string()).collect::>().join("\n"));
- }
- let merged = merge_with_existing(&config_path(), config)?;
- save_config(config_path(), &merged).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn list_profiles() -> Result, String> {
- let profiles = profile_manager::list_profiles().map_err(|e| e.to_string())?;
- Ok(profiles
- .into_iter()
- .map(|p| ProfileSummary {
- name: p.name,
- filename: p.filename,
- is_active: p.is_active,
- })
- .collect())
-}
-
-#[tauri::command]
-pub fn load_profile(filename: String) -> Result {
- profile_manager::load_profile(&filename).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn save_profile(filename: String, config: Config) -> Result<(), String> {
- let path = profile_manager::profile_path(&filename);
- let merged = merge_with_existing(&path, config)?;
- profile_manager::save_profile(&filename, &merged).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn switch_profile(filename: String) -> Result {
- profile_manager::switch_profile(&filename).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn rename_profile(old_filename: String, new_name: String) -> Result {
- profile_manager::rename_profile(&old_filename, &new_name).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn delete_profile(filename: String) -> Result<(), String> {
- profile_manager::delete_profile(&filename).map_err(|e| e.to_string())
-}
-
-#[tauri::command]
-pub fn open_config_dir() -> Result<(), String> {
- let path = config_dir();
- std::process::Command::new("explorer")
- .arg(path)
- .spawn()
- .map_err(|e| e.to_string())?;
- Ok(())
-}
-
-#[tauri::command]
-pub fn get_app_version() -> String {
- env!("CARGO_PKG_VERSION").to_string()
-}
-```
-
-- [ ] **Step 2: Add missing profile_manager::load_profile**
-
-Add to `profile_manager.rs`:
-```rust
-pub fn load_profile(filename: &str) -> anyhow::Result {
- load_config(profile_path(filename))
-}
-```
-
-- [ ] **Step 3: Run cargo check**
-
-Run:
-```bash
-cd src-tauri && cargo check
-```
-
-Expected: compilation succeeds.
-
----
-
-## Task 7: Configure Tauri v2 Permissions
-
-**Files:**
-- Create: `src-tauri/capabilities/default.json`
-- Modify: `src-tauri/tauri.conf.json`
-
-- [ ] **Step 1: Create capabilities/default.json**
-
-```json
-{
- "$schema": "../gen/schemas/desktop-schema.json",
- "identifier": "default",
- "description": "Default capabilities for KimiSwitch",
- "windows": ["main"],
- "permissions": [
- "core:default",
- "shell:allow-open",
- {
- "identifier": "fs:allow-read-file",
- "allow": [
- { "path": "$HOME/.kimi-code/config.toml" },
- { "path": "$HOME/.kimi-code/profiles/*" }
- ]
- },
- {
- "identifier": "fs:allow-write-file",
- "allow": [
- { "path": "$HOME/.kimi-code/config.toml" },
- { "path": "$HOME/.kimi-code/profiles/*" }
- ]
- },
- {
- "identifier": "fs:allow-read-dir",
- "allow": [
- { "path": "$HOME/.kimi-code" },
- { "path": "$HOME/.kimi-code/profiles" }
- ]
- },
- {
- "identifier": "fs:allow-create-dir",
- "allow": [
- { "path": "$HOME/.kimi-code" },
- { "path": "$HOME/.kimi-code/profiles" }
- ]
- }
- ]
-}
-```
-
-- [ ] **Step 2: Update tauri.conf.json bundle config**
-
-Ensure `"targets": ["msi"]` is set and add windows-specific config:
-
-```json
-"bundle": {
- "active": true,
- "targets": ["msi"],
- "windows": {
- "webviewInstallMode": {
- "type": "downloadBootstrapper"
- },
- "nsis": null
- },
- "icon": [
- "icons/32x32.png",
- "icons/128x128.png",
- "icons/128x128@2x.png",
- "icons/icon.ico"
- ]
-}
-```
-
-- [ ] **Step 3: Add placeholder icons**
-
-Create simple PNG/ICO icons or copy from Tauri default. For now, create empty files as placeholders.
-
-Run:
-```bash
-mkdir -p src-tauri/icons
-touch src-tauri/icons/32x32.png src-tauri/icons/128x128.png src-tauri/icons/128x128@2x.png src-tauri/icons/icon.ico
-```
-
-Note: Replace with real icons before final build.
-
----
-
-## Task 8: Frontend Types and useConfig Hook
-
-**Files:**
-- Create: `src/types/index.ts`
-- Create: `src/hooks/useConfig.ts`
-
-- [ ] **Step 1: Create src/types/index.ts**
-
-```typescript
-export type ProviderType =
- | "kimi"
- | "anthropic"
- | "openai"
- | "openai_responses"
- | "google-genai"
- | "vertexai";
-
-export interface Provider {
- name: string;
- provider_type: ProviderType;
- base_url: string | null;
- api_key: string | null;
- env: Record;
-}
-
-export interface Model {
- alias: string;
- provider: string;
- model: string;
- max_context_size: number;
- display_name: string | null;
-}
-
-export interface Config {
- default_model: string | null;
- providers: Record;
- models: Record;
-}
-
-export interface ProfileSummary {
- name: string;
- filename: string;
- is_active: boolean;
-}
-```
-
-- [ ] **Step 2: Create src/hooks/useConfig.ts**
-
-```typescript
-import { useState, useEffect, useCallback } from "react";
-import { invoke } from "@tauri-apps/api/core";
-import type { Config, ProfileSummary, Provider, Model } from "../types";
-
-export function useConfig() {
- const [config, setConfig] = useState(null);
- const [profiles, setProfiles] = useState([]);
- const [dirty, setDirty] = useState(false);
- const [error, setError] = useState(null);
- const [loading, setLoading] = useState(false);
-
- const refresh = useCallback(async () => {
- setLoading(true);
- try {
- const [cfg, profs] = await Promise.all([
- invoke("load_config_command"),
- invoke("list_profiles"),
- ]);
- setConfig(cfg);
- setProfiles(profs);
- setError(null);
- setDirty(false);
- } catch (e) {
- setError(String(e));
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- refresh();
- }, [refresh]);
-
- const updateConfig = useCallback((updater: (cfg: Config) => Config) => {
- setConfig((prev) => (prev ? updater(prev) : prev));
- setDirty(true);
- }, []);
-
- const save = useCallback(async () => {
- if (!config) return;
- try {
- await invoke("save_config_command", { config });
- setDirty(false);
- await refresh();
- } catch (e) {
- setError(String(e));
- }
- }, [config, refresh]);
-
- const switchProfile = useCallback(
- async (filename: string) => {
- try {
- const cfg = await invoke("switch_profile", { filename });
- setConfig(cfg);
- setDirty(false);
- await refresh();
- } catch (e) {
- setError(String(e));
- }
- },
- [refresh]
- );
-
- return {
- config,
- profiles,
- dirty,
- error,
- loading,
- refresh,
- save,
- switchProfile,
- updateConfig,
- };
-}
-```
-
----
-
-## Task 9: Build TopBar Component
-
-**Files:**
-- Create: `src/components/TopBar.tsx`
-
-- [ ] **Step 1: Write TopBar.tsx**
-
-```tsx
-import { useState, useEffect } from "react";
-import type { Config, ProfileSummary, ProviderType } from "../types";
-
-interface TopBarProps {
- config: Config;
- profiles: ProfileSummary[];
- selectedProvider: string;
- selectedModel: string;
- onProviderChange: (name: string) => void;
- onModelChange: (alias: string) => void;
- onSetDefault: () => void;
- onProfileAction: (action: "new" | "duplicate" | "rename" | "delete" | "switch", filename: string) => void;
-}
-
-export function TopBar({
- config,
- profiles,
- selectedProvider,
- selectedModel,
- onProviderChange,
- onModelChange,
- onSetDefault,
- onProfileAction,
-}: TopBarProps) {
- const [profileFilename, setProfileFilename] = useState("");
- const providerOptions = Object.values(config.providers);
- const modelOptions = Object.values(config.models).filter(
- (m) => m.provider === selectedProvider
- );
-
- useEffect(() => {
- const active = profiles.find((p) => p.is_active);
- setProfileFilename(active?.filename || profiles[0]?.filename || "");
- }, [profiles]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-```
-
----
-
-## Task 10: Build ProviderList and ProviderForm
-
-**Files:**
-- Create: `src/components/ProviderList.tsx`
-- Create: `src/components/ProviderForm.tsx`
-
-- [ ] **Step 1: Write ProviderList.tsx**
-
-```tsx
-import type { Provider } from "../types";
-
-interface ProviderListProps {
- providers: Provider[];
- selected: string;
- onSelect: (name: string) => void;
- onAdd: () => void;
- onDelete: (name: string) => void;
-}
-
-export function ProviderList({ providers, selected, onSelect, onAdd, onDelete }: ProviderListProps) {
- return (
-
-
供应商
-
- {providers.map((p) => (
-
onSelect(p.name)}
- >
- {p.name}
-
-
- ))}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: Write ProviderForm.tsx**
-
-```tsx
-import type { Provider, ProviderType } from "../types";
-
-const PROVIDER_TYPES: ProviderType[] = [
- "kimi",
- "anthropic",
- "openai",
- "openai_responses",
- "google-genai",
- "vertexai",
-];
-
-interface ProviderFormProps {
- provider: Provider;
- onChange: (provider: Provider) => void;
-}
-
-function credentialEnvKeys(type: ProviderType): { apiKey: string; baseUrl: string } {
- switch (type) {
- case "kimi":
- return { apiKey: "KIMI_API_KEY", baseUrl: "KIMI_BASE_URL" };
- case "anthropic":
- return { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" };
- case "openai":
- case "openai_responses":
- return { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" };
- case "google-genai":
- return { apiKey: "GOOGLE_API_KEY", baseUrl: "GOOGLE_GEMINI_BASE_URL" };
- case "vertexai":
- return { apiKey: "VERTEXAI_API_KEY", baseUrl: "GOOGLE_VERTEX_BASE_URL" };
- }
-}
-
-export function ProviderForm({ provider, onChange }: ProviderFormProps) {
- const useDirect = !!(provider.api_key || provider.base_url);
- const keys = credentialEnvKeys(provider.provider_type);
-
- const setUseDirect = (direct: boolean) => {
- if (direct === useDirect) return;
- if (direct) {
- // Move credentials from env to direct fields
- const env = { ...provider.env };
- const apiKey = env[keys.apiKey] || "";
- const baseUrl = env[keys.baseUrl] || "";
- delete env[keys.apiKey];
- delete env[keys.baseUrl];
- onChange({
- ...provider,
- api_key: apiKey || null,
- base_url: baseUrl || null,
- env,
- });
- } else {
- // Move credentials from direct fields to env
- const env = { ...provider.env };
- if (provider.api_key) env[keys.apiKey] = provider.api_key;
- if (provider.base_url) env[keys.baseUrl] = provider.base_url;
- onChange({
- ...provider,
- api_key: null,
- base_url: null,
- env,
- });
- }
- };
-
- const update = (patch: Partial) => {
- onChange({ ...provider, ...patch });
- };
-
- return (
-
- );
-}
-
-function EnvTable({
- env,
- onChange,
- disabled,
-}: {
- env: Record;
- onChange: (env: Record) => void;
- disabled: boolean;
-}) {
- const entries = Object.entries(env);
-
- return (
-
- );
-}
-```
-
----
-
-## Task 11: Build ModelList and ModelForm
-
-**Files:**
-- Create: `src/components/ModelList.tsx`
-- Create: `src/components/ModelForm.tsx`
-
-- [ ] **Step 1: Write ModelList.tsx**
-
-```tsx
-import type { Model } from "../types";
-
-interface ModelListProps {
- models: Model[];
- selected: string;
- onSelect: (alias: string) => void;
- onAdd: () => void;
- onDelete: (alias: string) => void;
-}
-
-export function ModelList({ models, selected, onSelect, onAdd, onDelete }: ModelListProps) {
- return (
-
-
模型别名
-
- {models.map((m) => (
-
onSelect(m.alias)}
- >
- {m.alias}
-
-
- ))}
-
-
-
- );
-}
-```
-
-- [ ] **Step 2: Write ModelForm.tsx**
-
-```tsx
-import type { Model } from "../types";
-
-interface ModelFormProps {
- model: Model;
- providers: string[];
- onChange: (model: Model) => void;
- onSetDefault: () => void;
-}
-
-export function ModelForm({ model, providers, onChange, onSetDefault }: ModelFormProps) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- onChange({ ...model, model: e.target.value })}
- />
-
-
-
-
-
- onChange({ ...model, max_context_size: Number(e.target.value) })
- }
- />
-
-
-
-
-
- onChange({ ...model, display_name: e.target.value || null })
- }
- />
-
-
-
-
- );
-}
-```
-
----
-
-## Task 12: Build BottomToolbar and App.tsx
-
-**Files:**
-- Create: `src/components/BottomToolbar.tsx`
-- Create: `src/App.tsx`
-
-- [ ] **Step 1: Write BottomToolbar.tsx**
-
-```tsx
-interface BottomToolbarProps {
- dirty: boolean;
- onSave: () => void;
- onReload: () => void;
- onSaveAsProfile: () => void;
- onOpenDir: () => void;
-}
-
-export function BottomToolbar({
- dirty,
- onSave,
- onReload,
- onSaveAsProfile,
- onOpenDir,
-}: BottomToolbarProps) {
- return (
-
-
-
-
-
- {dirty && 有未保存修改}
-
- );
-}
-```
-
-- [ ] **Step 2: Write App.tsx**
-
-```tsx
-import { useState, useMemo } from "react";
-import { invoke } from "@tauri-apps/api/core";
-import { useConfig } from "./hooks/useConfig";
-import { TopBar } from "./components/TopBar";
-import { ProviderList } from "./components/ProviderList";
-import { ProviderForm } from "./components/ProviderForm";
-import { ModelList } from "./components/ModelList";
-import { ModelForm } from "./components/ModelForm";
-import { BottomToolbar } from "./components/BottomToolbar";
-import type { Config, Provider, Model } from "./types";
-
-export default function App() {
- const {
- config,
- profiles,
- dirty,
- error,
- loading,
- refresh,
- save,
- switchProfile,
- updateConfig,
- } = useConfig();
-
- const [selectedProvider, setSelectedProvider] = useState("");
- const [selectedModel, setSelectedModel] = useState("");
-
- const providers = useMemo(
- () => (config ? Object.values(config.providers) : []),
- [config]
- );
- const models = useMemo(
- () => (config ? Object.values(config.models) : []),
- [config]
- );
- const filteredModels = useMemo(
- () => models.filter((m) => m.provider === selectedProvider),
- [models, selectedProvider]
- );
-
- if (loading || !config) {
- return 加载中...
;
- }
-
- const handleProviderSelect = (name: string) => {
- setSelectedProvider(name);
- const firstModel = models.find((m) => m.provider === name);
- setSelectedModel(firstModel?.alias || "");
- };
-
- const handleSetDefault = () => {
- if (!selectedModel) return;
- updateConfig((cfg) => ({ ...cfg, default_model: selectedModel }));
- };
-
- const handleAddProvider = () => {
- const name = `provider-${providers.length + 1}`;
- updateConfig((cfg) => ({
- ...cfg,
- providers: { ...cfg.providers, [name]: {
- name,
- provider_type: "kimi",
- base_url: null,
- api_key: null,
- env: {},
- }},
- }));
- setSelectedProvider(name);
- };
-
- const handleUpdateProvider = (provider: Provider) => {
- updateConfig((cfg) => ({
- ...cfg,
- providers: { ...cfg.providers, [provider.name]: provider },
- }));
- };
-
- const handleDeleteProvider = (name: string) => {
- updateConfig((cfg) => {
- const providers = { ...cfg.providers };
- delete providers[name];
- return { ...cfg, providers };
- });
- if (selectedProvider === name) {
- setSelectedProvider("");
- setSelectedModel("");
- }
- };
-
- const handleAddModel = () => {
- const alias = `model-${models.length + 1}`;
- updateConfig((cfg) => ({
- ...cfg,
- models: {
- ...cfg.models,
- [alias]: {
- alias,
- provider: selectedProvider || Object.keys(cfg.providers)[0] || "",
- model: "",
- max_context_size: 262144,
- display_name: null,
- },
- },
- }));
- setSelectedModel(alias);
- };
-
- const handleUpdateModel = (model: Model) => {
- updateConfig((cfg) => ({
- ...cfg,
- models: { ...cfg.models, [model.alias]: model },
- }));
- };
-
- const handleDeleteModel = (alias: string) => {
- updateConfig((cfg) => {
- const models = { ...cfg.models };
- delete models[alias];
- return { ...cfg, models };
- });
- if (selectedModel === alias) setSelectedModel("");
- };
-
- const handleProfileAction = async (
- action: "new" | "duplicate" | "rename" | "delete" | "switch",
- filename: string
- ) => {
- if (action === "switch") {
- await switchProfile(filename);
- return;
- }
-
- if (action === "new") {
- const name = prompt("输入 Profile 名称")?.trim();
- if (!name) return;
- const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
- await invoke("save_profile", {
- filename: `${safe}.toml`,
- config,
- });
- await refresh();
- return;
- }
-
- if (filename === "default") {
- alert("default Profile 不可进行复制/重命名/删除");
- return;
- }
-
- if (action === "duplicate") {
- const cfg = await invoke("load_profile", { filename });
- const base = filename.replace(/\.toml$/, "");
- const newFilename = `${base}-copy.toml`;
- await invoke("save_profile", { filename: newFilename, config: cfg });
- await refresh();
- } else if (action === "rename") {
- const name = prompt("输入新 Profile 名称")?.trim();
- if (!name) return;
- await invoke("rename_profile", { oldFilename: filename, newName: name });
- await refresh();
- } else if (action === "delete") {
- if (!confirm(`确定删除 Profile ${filename}?`)) return;
- await invoke("delete_profile", { filename });
- await refresh();
- }
- };
-
- const currentProvider = config.providers[selectedProvider];
- const currentModel = config.models[selectedModel];
-
- return (
-
-
-
- {error && (
-
{error}
- )}
-
-
-
-
-
- {currentProvider ? (
-
- ) : (
-
请选择或添加供应商
- )}
-
-
-
-
-
-
-
- {currentModel ? (
-
- ) : (
-
请选择或添加模型
- )}
-
-
-
-
{
- const name = prompt("输入新 Profile 名称")?.trim();
- if (!name) return;
- const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
- await invoke("save_profile", {
- filename: `${safe}.toml`,
- config,
- });
- await refresh();
- }}
- onOpenDir={() => invoke("open_config_dir")}
- />
-
- );
-}
-```
-
----
-
-## Task 13: Tailwind Configuration
-
-**Files:**
-- Create: `tailwind.config.js`
-- Create: `postcss.config.js`
-
-- [ ] **Step 1: Create tailwind.config.js**
-
-```javascript
-/** @type {import('tailwindcss').Config} */
-export default {
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
- theme: {
- extend: {},
- },
- plugins: [],
-};
-```
-
-- [ ] **Step 2: Create postcss.config.js**
-
-```javascript
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
-```
-
----
-
-## Task 14: Rust Unit Tests
-
-**Files:**
-- Create: `src-tauri/tests/config_io.rs`
-- Create: `src-tauri/tests/validators.rs`
-- Create: `src-tauri/tests/profile_manager.rs`
-
-- [ ] **Step 1: Write validators test**
-
-```rust
-use kimiswitch_lib::models::{Config, Model, Provider, ProviderType, Table};
-use kimiswitch_lib::validators::validate_config;
-use indexmap::IndexMap;
-
-fn valid_config() -> Config {
- let mut providers = IndexMap::new();
- providers.insert(
- "kimi".to_string(),
- Provider {
- name: "kimi".to_string(),
- provider_type: ProviderType::Kimi,
- base_url: None,
- api_key: None,
- env: [("KIMI_API_KEY".to_string(), "sk-xxx".to_string())]
- .into_iter()
- .collect(),
- raw_other: Table::new(),
- },
- );
-
- let mut models = IndexMap::new();
- models.insert(
- "kimi-coding".to_string(),
- Model {
- alias: "kimi-coding".to_string(),
- provider: "kimi".to_string(),
- model: "kimi-for-coding".to_string(),
- max_context_size: 262144,
- display_name: None,
- raw_other: Table::new(),
- },
- );
-
- Config {
- default_model: Some("kimi-coding".to_string()),
- providers,
- models,
- raw_other: Table::new(),
- }
-}
-
-#[test]
-fn test_valid_config() {
- assert!(validate_config(&valid_config()).is_ok());
-}
-
-#[test]
-fn test_missing_credentials() {
- let mut cfg = valid_config();
- cfg.providers.get_mut("kimi").unwrap().env.clear();
- let errors = validate_config(&cfg).unwrap_err();
- assert!(errors.iter().any(|e| e.to_string().contains("missing credentials")));
-}
-
-#[test]
-fn test_unknown_model_provider() {
- let mut cfg = valid_config();
- cfg.models.get_mut("kimi-coding").unwrap().provider = "missing".to_string();
- let errors = validate_config(&cfg).unwrap_err();
- assert!(errors.iter().any(|e| e.to_string().contains("Unknown model provider")));
-}
-```
-
-- [ ] **Step 2: Run Rust tests**
-
-Run:
-```bash
-cd src-tauri && cargo test
-```
-
-Expected: all tests pass.
-
----
-
-## Task 15: Build MSI Installer
-
-**Files:**
-- Modify: `src-tauri/tauri.conf.json`
-- Create: real icon files in `src-tauri/icons/`
-
-- [ ] **Step 1: Verify tauri.conf.json bundle settings**
-
-Ensure:
-```json
-"bundle": {
- "active": true,
- "targets": ["msi"]
-}
-```
-
-- [ ] **Step 2: Add real icon files**
-
-Generate icons using Tauri CLI:
-```bash
-npm run tauri icon path/to/icon.png
-```
-
-If no icon yet, use a temporary 1024x1024 PNG.
-
-- [ ] **Step 3: Build release MSI**
-
-Run:
-```bash
-npm run tauri build
-```
-
-Expected output:
-```
-src-tauri/target/release/bundle/msi/KimiSwitch_0.1.0_x64_en-US.msi
-```
-
-- [ ] **Step 4: Install and smoke test on Windows**
-
-Double-click the MSI, verify:
-- Install completes without errors.
-- Start menu shortcut appears.
-- App launches.
-- Can create provider, model, save, switch profiles.
-
----
-
-## Task 16: Manual End-to-End Testing
-
-- [ ] **Step 1: Fresh config scenario**
-
-Delete `~/.kimi-code/config.toml`, launch KimiSwitch, verify it creates an empty config and default profile.
-
-- [ ] **Step 2: Existing config scenario**
-
-Create a hand-written `config.toml` with one provider and one model. Launch KimiSwitch, verify it loads correctly.
-
-- [ ] **Step 3: Provider/model switch**
-
-Add two providers with one model each. Use TopBar to switch between them and click「设为默认」. Verify `default_model` in `config.toml` updates.
-
-- [ ] **Step 4: Profile switch**
-
-Create two profiles with different providers. Switch between them. Verify `config.toml` content changes.
-
-- [ ] **Step 5: Validation error**
-
-Create a model referencing a non-existent provider. Click save. Verify error dialog appears and file is not overwritten.
-
----
-
-## Self-Review
-
-### Spec Coverage
-
-| Spec Section | Implementing Task |
-| --- | --- |
-| Tauri v2 + Rust + TS/React + Tailwind | Task 1 |
-| ProviderType enum with serde rename | Task 2 |
-| IndexMap for ordering | Task 2 |
-| Profile filename safe化 | Task 5 |
-| `toml_edit` round-trip | Task 3 |
-| Tauri permissions | Task 7 |
-| Tauri commands | Task 6 |
-| Credential env/direct mutual exclusivity | Task 10 (ProviderForm) |
-| `is_active` semantic compare | Task 5 |
-| Three-pane UI with TopBar | Tasks 9–12 |
-| Validation rules | Task 4 |
-| MSI packaging | Task 15 |
-| Windows-specific paths | Task 3 |
-| raw_other preservation | Task 6 (`merge_with_existing`) |
-
-### Placeholder Scan
-
-No TBD/TODO/fill-in-details found. All code blocks contain concrete implementations.
-
-### Type Consistency
-
-- `Provider`, `Model`, `Config` types match between Rust (`models.rs`) and TypeScript (`src/types/index.ts`).
-- Command names in `lib.rs` match `commands.rs` and `useConfig.ts`.
-- `Table` re-exported from `models.rs` so integration tests can construct models.
-
-### Implementation Notes
-
-- **raw_other preservation**: `save_config_command` and `save_profile_command` load the existing on-disk file, merge managed fields from the frontend, and keep the original `raw_other`. This protects `[thinking]`, `[loop_control]`, `custom_headers`, `capabilities`, `overrides`, and comments/formatting.
-- **Provider/model rename**: For simplicity in the first implementation, provider `name` and model `alias` are read-only in the edit forms. Users can delete and recreate, or rename via Profile-level operations. A future enhancement can add explicit rename with reference updates.
-- **Chinese Profile names**: The safe-filename function keeps ASCII alphanumeric characters; pure Chinese names fall back to a hash-based filename.
-- **Provider default Base URL auto-fill**: Not implemented in this plan; add as a follow-up enhancement.
diff --git a/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md b/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md
deleted file mode 100644
index 489bbb9..0000000
--- a/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md
+++ /dev/null
@@ -1,270 +0,0 @@
-# i18n / Agent Switch / UI Cleanup 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:** Complete Chinese/English i18n, replace the target dropdown with a CC Switch-style icon selector, clean up the header/footer layout, fix backend validation so managed providers and half-empty models don’t block saving, and ensure the app only fetches model lists on explicit user action.
-
-**Architecture:** Keep the existing Rust backend commands and `ConfigTarget` abstraction. Front-end state stays in `App.tsx`; UI pieces live in `src/components/`. Validation/sanitization moves into a small Rust helper that normalizes config before validation, so legacy or in-progress configs can be saved without surprising the user.
-
-**Tech Stack:** Tauri v2 (Rust), React + Tailwind CSS, TypeScript, `toml_edit`, `serde_yaml`.
-
----
-
-### Task 1: Fix backend validation / sanitization
-
-**Files:**
-- Modify: `src-tauri/src/validators.rs`
-- Modify: `src-tauri/src/commands.rs`
-- Test: `src-tauri/tests/validators.rs`
-
-- [ ] **Step 1.1: Treat `managed:*` providers as managed**
-
-In `validators.rs`, add a helper and use it in the credential check:
-
-```rust
-fn is_managed_provider(provider: &Provider) -> bool {
- provider.managed || provider.name.starts_with("managed:")
-}
-```
-
-Replace the credential guard condition:
-
-```rust
-if !is_managed_provider(provider) && !matches!(provider.provider_type, ProviderType::Vertexai) {
- // existing ambiguous / missing checks
-}
-```
-
-- [ ] **Step 1.2: Sanitize config before validation**
-
-In `commands.rs`, add a `sanitize_config(config: &mut Config)` function before `validate_config` is called in `save_config_command`:
-
-```rust
-fn sanitize_config(config: &mut Config) {
- // Managed:* providers should carry the managed flag.
- for provider in config.providers.values_mut() {
- if provider.name.starts_with("managed:") {
- provider.managed = true;
- }
- }
-
- let first_provider = config.providers.keys().next().cloned();
- for model in config.models.values_mut() {
- if model.provider.trim().is_empty() {
- if let Some(name) = &first_provider {
- model.provider = name.clone();
- }
- }
- if model.model.trim().is_empty() {
- model.model = model.alias.clone();
- }
- if model.max_context_size == 0 {
- model.max_context_size = 262_144;
- }
- }
-}
-```
-
-Call it at the top of `save_config_command`:
-
-```rust
-pub fn save_config_command(
- app: tauri::AppHandle,
- target: ConfigTarget,
- mut config: Config,
-) -> Result<(), String> {
- sanitize_config(&mut config);
- if let Err(errors) = validate_config(&config) { ... }
- ...
-}
-```
-
-- [ ] **Step 1.3: Add/update tests**
-
-Add tests in `src-tauri/tests/validators.rs` for:
-1. `managed:kimi-code` with empty credentials passes.
-2. `glm-5` with empty provider/model/max_context_size is sanitized and then passes.
-
-Run: `cd src-tauri && cargo test`
-Expected: all tests pass.
-
----
-
-### Task 2: Remove implicit model auto-fetch
-
-**Files:**
-- Modify: `src/components/ProviderEdit.tsx`
-
-- [ ] **Step 2.1: Delete the auto-fetch `useEffect`**
-
-Remove:
-
-```tsx
-const [autoFetched, setAutoFetched] = useState(false);
-
-useEffect(() => {
- if (models.length === 0 && !autoFetched && !discovering) {
- setAutoFetched(true);
- handleDiscover();
- }
-}, [models.length, autoFetched, discovering, handleDiscover]);
-```
-
-Keep the explicit `获取模型列表` button and the discovered-models panel. Models are now fetched only when the user clicks the button.
-
----
-
-### Task 3: Redesign header and footer
-
-**Files:**
-- Modify: `src/App.tsx`
-- Modify: `src/i18n/zh.ts`
-- Modify: `src/i18n/en.ts`
-- Modify: `src/index.css` (if needed for footer)
-
-- [ ] **Step 3.1: Replace target dropdown with icon selector**
-
-In `App.tsx`, replace the `