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 字符折叠) | | **未保存提示** | 关闭窗口前检测未保存修改,标题栏加 `*` 前缀 | +## 界面预览 + +**供应商列表**(浅色主题) + +![供应商列表](docs/screenshots/providers.png) + +清晰展示当前激活的供应商、默认模型、延迟和可用模型数量,支持快速切换、复制、跳转官网、编辑和删除操作。 + +**编辑供应商 - 基本信息** + +![编辑供应商-基本信息](docs/screenshots/provider-basic-info.png) + +包含供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key 与请求地址等基础字段。 + +**编辑供应商 - 模型映射** + +![编辑供应商-模型映射](docs/screenshots/provider-model-mapping.png) + +一张表管理全部模型映射:显示名称、实际请求模型、上下文长度、1M 上下文声明、能力标签(思考/图像/视频/工具调用/其他),支持「一键设置」「获取模型列表」批量填充。 + +**用量仪表盘** + +![用量仪表盘](docs/screenshots/dashboard.png) + +8 项核心 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图)+ 模型用量明细(请求、Token、缓存命中、费用)。 + +**会话管理** + +![会话管理](docs/screenshots/sessions.png) + +按工作区隔离浏览 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) + +![Provider list](docs/screenshots/providers.png) + +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** + +![Edit provider — basic info](docs/screenshots/provider-basic-info.png) + +Provider name, notes, official URL, managed-provider toggle, API format, API key and base URL — the essentials in one panel. + +**Edit provider — model mapping** + +![Edit provider — model mapping](docs/screenshots/provider-model-mapping.png) + +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** + +![Usage dashboard](docs/screenshots/dashboard.png) + +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** + +![Session management](docs/screenshots/sessions.png) + +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 ( -
-
- - -
- -
- - -
- -
- - update({ base_url: e.target.value || null })} - /> -
- -
- - update({ api_key: e.target.value || null })} - /> -
- -
- - update({ env })} - disabled={useDirect} - /> -
-
- ); -} - -function EnvTable({ - env, - onChange, - disabled, -}: { - env: Record; - onChange: (env: Record) => void; - disabled: boolean; -}) { - const entries = Object.entries(env); - - return ( -
- {entries.map(([k, v], idx) => ( -
- { - const newEnv = { ...env }; - delete newEnv[k]; - newEnv[e.target.value] = v; - onChange(newEnv); - }} - /> - onChange({ ...env, [k]: e.target.value })} - /> - -
- ))} - -
- ); -} -``` - ---- - -## Task 11: Build ModelList and ModelForm - -**Files:** -- Create: `src/components/ModelList.tsx` -- Create: `src/components/ModelForm.tsx` - -- [ ] **Step 1: Write ModelList.tsx** - -```tsx -import type { Model } from "../types"; - -interface ModelListProps { - models: Model[]; - selected: string; - onSelect: (alias: string) => void; - onAdd: () => void; - onDelete: (alias: string) => void; -} - -export function ModelList({ models, selected, onSelect, onAdd, onDelete }: ModelListProps) { - return ( -
-
模型别名
-
- {models.map((m) => ( -
onSelect(m.alias)} - > - {m.alias} - -
- ))} -
- -
- ); -} -``` - -- [ ] **Step 2: Write ModelForm.tsx** - -```tsx -import type { Model } from "../types"; - -interface ModelFormProps { - model: Model; - providers: string[]; - onChange: (model: Model) => void; - onSetDefault: () => void; -} - -export function ModelForm({ model, providers, onChange, onSetDefault }: ModelFormProps) { - return ( -
-
- - -
- -
- - -
- -
- - onChange({ ...model, model: e.target.value })} - /> -
- -
- - - onChange({ ...model, max_context_size: Number(e.target.value) }) - } - /> -
- -
- - - onChange({ ...model, display_name: e.target.value || null }) - } - /> -
- - -
- ); -} -``` - ---- - -## Task 12: Build BottomToolbar and App.tsx - -**Files:** -- Create: `src/components/BottomToolbar.tsx` -- Create: `src/App.tsx` - -- [ ] **Step 1: Write BottomToolbar.tsx** - -```tsx -interface BottomToolbarProps { - dirty: boolean; - onSave: () => void; - onReload: () => void; - onSaveAsProfile: () => void; - onOpenDir: () => void; -} - -export function BottomToolbar({ - dirty, - onSave, - onReload, - onSaveAsProfile, - onOpenDir, -}: BottomToolbarProps) { - return ( -
- - - - - {dirty && 有未保存修改} -
- ); -} -``` - -- [ ] **Step 2: Write App.tsx** - -```tsx -import { useState, useMemo } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { useConfig } from "./hooks/useConfig"; -import { TopBar } from "./components/TopBar"; -import { ProviderList } from "./components/ProviderList"; -import { ProviderForm } from "./components/ProviderForm"; -import { ModelList } from "./components/ModelList"; -import { ModelForm } from "./components/ModelForm"; -import { BottomToolbar } from "./components/BottomToolbar"; -import type { Config, Provider, Model } from "./types"; - -export default function App() { - const { - config, - profiles, - dirty, - error, - loading, - refresh, - save, - switchProfile, - updateConfig, - } = useConfig(); - - const [selectedProvider, setSelectedProvider] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - - const providers = useMemo( - () => (config ? Object.values(config.providers) : []), - [config] - ); - const models = useMemo( - () => (config ? Object.values(config.models) : []), - [config] - ); - const filteredModels = useMemo( - () => models.filter((m) => m.provider === selectedProvider), - [models, selectedProvider] - ); - - if (loading || !config) { - return
加载中...
; - } - - const handleProviderSelect = (name: string) => { - setSelectedProvider(name); - const firstModel = models.find((m) => m.provider === name); - setSelectedModel(firstModel?.alias || ""); - }; - - const handleSetDefault = () => { - if (!selectedModel) return; - updateConfig((cfg) => ({ ...cfg, default_model: selectedModel })); - }; - - const handleAddProvider = () => { - const name = `provider-${providers.length + 1}`; - updateConfig((cfg) => ({ - ...cfg, - providers: { ...cfg.providers, [name]: { - name, - provider_type: "kimi", - base_url: null, - api_key: null, - env: {}, - }}, - })); - setSelectedProvider(name); - }; - - const handleUpdateProvider = (provider: Provider) => { - updateConfig((cfg) => ({ - ...cfg, - providers: { ...cfg.providers, [provider.name]: provider }, - })); - }; - - const handleDeleteProvider = (name: string) => { - updateConfig((cfg) => { - const providers = { ...cfg.providers }; - delete providers[name]; - return { ...cfg, providers }; - }); - if (selectedProvider === name) { - setSelectedProvider(""); - setSelectedModel(""); - } - }; - - const handleAddModel = () => { - const alias = `model-${models.length + 1}`; - updateConfig((cfg) => ({ - ...cfg, - models: { - ...cfg.models, - [alias]: { - alias, - provider: selectedProvider || Object.keys(cfg.providers)[0] || "", - model: "", - max_context_size: 262144, - display_name: null, - }, - }, - })); - setSelectedModel(alias); - }; - - const handleUpdateModel = (model: Model) => { - updateConfig((cfg) => ({ - ...cfg, - models: { ...cfg.models, [model.alias]: model }, - })); - }; - - const handleDeleteModel = (alias: string) => { - updateConfig((cfg) => { - const models = { ...cfg.models }; - delete models[alias]; - return { ...cfg, models }; - }); - if (selectedModel === alias) setSelectedModel(""); - }; - - const handleProfileAction = async ( - action: "new" | "duplicate" | "rename" | "delete" | "switch", - filename: string - ) => { - if (action === "switch") { - await switchProfile(filename); - return; - } - - if (action === "new") { - const name = prompt("输入 Profile 名称")?.trim(); - if (!name) return; - const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - await invoke("save_profile", { - filename: `${safe}.toml`, - config, - }); - await refresh(); - return; - } - - if (filename === "default") { - alert("default Profile 不可进行复制/重命名/删除"); - return; - } - - if (action === "duplicate") { - const cfg = await invoke("load_profile", { filename }); - const base = filename.replace(/\.toml$/, ""); - const newFilename = `${base}-copy.toml`; - await invoke("save_profile", { filename: newFilename, config: cfg }); - await refresh(); - } else if (action === "rename") { - const name = prompt("输入新 Profile 名称")?.trim(); - if (!name) return; - await invoke("rename_profile", { oldFilename: filename, newName: name }); - await refresh(); - } else if (action === "delete") { - if (!confirm(`确定删除 Profile ${filename}?`)) return; - await invoke("delete_profile", { filename }); - await refresh(); - } - }; - - const currentProvider = config.providers[selectedProvider]; - const currentModel = config.models[selectedModel]; - - return ( -
- - - {error && ( -
{error}
- )} - -
-
- -
- -
- {currentProvider ? ( - - ) : ( -
请选择或添加供应商
- )} -
- -
- -
- -
- {currentModel ? ( - - ) : ( -
请选择或添加模型
- )} -
-
- - { - const name = prompt("输入新 Profile 名称")?.trim(); - if (!name) return; - const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - await invoke("save_profile", { - filename: `${safe}.toml`, - config, - }); - await refresh(); - }} - onOpenDir={() => invoke("open_config_dir")} - /> -
- ); -} -``` - ---- - -## Task 13: Tailwind Configuration - -**Files:** -- Create: `tailwind.config.js` -- Create: `postcss.config.js` - -- [ ] **Step 1: Create tailwind.config.js** - -```javascript -/** @type {import('tailwindcss').Config} */ -export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: {}, - }, - plugins: [], -}; -``` - -- [ ] **Step 2: Create postcss.config.js** - -```javascript -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; -``` - ---- - -## Task 14: Rust Unit Tests - -**Files:** -- Create: `src-tauri/tests/config_io.rs` -- Create: `src-tauri/tests/validators.rs` -- Create: `src-tauri/tests/profile_manager.rs` - -- [ ] **Step 1: Write validators test** - -```rust -use kimiswitch_lib::models::{Config, Model, Provider, ProviderType, Table}; -use kimiswitch_lib::validators::validate_config; -use indexmap::IndexMap; - -fn valid_config() -> Config { - let mut providers = IndexMap::new(); - providers.insert( - "kimi".to_string(), - Provider { - name: "kimi".to_string(), - provider_type: ProviderType::Kimi, - base_url: None, - api_key: None, - env: [("KIMI_API_KEY".to_string(), "sk-xxx".to_string())] - .into_iter() - .collect(), - raw_other: Table::new(), - }, - ); - - let mut models = IndexMap::new(); - models.insert( - "kimi-coding".to_string(), - Model { - alias: "kimi-coding".to_string(), - provider: "kimi".to_string(), - model: "kimi-for-coding".to_string(), - max_context_size: 262144, - display_name: None, - raw_other: Table::new(), - }, - ); - - Config { - default_model: Some("kimi-coding".to_string()), - providers, - models, - raw_other: Table::new(), - } -} - -#[test] -fn test_valid_config() { - assert!(validate_config(&valid_config()).is_ok()); -} - -#[test] -fn test_missing_credentials() { - let mut cfg = valid_config(); - cfg.providers.get_mut("kimi").unwrap().env.clear(); - let errors = validate_config(&cfg).unwrap_err(); - assert!(errors.iter().any(|e| e.to_string().contains("missing credentials"))); -} - -#[test] -fn test_unknown_model_provider() { - let mut cfg = valid_config(); - cfg.models.get_mut("kimi-coding").unwrap().provider = "missing".to_string(); - let errors = validate_config(&cfg).unwrap_err(); - assert!(errors.iter().any(|e| e.to_string().contains("Unknown model provider"))); -} -``` - -- [ ] **Step 2: Run Rust tests** - -Run: -```bash -cd src-tauri && cargo test -``` - -Expected: all tests pass. - ---- - -## Task 15: Build MSI Installer - -**Files:** -- Modify: `src-tauri/tauri.conf.json` -- Create: real icon files in `src-tauri/icons/` - -- [ ] **Step 1: Verify tauri.conf.json bundle settings** - -Ensure: -```json -"bundle": { - "active": true, - "targets": ["msi"] -} -``` - -- [ ] **Step 2: Add real icon files** - -Generate icons using Tauri CLI: -```bash -npm run tauri icon path/to/icon.png -``` - -If no icon yet, use a temporary 1024x1024 PNG. - -- [ ] **Step 3: Build release MSI** - -Run: -```bash -npm run tauri build -``` - -Expected output: -``` -src-tauri/target/release/bundle/msi/KimiSwitch_0.1.0_x64_en-US.msi -``` - -- [ ] **Step 4: Install and smoke test on Windows** - -Double-click the MSI, verify: -- Install completes without errors. -- Start menu shortcut appears. -- App launches. -- Can create provider, model, save, switch profiles. - ---- - -## Task 16: Manual End-to-End Testing - -- [ ] **Step 1: Fresh config scenario** - -Delete `~/.kimi-code/config.toml`, launch KimiSwitch, verify it creates an empty config and default profile. - -- [ ] **Step 2: Existing config scenario** - -Create a hand-written `config.toml` with one provider and one model. Launch KimiSwitch, verify it loads correctly. - -- [ ] **Step 3: Provider/model switch** - -Add two providers with one model each. Use TopBar to switch between them and click「设为默认」. Verify `default_model` in `config.toml` updates. - -- [ ] **Step 4: Profile switch** - -Create two profiles with different providers. Switch between them. Verify `config.toml` content changes. - -- [ ] **Step 5: Validation error** - -Create a model referencing a non-existent provider. Click save. Verify error dialog appears and file is not overwritten. - ---- - -## Self-Review - -### Spec Coverage - -| Spec Section | Implementing Task | -| --- | --- | -| Tauri v2 + Rust + TS/React + Tailwind | Task 1 | -| ProviderType enum with serde rename | Task 2 | -| IndexMap for ordering | Task 2 | -| Profile filename safe化 | Task 5 | -| `toml_edit` round-trip | Task 3 | -| Tauri permissions | Task 7 | -| Tauri commands | Task 6 | -| Credential env/direct mutual exclusivity | Task 10 (ProviderForm) | -| `is_active` semantic compare | Task 5 | -| Three-pane UI with TopBar | Tasks 9–12 | -| Validation rules | Task 4 | -| MSI packaging | Task 15 | -| Windows-specific paths | Task 3 | -| raw_other preservation | Task 6 (`merge_with_existing`) | - -### Placeholder Scan - -No TBD/TODO/fill-in-details found. All code blocks contain concrete implementations. - -### Type Consistency - -- `Provider`, `Model`, `Config` types match between Rust (`models.rs`) and TypeScript (`src/types/index.ts`). -- Command names in `lib.rs` match `commands.rs` and `useConfig.ts`. -- `Table` re-exported from `models.rs` so integration tests can construct models. - -### Implementation Notes - -- **raw_other preservation**: `save_config_command` and `save_profile_command` load the existing on-disk file, merge managed fields from the frontend, and keep the original `raw_other`. This protects `[thinking]`, `[loop_control]`, `custom_headers`, `capabilities`, `overrides`, and comments/formatting. -- **Provider/model rename**: For simplicity in the first implementation, provider `name` and model `alias` are read-only in the edit forms. Users can delete and recreate, or rename via Profile-level operations. A future enhancement can add explicit rename with reference updates. -- **Chinese Profile names**: The safe-filename function keeps ASCII alphanumeric characters; pure Chinese names fall back to a hash-based filename. -- **Provider default Base URL auto-fill**: Not implemented in this plan; add as a follow-up enhancement. 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 `` in the top-right of the header, next to the agent selector. - -- [ ] **Step 3.4: Add missing translation keys** - -Add `unsavedChanges: "未保存修改"` / `"Unsaved changes"` to both translation files. - ---- - -### Task 4: Improve adaptive / maximize-friendly layout - -**Files:** -- Modify: `src/components/ProviderList.tsx` -- Modify: `src/components/ProviderEdit.tsx` - -- [ ] **Step 4.1: ProviderList cards adapt to width** - -Ensure the list container is `flex-1 overflow-auto` and cards use `w-full`. The right-side action buttons should wrap on narrow widths by adding `flex-wrap` to their container. - -- [ ] **Step 4.2: ProviderEdit table scrolls horizontally** - -Wrap the model mapping table in a `div` with `overflow-auto`: - -```tsx -
- ...
-
-``` - -Change the two-column basic-info grid to collapse on small widths by using `grid-cols-1 md:grid-cols-2`. - ---- - -### Task 5: Complete zh/en i18n - -**Files:** -- Modify: `src/i18n/zh.ts` -- Modify: `src/i18n/en.ts` - -- [ ] **Step 5.1: Audit for hardcoded strings** - -Search `src/components/` and `src/App.tsx` for any literal Chinese or English UI text not using `t()`. Add keys and replace them. Ensure keys added to both `zh.ts` and `en.ts`. - ---- - -### Task 6: Rename / update logo and icons - -**Files:** -- Modify: `index.html` -- Create/overwrite: `public/kimi.svg` -- Create/overwrite: `src-tauri/icons/32x32.png`, `src-tauri/icons/128x128.png`, `src-tauri/icons/128x128@2x.png`, `src-tauri/icons/icon.ico` - -- [ ] **Step 6.1: Generate Kimi Switch icons** - -Use a Python script (Pillow) to generate PNG/ICO files with a π symbol. Update `index.html` favicon to `/kimi.svg`. - -- [ ] **Step 6.2: Verify app title** - -`tauri.conf.json` already has `"title": "Kimi Switch"`. Ensure `index.html` `` is also `Kimi Switch`. - ---- - -### Task 7: Build and run - -**Files:** N/A - -- [ ] **Step 7.1: Run frontend build** - -`npm run build` -Expected: no TypeScript or build errors. - -- [ ] **Step 7.2: Run Rust tests** - -`cd src-tauri && cargo test` -Expected: all tests pass. - -- [ ] **Step 7.3: Start dev app** - -`npm run tauri-dev` -Expected: app window opens, agent switch works, save/reload/open buttons work, model fetch is manual. - ---- - -### Self-Review - -- **Spec coverage:** managed-provider error → Task 1.1; glm-5 empty fields → Task 1.2; manual model fetch → Task 2; CC Switch icon selector → Task 3.1; footer action placement → Task 3.2; adaptive layout → Task 4; i18n → Task 5; logo/title → Task 6. -- **Placeholder scan:** no TBD/TODO/fill-in details; each step has file paths and code. -- **Type consistency:** `ConfigTarget` values remain `"kimi"` / `"omp"`; translation keys match existing `TranslationKey` type. diff --git a/docs/superpowers/plans/2026-07-08-kimicode-reload-hint.md b/docs/superpowers/plans/2026-07-08-kimicode-reload-hint.md deleted file mode 100644 index 41f5080..0000000 --- a/docs/superpowers/plans/2026-07-08-kimicode-reload-hint.md +++ /dev/null @@ -1,192 +0,0 @@ -# Kimi Code /reload Hint 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:** Add an info icon tooltip next to the "switch provider" button, visible only in the Kimi Code tab, reminding users to run `/reload` after switching providers. - -**Architecture:** Extend `ProviderList` to receive the current `agent` and conditionally render a hint icon beside the switch button. Keep changes minimal and UI-only; no state or backend changes. - -**Tech Stack:** React 18, TypeScript, Tailwind CSS, custom i18n module (`src/i18n`). - ---- - -### Task 1: Add i18n translation keys - -**Files:** -- Modify: `src/i18n/zh.ts` -- Modify: `src/i18n/en.ts` - -- [ ] **Step 1: Add Chinese translation** - -Insert `switchReloadHint` into the "Provider list" section of `src/i18n/zh.ts`, e.g. after `switchTo`: - -```ts - switchReloadHint: "切换供应商后,请在 Kimi Code 中执行 /reload 以生效。", -``` - -- [ ] **Step 2: Add English translation** - -Insert the matching key into `src/i18n/en.ts`: - -```ts - switchReloadHint: "After switching providers, run /reload in Kimi Code to apply the change.", -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/i18n/zh.ts src/i18n/en.ts -git commit -m "i18n: add switchReloadHint for Kimi Code reload reminder" -``` - ---- - -### Task 2: Update ProviderList to accept agent and render hint - -**Files:** -- Modify: `src/components/ProviderList.tsx` - -- [ ] **Step 1: Import Agent type and update props** - -Add `Agent` to the import from `../types` and add `agent` to `ProviderListProps`: - -```tsx -import { useTranslation } from "../i18n"; -import type { Agent, Model, Provider } from "../types"; - -interface ProviderListProps { - providers: Provider[]; - defaultModel: string | null; - models: Record<string, Model>; - onEdit: (name: string) => void; - onDelete: (name: string) => void; - onAdd: () => void; - onSwitchProvider: (name: string) => void; - agent: Agent; -} -``` - -- [ ] **Step 2: Destructure agent in component signature** - -```tsx -export function ProviderList({ - providers, - defaultModel, - models, - onEdit, - onDelete, - onAdd, - onSwitchProvider, - agent, -}: ProviderListProps) { -``` - -- [ ] **Step 3: Render hint icon next to the switch button** - -In the supplier card actions area, place an info icon immediately after the "切换使用" / "Switch to" button, rendered only when `agent === "kimi_code"`: - -```tsx - <button - type="button" - onClick={(e) => { - e.stopPropagation(); - onSwitchProvider(provider.name); - }} - className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${ - isActive - ? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500" - : "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500" - }`} - > - {isActive ? t("inUse") : t("switchTo")} - </button> - {agent === "kimi_code" && ( - <span - className="text-sm text-gray-500 hover:text-gray-300 cursor-help select-none" - title={t("switchReloadHint")} - aria-label={t("switchReloadHint")} - > - ⓘ - </span> - )} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/ProviderList.tsx -git commit -m "feat: show /reload hint next to switch provider button in Kimi Code tab" -``` - ---- - -### Task 3: Pass agent from App to ProviderList - -**Files:** -- Modify: `src/App.tsx` - -- [ ] **Step 1: Add agent prop to ProviderList usage** - -Locate the `<ProviderList ... />` call in `src/App.tsx` and add `agent={agent}`: - -```tsx - <ProviderList - providers={providers} - defaultModel={config.default_model} - models={config.models} - onEdit={(name) => { - setEditingProvider(name); - setView("edit"); - }} - onDelete={handleDeleteProvider} - onAdd={handleAddProvider} - onSwitchProvider={handleSwitchProvider} - agent={agent} - /> -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/App.tsx -git commit -m "chore: pass current agent to ProviderList" -``` - ---- - -### Task 4: Verify with type check and manual test - -- [ ] **Step 1: Run TypeScript compilation** - -```bash -npm run build -``` - -Expected: `tsc` completes without errors and Vite builds successfully. - -- [ ] **Step 2: Run the app** - -```bash -npm run tauri-dev -``` - -- [ ] **Step 3: Manual verification** - -1. Select the **Kimi Code** tab. -2. Hover over the `ⓘ` icon next to any provider's "切换使用" / "Switch to" button. -3. Confirm the tooltip reads: - - 中文:`切换供应商后,请在 Kimi Code 中执行 /reload 以生效。` - - English: `After switching providers, run /reload in Kimi Code to apply the change.` -4. Switch to the **Pi** tab and confirm the `ⓘ` icon is **not** present. - -- [ ] **Step 4: Commit verification notes (optional)** - -If any fixes were needed, commit them; otherwise no additional commit is required. - ---- - -## Self-Review - -- **Spec coverage:** Every design requirement (icon next to switch button, Kimi Code only, i18n, no new dependencies) maps to a task above. -- **Placeholder scan:** No TBD/TODO/fill-in-details present. -- **Type consistency:** `Agent` type is imported from `../types` and used consistently in props and conditional rendering. diff --git a/docs/superpowers/plans/2026-07-09-kimicode-global-settings-panel.md b/docs/superpowers/plans/2026-07-09-kimicode-global-settings-panel.md deleted file mode 100644 index 8ceb95b..0000000 --- a/docs/superpowers/plans/2026-07-09-kimicode-global-settings-panel.md +++ /dev/null @@ -1,937 +0,0 @@ -# Kimi Code 全局配置面板与模型默认上下文 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:** Add a Kimi Code global settings panel inside ProviderEdit and auto-set `max_context_size` for new models based on a model-ID lookup table. - -**Architecture:** Extend the shared `Config` type with typed agent settings, add two pure helper modules (`agent-settings.ts` for `raw_other` IO and `model-defaults.ts` for context lookup), and render the panel in a new React component wired through `ProviderEdit`. - -**Tech Stack:** React 18, TypeScript, Tailwind CSS, custom i18n, Tauri v2 backend (no Rust changes). - ---- - -### Task 1: Add agent settings types - -**Files:** -- Modify: `src/types/index.ts` - -- [ ] **Step 1: Append new types** - -Insert at the end of `src/types/index.ts`: - -```ts -export interface ThinkingConfig { - enabled?: boolean; - effort?: "low" | "medium" | "high" | "max"; - keep?: "all" | false | 0 | "no" | "off" | "none" | null; -} - -export interface LoopControlConfig { - max_retries_per_step?: number; - reserved_context_size?: number; -} - -export interface BackgroundConfig { - max_running_tasks?: number; - keep_alive_on_exit?: boolean; -} - -export interface PermissionRule { - decision?: "allow" | "deny" | "ask"; - scope?: string; - pattern?: string; - reason?: string; -} - -export interface Hook { - event?: string; - matcher?: string; - command?: string; - timeout?: number; -} - -export interface AgentSettings { - thinking?: ThinkingConfig; - loop_control?: LoopControlConfig; - background?: BackgroundConfig; - permission?: { rules?: PermissionRule[] }; - hooks?: Hook[]; -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/types/index.ts -git commit -m "types: add AgentSettings and related Kimi Code config types" -``` - ---- - -### Task 2: Create model default context size lookup - -**Files:** -- Create: `src/lib/model-defaults.ts` - -- [ ] **Step 1: Create the lookup module** - -Create `src/lib/model-defaults.ts`: - -```ts -export const DEFAULT_MAX_CONTEXT_SIZE = 256000; - -interface ModelContextRule { - pattern: RegExp; - max_context_size: number; -} - -const MODEL_CONTEXT_RULES: ModelContextRule[] = [ - // Kimi - { pattern: /^kimi-for-coding$/i, max_context_size: 262144 }, - { pattern: /^kimi-k2\.5/i, max_context_size: 256000 }, - { pattern: /^kimi-k2/i, max_context_size: 256000 }, - { pattern: /^kimi-/i, max_context_size: 256000 }, - // GLM - { pattern: /^glm-5\.2/i, max_context_size: 1000000 }, - { pattern: /^glm-5\.1/i, max_context_size: 256000 }, - { pattern: /^glm-5/i, max_context_size: 256000 }, - { pattern: /^glm-4/i, max_context_size: 128000 }, - { pattern: /^glm-/i, max_context_size: 128000 }, - // MiniMax - { pattern: /^MiniMax-M3/i, max_context_size: 1000000 }, - { pattern: /^MiniMax-Text-01/i, max_context_size: 400000 }, - { pattern: /^MiniMax-/i, max_context_size: 256000 }, - // Qwen - { pattern: /^qwen2\.5/i, max_context_size: 128000 }, - { pattern: /^qwen-max/i, max_context_size: 128000 }, - { pattern: /^qwen-plus/i, max_context_size: 128000 }, - { pattern: /^qwen-turbo/i, max_context_size: 128000 }, - { pattern: /^qwen-coder/i, max_context_size: 128000 }, - { pattern: /^qwen-/i, max_context_size: 128000 }, - // DeepSeek - { pattern: /^deepseek-r1/i, max_context_size: 64000 }, - { pattern: /^deepseek-v3/i, max_context_size: 64000 }, - { pattern: /^deepseek-coder/i, max_context_size: 64000 }, - { pattern: /^deepseek-/i, max_context_size: 64000 }, - // Hunyuan - { pattern: /^hunyuan-pro/i, max_context_size: 32000 }, - { pattern: /^hunyuan-standard/i, max_context_size: 32000 }, - { pattern: /^hunyuan-lite/i, max_context_size: 32000 }, - { pattern: /^hunyuan-/i, max_context_size: 32000 }, - // Doubao - { pattern: /^doubao-pro/i, max_context_size: 128000 }, - { pattern: /^doubao-lite/i, max_context_size: 128000 }, - { pattern: /^doubao-vision/i, max_context_size: 128000 }, - { pattern: /^doubao-/i, max_context_size: 128000 }, - // ERNIE - { pattern: /^ernie-4\.0/i, max_context_size: 128000 }, - { pattern: /^ernie-3\.5/i, max_context_size: 128000 }, - { pattern: /^ernie-speed/i, max_context_size: 128000 }, - { pattern: /^ernie-lite/i, max_context_size: 128000 }, - { pattern: /^ernie-/i, max_context_size: 128000 }, - // Spark - { pattern: /^spark-v4/i, max_context_size: 32000 }, - { pattern: /^spark-v3\.5/i, max_context_size: 32000 }, - { pattern: /^spark-pro/i, max_context_size: 32000 }, - { pattern: /^spark-max/i, max_context_size: 32000 }, - { pattern: /^spark-/i, max_context_size: 32000 }, - // SenseChat - { pattern: /^sensechat-/i, max_context_size: 128000 }, - // Baichuan - { pattern: /^baichuan-4/i, max_context_size: 128000 }, - { pattern: /^baichuan-3/i, max_context_size: 128000 }, - { pattern: /^baichuan-/i, max_context_size: 128000 }, - // Yi - { pattern: /^yi-/i, max_context_size: 128000 }, - // Claude - { pattern: /^claude-opus/i, max_context_size: 200000 }, - { pattern: /^claude-sonnet/i, max_context_size: 200000 }, - { pattern: /^claude-haiku/i, max_context_size: 200000 }, - { pattern: /^claude-/i, max_context_size: 200000 }, - // OpenAI - { pattern: /^gpt-4\.1/i, max_context_size: 1047576 }, - { pattern: /^gpt-4o/i, max_context_size: 128000 }, - { pattern: /^gpt-4-turbo/i, max_context_size: 128000 }, - { pattern: /^gpt-4-/i, max_context_size: 128000 }, - // Gemini - { pattern: /^gemini-2\.0-flash/i, max_context_size: 1048576 }, - { pattern: /^gemini-1\.5-pro/i, max_context_size: 2097152 }, - { pattern: /^gemini-1\.5-flash/i, max_context_size: 1048576 }, - { pattern: /^gemini-/i, max_context_size: 1048576 }, -]; - -export function getDefaultMaxContextSize(modelId: string): number { - for (const rule of MODEL_CONTEXT_RULES) { - if (rule.pattern.test(modelId)) { - return rule.max_context_size; - } - } - return DEFAULT_MAX_CONTEXT_SIZE; -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/lib/model-defaults.ts -git commit -m "feat: add model default max_context_size lookup table" -``` - ---- - -### Task 3: Create agent settings IO helpers - -**Files:** -- Create: `src/lib/agent-settings.ts` - -- [ ] **Step 1: Create helper module** - -Create `src/lib/agent-settings.ts`: - -```ts -import type { AgentSettings } from "../types"; - -const DEFAULT_SETTINGS: AgentSettings = { - thinking: { - enabled: true, - effort: "medium", - keep: "all", - }, - loop_control: { - max_retries_per_step: 3, - reserved_context_size: 50000, - }, - background: { - keep_alive_on_exit: false, - }, - permission: { rules: [] }, - hooks: [], -}; - -function asRecord(value: unknown): Record<string, unknown> { - if (value && typeof value === "object" && !Array.isArray(value)) { - return value as Record<string, unknown>; - } - return {}; -} - -function getSection<T>(rawOther: unknown, key: string): T | undefined { - const root = asRecord(rawOther); - const section = root[key]; - if (section === undefined || section === null) return undefined; - return section as T; -} - -export function getAgentSettings(rawOther: unknown): AgentSettings { - return { - thinking: { - ...DEFAULT_SETTINGS.thinking, - ...getSection<AgentSettings["thinking"]>(rawOther, "thinking"), - }, - loop_control: { - ...DEFAULT_SETTINGS.loop_control, - ...getSection<AgentSettings["loop_control"]>(rawOther, "loop_control"), - }, - background: { - ...DEFAULT_SETTINGS.background, - ...getSection<AgentSettings["background"]>(rawOther, "background"), - }, - permission: { - rules: getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ?? [], - }, - hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [], - }; -} - -export function setAgentSettings( - rawOther: unknown, - patch: Partial<AgentSettings> -): unknown { - const root = { ...asRecord(rawOther) }; - const current = getAgentSettings(rawOther); - const next: AgentSettings = { - thinking: { ...current.thinking, ...patch.thinking }, - loop_control: { ...current.loop_control, ...patch.loop_control }, - background: { ...current.background, ...patch.background }, - permission: { rules: patch.permission?.rules ?? current.permission?.rules ?? [] }, - hooks: patch.hooks ?? current.hooks ?? [], - }; - - if (next.thinking) root.thinking = next.thinking; - if (next.loop_control) root.loop_control = next.loop_control; - if (next.background) root.background = next.background; - if (next.permission?.rules && next.permission.rules.length > 0) { - root.permission = next.permission; - } else { - delete root.permission; - } - if (next.hooks && next.hooks.length > 0) { - root.hooks = next.hooks; - } else { - delete root.hooks; - } - - return root; -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/lib/agent-settings.ts -git commit -m "feat: add agent settings read/write helpers" -``` - ---- - -### Task 4: Add i18n keys - -**Files:** -- Modify: `src/i18n/zh.ts` -- Modify: `src/i18n/en.ts` - -- [ ] **Step 1: Add Chinese keys** - -Insert into `src/i18n/zh.ts` in the "Agent / target" section: - -```ts - agentSettings: "全局配置", - enableThinking: "启用思考", - thinkingLevel: "思考等级", - thinkingKeep: "保留思考内容", - thinkingLow: "低", - thinkingMedium: "中", - thinkingHigh: "高", - thinkingMax: "最大", - thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", - loopControlSettings: "循环控制", - maxRetriesPerStep: "单步重试次数", - reservedContextSize: "上下文预留大小", - backgroundSettings: "后台任务", - maxRunningTasks: "最大并发数", - keepAliveOnExit: "退出时保持运行", - permissionRules: "权限规则", - permissionDecision: "处置", - permissionPattern: "模式", - permissionAllow: "允许", - permissionDeny: "拒绝", - permissionAsk: "询问", - addRule: "+ 添加规则", - addCommonRules: "添加常用规则", - hooks: "生命周期钩子", - hookEvent: "事件", - hookMatcher: "匹配器", - hookCommand: "命令", - hookTimeout: "超时", - addHook: "+ 添加钩子", -``` - -- [ ] **Step 2: Add English keys** - -Insert matching keys into `src/i18n/en.ts`: - -```ts - agentSettings: "Global Settings", - enableThinking: "Enable thinking", - thinkingLevel: "Thinking level", - thinkingKeep: "Keep thinking content", - thinkingLow: "Low", - thinkingMedium: "Medium", - thinkingHigh: "High", - thinkingMax: "Max", - thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.", - loopControlSettings: "Loop Control", - maxRetriesPerStep: "Max retries per step", - reservedContextSize: "Reserved context size", - backgroundSettings: "Background Tasks", - maxRunningTasks: "Max running tasks", - keepAliveOnExit: "Keep alive on exit", - permissionRules: "Permission Rules", - permissionDecision: "Decision", - permissionPattern: "Pattern", - permissionAllow: "Allow", - permissionDeny: "Deny", - permissionAsk: "Ask", - addRule: "+ Add rule", - addCommonRules: "Add common rules", - hooks: "Lifecycle Hooks", - hookEvent: "Event", - hookMatcher: "Matcher", - hookCommand: "Command", - hookTimeout: "Timeout", - addHook: "+ Add hook", -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/i18n/zh.ts src/i18n/en.ts -git commit -m "i18n: add agent settings panel translations" -``` - ---- - -### Task 5: Build the AgentSettingsPanel component - -**Files:** -- Create: `src/components/AgentSettingsPanel.tsx` - -- [ ] **Step 1: Create the panel component** - -Create `src/components/AgentSettingsPanel.tsx`: - -```tsx -import { useTranslation } from "../i18n"; -import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; -import type { AgentSettings, PermissionRule, Hook } from "../types"; -import type { ReactNode } from "react"; -import type { TranslationKey } from "../i18n/zh"; - -interface AgentSettingsPanelProps { - rawOther: unknown; - onChange: (nextRawOther: unknown) => void; -} - -const THINKING_LEVELS = ["low", "medium", "high", "max"] as const; -const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = { - low: "thinkingLow", - medium: "thinkingMedium", - high: "thinkingHigh", - max: "thinkingMax", -}; -const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; -const PERMISSION_LABELS: Record<(typeof PERMISSION_DECISIONS)[number], TranslationKey> = { - allow: "permissionAllow", - deny: "permissionDeny", - ask: "permissionAsk", -}; -const COMMON_EVENTS = ["PreToolUse", "PostToolUse"] as const; - -export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { - const { t } = useTranslation(); - const settings = getAgentSettings(rawOther); - - const update = (patch: Partial<AgentSettings>) => { - onChange(setAgentSettings(rawOther, patch)); - }; - - const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => { - update({ thinking: { ...settings.thinking, ...patch } }); - }; - - const updateLoopControl = (patch: Partial<AgentSettings["loop_control"]>) => { - update({ loop_control: { ...settings.loop_control, ...patch } }); - }; - - const updateBackground = (patch: Partial<AgentSettings["background"]>) => { - update({ background: { ...settings.background, ...patch } }); - }; - - const setRules = (rules: PermissionRule[]) => { - update({ permission: { rules } }); - }; - - const setHooks = (hooks: Hook[]) => { - update({ hooks }); - }; - - const thinkingEnabled = settings.thinking?.enabled ?? true; - - return ( - <div className="mt-6 space-y-4"> - <h3 className="text-gray-400 text-sm font-medium">{t("agentSettings")}</h3> - - {/* Thinking */} - <Card title={t("enableThinking")}> - <Checkbox - label={t("enableThinking")} - checked={thinkingEnabled} - onChange={(checked) => updateThinking({ enabled: checked })} - /> - <div className="flex items-center gap-3 flex-wrap"> - <span className="text-sm text-gray-400">{t("thinkingLevel")}</span> - <Segmented - options={THINKING_LEVELS.map((lvl) => ({ key: lvl, label: t(THINKING_LABELS[lvl]) }))} - value={settings.thinking?.effort ?? "medium"} - onChange={(effort) => updateThinking({ effort })} - disabled={!thinkingEnabled} - /> - </div> - <Checkbox - label={t("thinkingKeep")} - checked={settings.thinking?.keep === "all"} - disabled={!thinkingEnabled} - onChange={(checked) => updateThinking({ keep: checked ? "all" : false })} - /> - <p className="text-xs text-gray-500">{t("thinkingContextHint")}</p> - </Card> - - {/* Loop Control */} - <Card title={t("loopControlSettings")}> - <NumberField - label={t("maxRetriesPerStep")} - value={settings.loop_control?.max_retries_per_step ?? 3} - onChange={(v) => updateLoopControl({ max_retries_per_step: v })} - /> - <NumberField - label={t("reservedContextSize")} - value={settings.loop_control?.reserved_context_size ?? 50000} - onChange={(v) => updateLoopControl({ reserved_context_size: v })} - /> - </Card> - - {/* Background */} - <Card title={t("backgroundSettings")}> - <NumberField - label={t("maxRunningTasks")} - value={settings.background?.max_running_tasks} - onChange={(v) => updateBackground({ max_running_tasks: v })} - /> - <Checkbox - label={t("keepAliveOnExit")} - checked={settings.background?.keep_alive_on_exit ?? false} - onChange={(checked) => updateBackground({ keep_alive_on_exit: checked })} - /> - </Card> - - {/* Permission Rules */} - <Card title={t("permissionRules")}> - <div className="space-y-2"> - {(settings.permission?.rules ?? []).map((rule, idx) => ( - <div key={idx} className="flex items-center gap-2"> - <select - className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={rule.decision ?? "allow"} - onChange={(e) => { - const rules = [...(settings.permission?.rules ?? [])]; - rules[idx] = { ...rule, decision: e.target.value as PermissionRule["decision"] }; - setRules(rules); - }} - > - {PERMISSION_DECISIONS.map((d) => ( - <option key={d} value={d}>{t(PERMISSION_LABELS[d])}</option> - ))} - </select> - <input - type="text" - className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={rule.pattern ?? ""} - placeholder={t("permissionPattern")} - onChange={(e) => { - const rules = [...(settings.permission?.rules ?? [])]; - rules[idx] = { ...rule, pattern: e.target.value }; - setRules(rules); - }} - /> - <button - type="button" - onClick={() => { - const rules = [...(settings.permission?.rules ?? [])]; - rules.splice(idx, 1); - setRules(rules); - }} - className="px-2 py-1 text-sm text-red-400 hover:bg-red-900/20 rounded" - > - × - </button> - </div> - ))} - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={() => { - setRules([...(settings.permission?.rules ?? []), { decision: "allow", pattern: "" }]); - }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" - > - {t("addRule")} - </button> - <button - type="button" - onClick={() => { - setRules([ - ...(settings.permission?.rules ?? []), - { decision: "allow", pattern: "Read" }, - { decision: "deny", pattern: "Bash(rm -rf*)" }, - ]); - }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" - > - {t("addCommonRules")} - </button> - </div> - </Card> - - {/* Hooks */} - <Card title={t("hooks")}> - <div className="space-y-2"> - {(settings.hooks ?? []).map((hook, idx) => ( - <div key={idx} className="flex items-center gap-2 flex-wrap"> - <input - list="hook-events" - type="text" - className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={hook.event ?? ""} - placeholder={t("hookEvent")} - onChange={(e) => { - const hooks = [...(settings.hooks ?? [])]; - hooks[idx] = { ...hook, event: e.target.value }; - setHooks(hooks); - }} - /> - <datalist id="hook-events"> - {COMMON_EVENTS.map((e) => <option key={e} value={e} />)} - </datalist> - <input - type="text" - className="w-24 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={hook.matcher ?? ""} - placeholder={t("hookMatcher")} - onChange={(e) => { - const hooks = [...(settings.hooks ?? [])]; - hooks[idx] = { ...hook, matcher: e.target.value }; - setHooks(hooks); - }} - /> - <input - type="text" - className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={hook.command ?? ""} - placeholder={t("hookCommand")} - onChange={(e) => { - const hooks = [...(settings.hooks ?? [])]; - hooks[idx] = { ...hook, command: e.target.value }; - setHooks(hooks); - }} - /> - <input - type="number" - className="w-20 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm" - value={hook.timeout ?? ""} - placeholder={t("hookTimeout")} - onChange={(e) => { - const hooks = [...(settings.hooks ?? [])]; - hooks[idx] = { ...hook, timeout: e.target.value === "" ? undefined : Number(e.target.value) }; - setHooks(hooks); - }} - /> - <button - type="button" - onClick={() => { - const hooks = [...(settings.hooks ?? [])]; - hooks.splice(idx, 1); - setHooks(hooks); - }} - className="px-2 py-1 text-sm text-red-400 hover:bg-red-900/20 rounded" - > - × - </button> - </div> - ))} - </div> - <button - type="button" - onClick={() => { - setHooks([...(settings.hooks ?? []), { event: "", matcher: "", command: "" }]); - }} - className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]" - > - {t("addHook")} - </button> - </Card> - </div> - ); -} - -function Card({ title, children }: { title: string; children: ReactNode }) { - return ( - <div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3"> - <h4 className="text-gray-400 text-sm font-medium">{title}</h4> - {children} - </div> - ); -} - -function Checkbox({ - label, - checked, - disabled, - onChange, -}: { - label: string; - checked: boolean; - disabled?: boolean; - onChange: (checked: boolean) => void; -}) { - return ( - <label className={`flex items-center gap-2 text-sm ${disabled ? "text-gray-600" : "text-[#e5e5e7]"}`}> - <input - type="checkbox" - checked={checked} - disabled={disabled} - onChange={(e) => onChange(e.target.checked)} - className="rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500" - /> - {label} - </label> - ); -} - -function NumberField({ - label, - value, - onChange, -}: { - label: string; - value?: number; - onChange: (value: number | undefined) => void; -}) { - return ( - <label className="flex items-center gap-3 text-sm text-[#e5e5e7]"> - <span className="text-gray-400">{label}</span> - <input - type="number" - value={value ?? ""} - onChange={(e) => { - const raw = e.target.value; - onChange(raw === "" ? undefined : Number(raw)); - }} - className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> - </label> - ); -} - -function Segmented({ - options, - value, - onChange, - disabled, -}: { - options: { key: string; label: string }[]; - value: string; - onChange: (value: string) => void; - disabled?: boolean; -}) { - return ( - <div className={`flex items-center rounded overflow-hidden border border-[#2a2a2e] ${disabled ? "opacity-50" : ""}`}> - {options.map((opt) => ( - <button - key={opt.key} - type="button" - disabled={disabled} - onClick={() => onChange(opt.key)} - className={`px-3 py-1 text-sm ${ - value === opt.key - ? "bg-blue-600 text-white" - : "bg-[#1f1f23] text-gray-400 hover:bg-[#252529]" - }`} - > - {opt.label} - </button> - ))} - </div> - ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/components/AgentSettingsPanel.tsx -git commit -m "feat: add AgentSettingsPanel component" -``` - ---- - -### Task 6: Wire AgentSettingsPanel into ProviderEdit - -**Files:** -- Modify: `src/components/ProviderEdit.tsx` - -- [ ] **Step 1: Update ProviderEditProps** - -Add `rawOther` and `onRawOtherChange` to the props interface: - -```tsx -interface ProviderEditProps { - agent: Agent; - provider: Provider; - models: Model[]; - defaultModel: string | null; - rawOther: unknown; - onRawOtherChange: (nextRawOther: unknown) => void; - onBack: () => void; - // ... existing callbacks -} -``` - -- [ ] **Step 2: Destructure new props** - -```tsx -export function ProviderEdit({ - agent, - provider, - models, - defaultModel, - rawOther, - onRawOtherChange, - onBack, - // ... existing -}: ProviderEditProps) { -``` - -- [ ] **Step 3: Render the panel in the model mapping tab** - -Locate the model mapping tab content (around the model table and "+ 添加模型映射" button) and insert `<AgentSettingsPanel />` at the bottom, conditionally for Kimi Code: - -```tsx - {agent === "kimi_code" && ( - <AgentSettingsPanel rawOther={rawOther} onChange={onRawOtherChange} /> - )} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/components/ProviderEdit.tsx -git commit -m "feat: render AgentSettingsPanel in ProviderEdit for Kimi Code" -``` - ---- - -### Task 7: Wire ProviderEdit to App.tsx - -**Files:** -- Modify: `src/App.tsx` - -- [ ] **Step 1: Pass rawOther and updater to ProviderEdit** - -Find the `<ProviderEdit ... />` call and add: - -```tsx - <ProviderEdit - agent={agent} - provider={currentProvider} - models={...} - defaultModel={config.default_model} - rawOther={config.raw_other} - onRawOtherChange={(nextRawOther) => - updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther })) - } - // ... existing props - /> -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/App.tsx -git commit -m "chore: pass rawOther to ProviderEdit" -``` - ---- - -### Task 8: Apply smart max_context_size defaults when adding models - -**Files:** -- Modify: `src/App.tsx` - -- [ ] **Step 1: Import helper in App.tsx** - -In `src/App.tsx`, import: - -```tsx -import { getDefaultMaxContextSize } from "./lib/model-defaults"; -``` - -- [ ] **Step 2: Use lookup in onModelAdd** - -Find the `onModelAdd` prop passed to `<ProviderEdit />` and update the default `max_context_size`: - -```tsx - onModelAdd={() => { - const providerModels = Object.values(config.models).filter( - (m) => m.provider === currentProvider.name - ); - const alias = `${currentProvider.name}-${providerModels.length + 1}`; - updateConfig((cfg) => ({ - ...cfg, - models: { - ...cfg.models, - [alias]: { - alias, - provider: currentProvider.name, - model: "", - max_context_size: getDefaultMaxContextSize(alias), - display_name: null, - role: null, - supports_1m: false, - capabilities: [], - }, - }, - })); - }} -``` - -- [ ] **Step 3: Use lookup in handleApplyProviderJson** - -Update the model creation loop in `handleApplyProviderJson`: - -```tsx - updatedModels[alias] = { - ...m, - alias, - provider: provider.name, - max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model), - }; -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/App.tsx -git commit -m "feat: apply smart default max_context_size when adding models" -``` - ---- - -### Task 9: Verify with build and manual checks - -- [ ] **Step 1: Type check** - -```bash -npm run build -``` - -Expected: `tsc` and Vite build succeed. - -- [ ] **Step 2: Run the app** - -```bash -npm run tauri-dev -``` - -- [ ] **Step 3: Manual verification** - -1. Kimi Code tab → edit a provider → "模型映射" tab → scroll to bottom. -2. Confirm "全局配置" panel shows 5 cards. -3. Toggle "启用思考" and verify effort/keep enable/disable. -4. Change thinking effort, save, open `~/.kimi-code/config.toml`, verify `[thinking]` block. -5. Add a permission rule and a hook, save, verify `[[permission.rules]]` and `[[hooks]]` in TOML. -6. Add a new model with alias `glm-5.2-xxx`, verify `max_context_size` defaults to `1000000`. -7. Add a new model with unknown alias, verify `max_context_size` defaults to `256000`. -8. Switch to Pi tab, confirm global settings panel is not visible. - -- [ ] **Step 4: Commit any fixes** - -If any fixes were needed during verification, commit them. - ---- - -## Self-Review - -- **Spec coverage:** Every requirement (5 cards, model defaults, i18n, Kimi-only visibility, manual override) maps to a task. -- **Placeholder scan:** No TBD/TODO/fill-in-details; all code is shown. -- **Type consistency:** `AgentSettings`, helper signatures, and component props match across tasks. diff --git a/docs/superpowers/specs/2026-07-06-kimiswitch-design.md b/docs/superpowers/specs/2026-07-06-kimiswitch-design.md deleted file mode 100644 index 00022d4..0000000 --- a/docs/superpowers/specs/2026-07-06-kimiswitch-design.md +++ /dev/null @@ -1,420 +0,0 @@ -# KimiSwitch 设计文档 - -## 1. 项目概述 - -KimiSwitch 是一个 Windows 桌面配置管理工具,用于简化 Kimi Code CLI 的多供应商配置维护。它通过图形界面让用户增删改查 `[providers]` 与 `[models]`,支持多套配置**配置文件(Profile)**的保存与一键切换,最终生成符合 Kimi Code CLI 规范的 `~/.kimi-code/config.toml`。 - -## 2. 问题与目标 - -### 2.1 现状痛点 - -- Kimi Code CLI 的配置文件是 TOML 格式,需要手动编辑。 -- 维护多个供应商(Kimi、OpenAI、Anthropic、Gemini、Vertex 等)时,`base_url` 与 `api_key` 容易写错或重复。 -- 对 TOML 语法不熟的用户(例如 `[models."gpt-4.1"]` 需要引号)上手成本高。 -- 工作、个人、测试等场景需要多套 API 配置时,只能手动备份/替换 `config.toml`,切换麻烦且容易出错。 - -### 2.2 项目目标 - -- **优先做好 Windows 版本**:安装包、路径处理、UI 风格、权限行为均以 Windows 为首要目标平台。 -- 提供图形化界面,零 TOML 语法负担地维护供应商与模型别名。 -- **支持一键切换当前使用的供应商及其对应模型**:选择供应商后自动筛选关联模型,确认后立即将 `default_model` 写入 `config.toml`。 -- **支持多配置文件(Profile)管理**:保存多份独立配置,一键切换激活某一份到 `~/.kimi-code/config.toml`。 -- 支持 Kimi Code CLI 官方支持的全部供应商类型。 -- 自动处理凭证存储方式(直接字段 vs `[providers.<name>.env]` 子表)。 -- 以 Windows MSI 安装包形式分发给 Windows 用户。 - -## 3. 功能范围 - -### 3.1 In Scope - -- 供应商(providers)管理:增删改查。 -- 模型别名(models)管理:增删改查。 -- **供应商与模型快速切换**:选择供应商后只显示使用该供应商的模型,一键设为 `default_model`。 -- **Profile(配置快照)管理**:新建、重命名、复制、删除、切换 Profile;Profile 保存在 `~/.kimi-code/profiles/` 下,切换时写入 `~/.kimi-code/config.toml`。 -- 读取现有 `~/.kimi-code/config.toml`。 -- 保存时自动备份原配置并写入新配置。 -- 基础校验:必填项、重复别名、缺失的供应商引用、缺失凭证。 -- 生成 Windows MSI 安装包。 - -### 3.2 Out of Scope - -- 不实现加密存储:API Key 按 Kimi Code CLI 官方方式保存在 `config.toml` 中。 -- 不修改 `tui.toml`、`mcp.json`、OAuth 凭证等其他文件。 -- 不实现远程同步或云存储。 -- 不实现网络连通性测试或 API 调用验证。 -- 第一版不支持 macOS / Linux 安装包,仅输出 Windows x64 MSI;其他平台待 Windows x64 版本完成后再规划。 - -## 4. 技术方案 - -### 4.1 推荐方案 - -**Tauri(Rust + TypeScript)** - -- **Tauri v2**:Rust 编写的跨平台桌面框架,内置 Windows MSI 打包,产物体积小、启动快。本设计基于 Tauri v2 的权限与命令体系。 -- **Rust**:负责文件 I/O、配置解析、Profile 管理、校验逻辑;类型安全、性能高。 -- **TypeScript + React + Tailwind CSS**:负责前端界面;Tailwind 提供基础样式,无需引入重型 UI 组件库。 -- **toml_edit**:Rust 生态中支持 round-trip 的 TOML 编辑库,保留注释与格式。 -- **IndexMap**:替代 `HashMap` 保留 Provider / Model 的插入顺序。 -- **Tauri Bundler**:一键生成 `.msi` 安装包,无需额外 WiX 手动编写。 - -### 4.2 备选方案 - -| 方案 | 优点 | 缺点 | -| --- | --- | --- | -| Tauri + Rust + Vanilla TS | 依赖更少,构建更快 | 大型表单状态管理需手写 | -| Tauri + Rust + Vue/Svelte | 体积与 Vanilla 相近 | 团队需熟悉对应框架 | -| egui / Iced(纯 Rust) | 无前端构建链 | UI 灵活度与组件生态不如 Web | - -选择 **Tauri + Rust + React** 的原因是:Tauri 内置 MSI 打包且产物体积小;Rust 适合处理文件与配置逻辑;React 表单/表格生态成熟,能快速实现三栏配置界面。 - -## 5. 目录结构 - -``` -D:/AIGC/KimiSwitch -├── src-tauri/ -│ ├── Cargo.toml -│ ├── tauri.conf.json # Tauri 应用配置与打包选项 -│ └── src/ -│ ├── main.rs # 入口 -│ ├── lib.rs # 模块导出 -│ ├── models.rs # Provider / Model / Config / Profile 结构体 -│ ├── config_io.rs # 读写 config.toml 与 Profile 文件 -│ ├── profile_manager.rs # Profile 生命周期管理 -│ ├── validators.rs # 配置校验 -│ └── commands.rs # Tauri 暴露给前端的命令 -├── src/ # 前端源码 -│ ├── App.tsx -│ ├── components/ -│ │ ├── TopBar.tsx # Profile / 供应商 / 模型 / 设为默认 -│ │ ├── ProviderList.tsx -│ │ ├── ProviderForm.tsx -│ │ ├── ModelList.tsx -│ │ ├── ModelForm.tsx -│ │ └── BottomToolbar.tsx -│ ├── hooks/ -│ │ └── useConfig.ts # 配置状态与 Tauri 命令封装 -│ └── types/ -│ └── index.ts # 前端类型定义 -├── tests/ -│ ├── config_io.rs -│ ├── profile_manager.rs -│ └── validators.rs -├── package.json -├── tsconfig.json -├── vite.config.ts -├── Cargo.lock -├── README.md -└── .gitignore -``` - -## 6. 数据模型 - -### 6.1 ProviderType 枚举 - -```rust -#[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, -} -``` - -> 注意:`openai_responses` 与 `google-genai` 包含连字符,无法直接用 `rename_all = "snake_case"`,需单独 `#[serde(rename)]`。 - -### 6.2 Provider - -```rust -#[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>, // [providers.<name>.env] 子表,保留顺序 - pub raw_other: Table, // 保留本工具不直接编辑的其他字段(如 custom_headers) -} -``` - -### 6.3 Model - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Model { - pub alias: String, // 模型别名 - pub provider: String, // 引用的供应商 name - pub model: String, // 实际模型 ID - pub max_context_size: u64, - pub display_name: Option<String>, - pub raw_other: Table, // 保留 capabilities 等本工具不直接编辑的字段 -} -``` - -### 6.4 Config - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub default_model: Option<String>, - pub providers: IndexMap<String, Provider>, // 保留顺序 - pub models: IndexMap<String, Model>, // 保留顺序 - pub raw_other: Table, // 保留 config.toml 中与本工具无关的其他字段 -} -``` - -### 6.5 Profile - -```rust -#[derive(Debug, Clone)] -pub struct Profile { - pub name: String, // Profile 显示名称,可包含中文/空格 - pub filename: String, // 安全化的存储文件名,如 "my-profile.toml" - pub config: Config, // 该 Profile 对应的完整配置内容 - pub is_active: bool, // 是否当前已激活 -} -``` - -Profile 文件统一存放在 `~/.kimi-code/profiles/` 目录下。`default` Profile 特殊处理:它直接对应 `~/.kimi-code/config.toml`,不额外在 `profiles/` 下生成文件。 - -**文件名安全化**:Profile 显示名可包含中文、空格、大小写;存储时自动转译为合法 Windows 文件名。转译规则:保留 ASCII 字母数字,其他字符替换为 `-`,连续 `-` 合并,小写化;若结果为空(如纯中文名),则回退为基于名称哈希的短文件名(如 `profile-48291.toml`)。 - -## 7. 配置读写行为 - -### 7.1 读取流程 - -1. 定位 `~/.kimi-code/config.toml`(可通过 `KIMI_CODE_HOME` 环境变量覆盖;使用 Rust `std::path::PathBuf` 处理 Windows 路径)。 -2. 使用 `toml_edit` 解析 TOML(保留注释、空行与字段顺序)。 -3. 提取 `[providers.*]` 与 `[models.*]` 到 Rust 结构体。 -4. 保留文件中其他字段(如 `[thinking]`、`[loop_control]` 等),保存时原样写回。 - -### 7.2 写入流程 - -1. 若目标文件已存在,先复制一份带时间戳的备份:`config.toml.bak.YYYYMMDD_HHMMSS_<毫秒>`(如 1 秒内多次保存,追加自增序号)。 -2. 使用 `toml_edit` 加载并修改 `[providers]`、`[models]` 与 `default_model`,保留原文件中的注释、空行与字段顺序。 -3. 合并保留的其他字段。 -4. 写回文件。 -5. 写操作使用临时文件 + 原子重命名(`write to tmp → fsync → rename`),降低写损风险。 - -> 第一版即使用 `toml_edit` 而非 `toml`/`serde_toml`,避免用户的既有注释和格式在保存后被清空。 - -### 7.3 Tauri 权限配置 - -Tauri 默认禁止前端访问文件系统,需在 `tauri.conf.json`(或 `capabilities/` 目录)中显式声明: - -- `fs:allow-read-file`:`~/.kimi-code/config.toml`、Profile 文件。 -- `fs:allow-write-file`:`~/.kimi-code/config.toml`、Profile 文件。 -- `fs:allow-read-dir`:`~/.kimi-code/`、`~/.kimi-code/profiles/`。 -- `fs:allow-create-dir`:首次运行时创建 `~/.kimi-code/profiles/`。 -- `core:default`:基础窗口与事件权限。 - -所有路径使用 Tauri 的 `BaseDirectory::Home` 解析,避免硬编码绝对路径。 - -### 7.4 Tauri 命令清单 - -Rust 后端通过 `commands.rs` 暴露以下命令给前端调用: - -| 命令 | 输入 | 输出 | 说明 | -| --- | --- | --- | --- | -| `load_config` | 无 | `Config` | 读取 `~/.kimi-code/config.toml`,若不存在则初始化空配置。 | -| `save_config` | `Config` | `Result<(), String>` | 校验并写入 `config.toml`,失败返回错误信息。 | -| `list_profiles` | 无 | `Vec<ProfileSummary>` | 列出 `profiles/` 下所有 Profile 及其激活状态。 | -| `load_profile` | `filename: String` | `Config` | 读取指定 Profile 文件内容。 | -| `save_profile` | `filename: String, config: Config` | `Result<(), String>` | 保存或覆盖指定 Profile 文件。 | -| `switch_profile` | `filename: String` | `Result<Config, String>` | 将指定 Profile 内容写入 `config.toml`(带备份)并返回新配置。 | -| `rename_profile` | `old_filename, new_name` | `Result<String, String>` | 重命名 Profile,返回新的安全文件名。 | -| `delete_profile` | `filename: String` | `Result<(), String>` | 删除指定 Profile 文件。 | -| `open_config_dir` | 无 | `Result<(), String>` | 调用系统文件管理器打开配置目录。 | -| `get_app_version` | 无 | `String` | 返回当前应用版本号。 | - -### 7.5 凭证存储策略 - -根据 [Kimi Code CLI 配置覆盖文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/overrides.html),凭证解析优先级为: - -1. `[providers.<name>].api_key` / `.base_url` -2. `[providers.<name>.env]` 中对应键 -3. 两者都缺失 → CLI 启动报错 - -KimiSwitch 默认将凭证写入 `[providers.<name>.env]` 子表,使用官方约定键名,同时提供「使用直接字段」开关,允许用户将 `api_key` / `base_url` 写到 `[providers.<name>]` 顶层。UI 中两种方式互斥: - -- **env 子表模式**:`api_key` / `base_url` 输入框的值映射到 `env` 中的约定键名;保存时顶层 `api_key` / `base_url` 字段被移除。 -- **直接字段模式**:`api_key` / `base_url` 写入顶层字段;保存时 `env` 中的对应约定键名被移除,其他自定义 `env` 键保留。 - -避免直接字段与 `env` 同时存在导致优先级迷惑。 - -各供应商的默认 Base URL 与 `env` 键名如下(参考 [Kimi Code CLI 平台与模型](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)): - -| 供应商类型 | 默认 Base URL | API Key 键名 | Base URL 键名 | 特殊说明 | -| --- | --- | --- | --- | --- | -| `kimi` | `https://api.moonshot.ai/v1` | `KIMI_API_KEY` | `KIMI_BASE_URL` | 支持视频上传 | -| `anthropic` | Anthropic SDK 默认 | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` | Claude 模型自动识别 thinking / 视觉 / 工具调用 | -| `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | 兼容 DeepSeek、Qwen 等第三方网关 | -| `openai_responses` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | 使用 OpenAI Responses API | -| `google-genai` | `https://generativelanguage.googleapis.com` | `GOOGLE_API_KEY` | `GOOGLE_GEMINI_BASE_URL` | 只填主机根地址,不要带 `/v1beta` | -| `vertexai` | 区域化 `*-aiplatform.googleapis.com` | 通常不需要(走 ADC) | `GOOGLE_VERTEX_BASE_URL` | 必须提供 `GOOGLE_CLOUD_PROJECT` 与 `GOOGLE_CLOUD_LOCATION`;只填主机根地址 | - -创建供应商时,UI 根据所选 `type` 自动填入默认 Base URL 与推荐 `env` 键名,用户可手动覆盖。 - -### 7.6 Profile 生命周期 - -Profile 是 `~/.kimi-code/config.toml` 的命名快照,存放在 `~/.kimi-code/profiles/` 目录下。 - -#### 7.6.1 初始化 - -- 工具首次启动时,若 `~/.kimi-code/config.toml` 已存在,自动生成一个名为 `default` 的 Profile,其内容即为当前 `config.toml`。 -- 若 `config.toml` 不存在,创建一个空 `default` Profile。 -- `default` Profile 始终直接对应 `~/.kimi-code/config.toml`,不在 `profiles/` 下额外生成文件。 - -**文件名安全化**:Profile 显示名可包含中文、空格、大小写;存储时自动转译为合法 Windows 文件名。转译规则:保留 ASCII 字母数字,其他字符替换为 `-`,连续 `-` 合并,小写化;若结果为空(如纯中文名),则回退为基于名称哈希的短文件名(如 `profile-48291.toml`)。 - -#### 7.6.2 操作 - -| 操作 | 行为 | -| --- | --- | -| 新建 Profile | 用户输入显示名,自动生成安全文件名;可选择「从当前配置复制」或「空白」;保存到 `profiles/<safe-name>.toml`。 | -| 重命名 Profile | 仅重命名非 `default` 的 Profile,修改显示名并重新生成安全文件名;原文件删除,新文件写入。 | -| 复制 Profile | 以 `<原显示名> Copy` 创建副本,自动生成新的安全文件名。 | -| 删除 Profile | 删除 `profiles/<safe-name>.toml`;`default` 不可删除。 | -| 切换 Profile | 将选中的 Profile 内容写入 `~/.kimi-code/config.toml`(先备份当前 `config.toml`),然后重新加载界面。 | -| 保存当前到 Profile | 把当前编辑内容导出为新的 Profile 文件,不影响当前激活状态。 | - -#### 7.6.3 激活状态判定 - -- 工具启动时将 `config.toml` 解析为 `Config`,再与各 Profile 的 `Config` 做深度比较(而非字符串比较),语义一致即标记为 `is_active`。 -- 若没有任何 Profile 与当前 `config.toml` 语义一致,则 `default` Profile 标记为 `is_active`,并在界面上提示「当前 config.toml 与所有 Profile 不完全匹配」。 - -#### 7.6.4 未保存变更处理 - -- 切换 Profile 或退出前若存在未保存的编辑,弹出对话框:「保存当前修改 / 丢弃 / 取消」。 -- 标题栏或状态栏显示「已修改」指示器。 - -## 8. 界面设计 - -前端使用 React + TypeScript + Tailwind CSS 实现,主窗口采用三栏布局,顶部增加「快速切换」工具条。前端通过 Tauri `invoke` 调用 Rust 命令完成所有文件操作。 - -- **窗口约束**:最小宽度 1000px,最小高度 700px;标题栏显示 `[未保存] Profile 名 - KimiSwitch`。 -- **快捷键**:`Ctrl + S` 保存当前配置,`Ctrl + R` 重新读取,`Ctrl + O` 打开配置目录。第一版不单独做主题切换,跟随系统亮/暗模式。 - -### 8.1 顶部工具条 - -从左到右依次为: - -- **Profile 下拉框**:列出所有 Profile(含 `default`),当前激活项高亮显示。 -- **Profile 操作按钮**:「新建」「复制」「重命名」「删除」「切换」。 -- **当前供应商** 下拉框:列出当前 Profile 中已配置供应商。 -- **当前模型** 下拉框:根据所选供应商,只列出引用该供应商的模型别名;若该供应商下没有模型,则提示「未绑定模型,请先在右侧添加」。 -- **设为默认** 按钮:将当前选中的模型写入 `default_model` 字段,并立即保存到当前 Profile 与 `config.toml`。 -- 启动时自动高亮当前 `default_model` 对应的供应商和模型。 - -### 8.2 左栏:供应商列表 - -- 显示所有 provider name 与 type。 -- 支持「添加」与「删除」按钮。 -- 切换左栏选中项时,顶部「当前供应商」下拉框同步联动,右栏模型列表按该供应商过滤。 - -### 8.3 中栏:供应商详情表单 - -- `type` 下拉选择框;切换时自动填充默认 `base_url` 与推荐 `env` 键名。 -- `base_url` 输入框。 -- `api_key` 输入框(默认掩码,可切换明文)。 -- 凭证存储方式单选:直接字段 / env 子表;两种方式互斥,保存时只写入被选中的那一种。 -- `env` 键值对表格(可增删改行)。 - -### 8.4 右栏:模型别名列表与详情 - -- 显示当前选中供应商下的 model alias(也可切换显示全部)。 -- 表单包含 `alias`、`provider` 下拉选择、`model`、`max_context_size`、`display_name`。 -- 提供「设为默认模型」按钮,等同于顶部快速切换区的「设为默认」。 - -### 8.5 状态与错误处理 - -- **未保存指示**:标题栏在配置有未保存变更时显示 `*` 前缀;状态栏显示「已修改」或「已保存」。 -- **切换/退出前检查**:存在未保存变更时,弹出「保存 / 丢弃 / 取消」三选一对话框。 -- **文件不存在**:首次启动或配置目录不存在时,自动创建 `~/.kimi-code/` 目录与空 `config.toml`,不报错。 -- **文件只读/权限不足**:保存失败时给出明确提示,并建议检查目录权限或以管理员身份运行。 -- **校验失败**:保存前校验不通过时,弹窗列出所有错误项,禁止写入。 -- **备份失败**:若备份步骤失败(如磁盘满),禁止覆盖原文件。 -- **多实例冲突**:启动时检测 `~/.kimi-code/.kimiswitch.lock`;若已存在且进程仍在运行,提示用户「KimiSwitch 已在运行,请勿多开」;若进程已消失则清理旧锁。保存时不强制全局锁,但建议单次会话内顺序执行写操作。 - -### 8.6 底部工具栏 - -- **读取配置**:从 `~/.kimi-code/config.toml` 重新加载。 -- **保存配置**:校验并写入当前 Profile 与 `config.toml`,失败时弹出错误提示。 -- **保存为新 Profile**:把当前编辑内容导出为新的 Profile 文件。 -- **打开配置目录**:调用系统文件管理器定位到数据目录。 - -## 9. 校验规则 - -保存前必须校验通过,否则禁止写入: - -1. 每个 Provider 的 `name` 非空、不重复,且为合法 TOML 表键(必要时生成带引号的键)。 -2. 每个 Provider 的 `type` 必须是支持的类型之一。 -3. 每个 Provider 必须至少提供一种凭证来源,但 `vertexai` 例外(走 Google Cloud ADC,无需静态 API Key): - - `api_key` 直接字段非空,或 - - `env` 中包含对应供应商的 API Key 键名。 -4. 凭证存储方式在直接字段与 `env` 子表之间互斥,保存时只写入选中的那一种。 -5. `vertexai` 类型的 Provider 必须在 `env` 中提供 `GOOGLE_CLOUD_PROJECT` 与 `GOOGLE_CLOUD_LOCATION`,不强制要求 API Key。 -6. 每个 Model 的 `alias` 非空、不重复,且为合法 TOML 表键(含 `.` 等字符时自动加引号)。 -7. 每个 Model 的 `provider` 必须引用一个已存在的 Provider。 -8. 每个 Model 的 `model` 非空,`max_context_size` 为正整数。 - -## 10. MSI 打包 - -### 10.1 打包流程 - -1. 安装依赖:`npm install` 与 `cargo fetch` -2. 开发/构建前端:`npm run build` -3. 构建并打包:`npm run tauri build` - - Tauri 自动调用 `cargo build --release` 与 Windows bundler - - 输出:`src-tauri/target/release/bundle/msi/KimiSwitch_<version>_x64_en-US.msi` - -### 10.2 MSI 行为 - -- 默认安装目录:`C:\Program Files\KimiSwitch`( per-machine 安装,需管理员权限)。 -- 创建开始菜单快捷方式:`KimiSwitch`。 -- 安装程序不自动写入 `config.toml`,仅部署工具本身。 -- 支持标准卸载。 -- 可在 `tauri.conf.json` 中配置签名、版本、厂商信息、安装目录等。 -- 第一版仅构建 x64 架构 MSI,不构建 arm64 或 32 位版本。 -- 第一版**不启用 Tauri Updater**,后续版本通过重新安装 MSI 升级,降低第一版复杂度。 - -### 10.3 Windows 专项处理 - -- **路径解析**:使用 Rust `dirs::home_dir()` 获取用户主目录,拼接 `.kimi-code`,避免依赖 `$HOME` 环境变量(Windows 下可能不存在)。 -- **配置文件位置**:默认 `C:\Users\<用户名>\.kimi-code\config.toml`;若用户设置 `KIMI_CODE_HOME`,按该变量解析。 -- **权限**:工具运行时只读写用户目录下的文件,无需以管理员身份运行;仅 MSI 安装本身需要管理员权限。 -- **编码与换行**:TOML 文件统一使用 UTF-8;Windows 下换行保持为 `\r\n` 或 `\n` 均可,`toml_edit` 会保留原文件换行风格。 -- **代码签名(可选)**:如后续分发,建议为 MSI 配置代码签名证书,减少 Windows Defender SmartScreen 提示。 - -## 11. 测试策略 - -- **单元测试**(Rust): - - `config_io.rs`:解析与生成 TOML 的 round-trip 测试。 - - `profile_manager.rs`:Profile 新建、复制、切换、激活状态判定测试。 - - `validators.rs`:校验规则的边界测试。 -- **手动测试**: - - 启动 GUI,增删改供应商与模型。 - - 创建多个 Profile 并相互切换,验证 `config.toml` 内容正确变化。 - - 保存后使用 Kimi Code CLI 读取并验证可用性。 - - 在 Windows 上执行 MSI 安装与卸载。 - -## 12. 假设与约束 - -- **第一版目标平台仅为 Windows x64**,macOS / Linux 跨平台支持不在第一版范围内,待 Windows x64 版本完成后再规划。 -- API Key 按 Kimi Code CLI 官方方式明文存储在 `config.toml` 中,不引入额外加密。 -- 开发者已安装 Rust 工具链、Node.js 与 Tauri CLI(仅开发与打包时需要,最终用户不需要)。 -- 不覆盖 Kimi Code CLI 的自动更新机制或 OAuth 登录流程。 -- 最终用户运行 KimiSwitch 只需标准 Windows 用户权限,安装 MSI 时才需要管理员权限。 - -## 13. 参考文档 - -- [Kimi Code CLI 配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html) -- [Kimi Code CLI 平台与模型](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html) -- [Kimi Code CLI 数据路径](https://www.kimi.com/code/docs/kimi-code-cli/configuration/data-locations.html) -- [Kimi Code CLI 环境变量](https://www.kimi.com/code/docs/kimi-code-cli/configuration/env-vars.html) -- [Kimi Code CLI 配置覆盖](https://www.kimi.com/code/docs/kimi-code-cli/configuration/overrides.html) diff --git a/docs/superpowers/specs/2026-07-08-kimicode-reload-hint-design.md b/docs/superpowers/specs/2026-07-08-kimicode-reload-hint-design.md deleted file mode 100644 index 53e1178..0000000 --- a/docs/superpowers/specs/2026-07-08-kimicode-reload-hint-design.md +++ /dev/null @@ -1,47 +0,0 @@ -# Kimi Code 选项卡 /reload 提示设计 - -## 背景 - -用户在 Kimi Code 选项卡下切换供应商后,Kimi Code CLI 需要执行 `/reload` 才能加载新的提供方配置。当前界面没有给出该提示,用户容易遗漏这一步。 - -## 目标 - -在 Kimi Code 选项卡下,给每个供应商的"切换使用"按钮旁边增加一个信息提示,告知用户切换供应商后需要执行 `/reload`。 - -## 设计 - -### 位置与交互 - -- 在 `ProviderList` 组件中,每个供应商卡片右侧的"切换使用"按钮旁边添加一个信息图标 `ⓘ`。 -- 使用原生 `title` 属性展示 tooltip,鼠标悬停时显示提示文案。 -- 仅在当前选中 `agent === "kimi_code"` 时显示该图标;Pi 选项卡下不显示。 - -### 文案 - -- 中文:`切换供应商后,请在 Kimi Code 中执行 /reload 以生效。` -- 英文:`After switching providers, run /reload in Kimi Code to apply the change.` - -### 样式 - -- 图标大小约 14px,颜色为 `text-gray-500`,hover 时变为 `text-gray-300`。 -- 图标与"切换使用"按钮间距约 8px(`gap-2`)。 -- 不引入额外依赖或自定义 tooltip 组件。 - -### 组件与数据流 - -- `src/components/ProviderList.tsx`: - - `ProviderListProps` 新增 `agent: Agent` 字段。 - - 根据 `agent` 决定是否渲染提示图标。 - - 图标 `title` 使用 `t("switchReloadHint")`。 -- `src/App.tsx`: - - 渲染 `<ProviderList />` 时传入 `agent={agent}`。 -- `src/i18n/zh.ts` 与 `src/i18n/en.ts`: - - 新增键 `switchReloadHint`。 - -## 验收标准 - -- [ ] 切换到 Kimi Code 选项卡时,每个供应商的"切换使用"按钮旁出现 `ⓘ` 图标。 -- [ ] 鼠标悬停在图标上时,显示正确语言版本的 `/reload` 提示。 -- [ ] 切换到 Pi 选项卡时,该图标不显示。 -- [ ] 不引入新的运行时依赖。 -- [ ] TypeScript 编译与项目构建保持通过。 diff --git a/docs/superpowers/specs/2026-07-09-kimicode-global-settings-panel-design.md b/docs/superpowers/specs/2026-07-09-kimicode-global-settings-panel-design.md deleted file mode 100644 index eed26fc..0000000 --- a/docs/superpowers/specs/2026-07-09-kimicode-global-settings-panel-design.md +++ /dev/null @@ -1,253 +0,0 @@ -# Kimi Code 全局配置面板设计 - -## 背景 - -Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Kimi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。 - -## 目标 - -1. 在 Kimi Code 选项卡的供应商编辑页面(`ProviderEdit`)中,"模型映射"标签页下方新增一个"全局配置"面板,用图形化控件快速编辑上述 Kimi Code 全局设置。 -2. 根据模型 ID 自动为新添加/发现的模型设置默认 `max_context_size`,全局默认值为 256000,具体模型按可扩展的键值对表格匹配。 - -模型级的 `max_context_size` 已在模型映射表格中实现,用户仍可手动覆盖自动默认值。 - -## 设计 - -### 位置 - -- 组件:`src/components/ProviderEdit.tsx` -- 仅在 `agent === "kimi_code"` 时显示。 -- 位于"模型映射"标签页的内容区域内,在模型映射表格和"+ 添加模型映射"按钮的下方。 -- 垂直排列,超出区域高度时由页面滚动条处理。 - -### 分组与控件 - -面板按功能分为 5 张卡片: - -#### 卡片 1:思考模式 `[thinking]` - -| 控件 | 字段 | 说明 | -| --- | --- | --- | -| 复选框 | `enabled` | 是否默认开启思考 | -| 分段按钮 | `effort` | 思考强度:`low` / `medium` / `high` / `max` | -| 复选框 | `keep` | 是否保留历史思考内容(`"all"` 或关值) | - -- 思考等级和保留思考内容仅在"启用思考"勾选时可用,禁用时置灰。 -- "保留思考内容"未勾选时,写入 `[thinking].keep = false`(不能省略,否则 CLI 会回退到默认值 `"all"`)。 -- 卡片底部小字提示:"启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。" - -#### 卡片 2:循环控制 `[loop_control]` - -| 控件 | 字段 | 说明 | -| --- | --- | --- | -| 数字输入框 | `max_retries_per_step` | 单步失败后最大重试次数,默认 3 | -| 数字输入框 | `reserved_context_size` | 预留给模型输出的 token 数,单位 token | - -#### 卡片 3:后台任务 `[background]` - -| 控件 | 字段 | 说明 | -| --- | --- | --- | -| 数字输入框 | `max_running_tasks` | 同时运行的最大后台任务数 | -| 复选框 | `keep_alive_on_exit` | 会话关闭时是否保留后台任务 | - -#### 卡片 4:权限规则 `[[permission.rules]]` - -- 可添加/删除的规则列表,默认空列表。 -- 每行包含: - - **处置** 下拉框:`allow` / `deny` / `ask` - - **模式** 输入框:如 `Read`、`Bash(rm -rf*)` - - **删除** 按钮 -- 提供"添加常用规则"快捷入口,可一键插入: - - `allow` + `Read` - - `deny` + `Bash(rm -rf*)` - -#### 卡片 5:生命周期钩子 `[[hooks]]` - -- 可添加/删除的钩子列表,默认空列表。 -- 每行包含: - - **事件** 下拉框:常见事件如 `PreToolUse` / `PostToolUse`,也可手动输入其他事件 - - **匹配器** 输入框:如 `Bash` - - **命令** 输入框:如 `node ~/.kimi-code/hooks/check-bash.mjs` - - **超时** 数字输入框(秒) - - **删除** 按钮 - -### 样式 - -- 卡片容器:`bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4` -- 卡片间距:`gap-4` -- 卡片标题:`text-gray-400 text-sm font-medium mb-3` -- 表单项:`flex items-center gap-4 flex-wrap` -- 数字输入框宽度:`w-32` -- 禁用状态:文字 `text-gray-600`,按钮不可交互 - -### 字段默认值 - -当 `raw_other` 中不存在对应表时,控件按以下默认值展示: - -| 配置块 | 字段 | UI 默认值 | -| --- | --- | --- | -| `[thinking]` | `enabled` | `true` | -| `[thinking]` | `effort` | `"medium"` | -| `[thinking]` | `keep` | `"all"`(勾选) | -| `[loop_control]` | `max_retries_per_step` | `3` | -| `[loop_control]` | `reserved_context_size` | `50000` | -| `[background]` | `max_running_tasks` | 空(不写入) | -| `[background]` | `keep_alive_on_exit` | `false` | -| `[[permission.rules]]` | — | 空列表 | -| `[[hooks]]` | — | 空列表 | - -数字输入框允许清空,表示不写入配置文件,由 Kimi Code CLI 使用其内置默认值。 - -### 数据流 - -- `App.tsx` 向 `ProviderEdit` 传入: - - `agent` - - `rawOther`(即 `config.raw_other`) - - `updateRawOther: (updater: (rawOther: unknown) => unknown) => void` -- `ProviderEdit` 中新增内部子组件 `AgentSettingsPanel`,封装所有全局配置 UI。 -- 新增 TypeScript 类型(`src/types/index.ts`): - - `ThinkingConfig` - - `LoopControlConfig` - - `BackgroundConfig` - - `PermissionRule` - - `Hook` - - `AgentSettings` -- 新增 helper(`src/components/AgentSettingsPanel.tsx` 或 `src/lib/agent-settings.ts`): - - `getAgentSettings(rawOther: unknown): AgentSettings` - - `setAgentSettings(rawOther: unknown, patch: Partial<AgentSettings>): unknown` - - 统一从 `raw_other` 中读取/写入 `[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]`。 -- 后端无需改动:这些顶层表原本就通过 `Config.raw_other` 在导入/导出时保留。 - -### 模型默认上下文长度 - -在新增模型(手动添加、一键设置、从 API 发现模型)时,根据模型 ID 自动推荐 `max_context_size`。 - -- 默认回退值:`256000` -- 匹配方式:按模型 ID 字符串进行不区分大小写的正则前缀/全名匹配(代码中使用 `/pattern/i`),命中第一条规则即返回。 -- 规则表(按匹配优先级排列): - -| 匹配规则(不区分大小写) | `max_context_size` | 说明 | -| --- | --- | --- | -| **国内模型(优先)** | | | -| `^kimi-for-coding$` | `262144` | Kimi Code 官方托管模型 | -| `^kimi-k2\.5` | `256000` | Kimi K2.5 系列 | -| `^kimi-k2` | `256000` | Kimi K2 系列 | -| `^kimi-` | `256000` | 其他 Kimi 模型 | -| `^glm-5\.2` | `1000000` | GLM-5.2 | -| `^glm-5\.1` | `256000` | GLM-5.1 | -| `^glm-5` | `256000` | GLM-5 系列 | -| `^glm-4` | `128000` | GLM-4 / GLM-4-Plus / GLM-4-Flash | -| `^glm-` | `128000` | 其他 GLM 模型 | -| `^MiniMax-M3` | `1000000` | MiniMax-M3 | -| `^MiniMax-Text-01` | `400000` | MiniMax-Text-01 | -| `^MiniMax-` | `256000` | 其他 MiniMax 模型 | -| `^qwen2\.5` | `128000` | 通义千问 Qwen2.5 系列 | -| `^qwen-max` | `128000` | 通义千问 Max | -| `^qwen-plus` | `128000` | 通义千问 Plus | -| `^qwen-turbo` | `128000` | 通义千问 Turbo | -| `^qwen-coder` | `128000` | 通义千问 Coder | -| `^qwen-` | `128000` | 其他通义千问模型 | -| `^deepseek-r1` | `64000` | DeepSeek-R1 | -| `^deepseek-v3` | `64000` | DeepSeek-V3 | -| `^deepseek-coder` | `64000` | DeepSeek-Coder | -| `^deepseek-` | `64000` | 其他 DeepSeek 模型 | -| `^hunyuan-pro` | `32000` | 腾讯 Hunyuan Pro | -| `^hunyuan-standard` | `32000` | 腾讯 Hunyuan Standard | -| `^hunyuan-lite` | `32000` | 腾讯 Hunyuan Lite | -| `^hunyuan-` | `32000` | 其他 Hunyuan 模型 | -| `^doubao-pro` | `128000` | 字节 Doubao Pro | -| `^doubao-lite` | `128000` | 字节 Doubao Lite | -| `^doubao-vision` | `128000` | 字节 Doubao Vision | -| `^doubao-` | `128000` | 其他 Doubao 模型 | -| `^ernie-4\.0` | `128000` | 百度文心 4.0 | -| `^ernie-3\.5` | `128000` | 百度文心 3.5 | -| `^ernie-speed` | `128000` | 百度文心 Speed | -| `^ernie-lite` | `128000` | 百度文心 Lite | -| `^ernie-` | `128000` | 其他文心模型 | -| `^spark-v4` | `32000` | 讯飞星火 V4 | -| `^spark-v3\.5` | `32000` | 讯飞星火 V3.5 | -| `^spark-pro` | `32000` | 讯飞星火 Pro | -| `^spark-max` | `32000` | 讯飞星火 Max | -| `^spark-` | `32000` | 其他星火模型 | -| `^sensechat-` | `128000` | 商汤 SenseChat | -| `^baichuan-4` | `128000` | 百川 Baichuan 4 | -| `^baichuan-3` | `128000` | 百川 Baichuan 3 | -| `^baichuan-` | `128000` | 其他百川模型 | -| `^yi-` | `128000` | 零一万物 Yi 系列 | -| **国际模型** | | | -| `^claude-opus` | `200000` | Claude Opus 系列(多数版本为 200K;Claude 4 部分版本可达 1M,这里取保守值) | -| `^claude-sonnet` | `200000` | Claude Sonnet 系列 | -| `^claude-haiku` | `200000` | Claude Haiku 系列 | -| `^claude-` | `200000` | 其他 Claude 模型 | -| `^gpt-4\.1` | `1047576` | GPT-4.1 系列(1M tokens) | -| `^gpt-4o` | `128000` | GPT-4o 系列 | -| `^gpt-4-turbo` | `128000` | GPT-4 Turbo | -| `^gpt-4-` | `128000` | 其他 GPT-4 模型 | -| `^gemini-2\.0-flash` | `1048576` | Gemini 2.0 Flash(1M tokens) | -| `^gemini-1\.5-pro` | `2097152` | Gemini 1.5 Pro(2M tokens) | -| `^gemini-1\.5-flash` | `1048576` | Gemini 1.5 Flash(1M tokens) | -| `^gemini-` | `1048576` | 其他 Gemini 模型 | -| (默认) | `256000` | 未命中任何规则 | - -> 注:模型上下文长度取公开资料典型值,部分国内/国际模型存在多个版本导致数值差异,实际以供应商文档为准。规则表以代码常量形式存在,按 specificity 从高到低排列,命中第一条即返回,未命中时回退到 256000。用户仍可在模型映射表格中手动覆盖。 - -- 实现位置:`src/lib/model-defaults.ts`(或类似纯函数文件)。 -- 核心函数: - ```ts - export function getDefaultMaxContextSize(modelId: string): number; - ``` -- 调用点: - - `ProviderEdit` 中手动添加模型时。 - - `ProviderEdit` 中通过"获取模型列表"发现模型并添加时。 - - `handleAddProvider` / `handleApplyProviderJson` 等生成默认模型的地方,可一并使用此函数作为更智能的默认值。 - -### i18n 键 - -新增键: - -- `agentSettings`: "全局配置" -- `enableThinking`: "启用思考" -- `thinkingLevel`: "思考等级" -- `thinkingKeep`: "保留思考内容" -- `thinkingLow`: "低" -- `thinkingMedium`: "中" -- `thinkingHigh`: "高" -- `thinkingMax`: "最大" -- `thinkingContextHint`: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。" -- `loopControlSettings`: "循环控制" -- `maxRetriesPerStep`: "单步重试次数" -- `reservedContextSize`: "上下文预留大小" -- `backgroundSettings`: "后台任务" -- `maxRunningTasks`: "最大并发数" -- `keepAliveOnExit`: "退出时保持运行" -- `permissionRules`: "权限规则" -- `permissionDecision`: "处置" -- `permissionPattern`: "模式" -- `permissionAllow`: "允许" -- `permissionDeny`: "拒绝" -- `permissionAsk`: "询问" -- `addRule`: "+ 添加规则" -- `addCommonRules`: "添加常用规则" -- `hooks`: "生命周期钩子" -- `hookEvent`: "事件" -- `hookMatcher`: "匹配器" -- `hookCommand`: "命令" -- `hookTimeout`: "超时" -- `addHook`: "+ 添加钩子" - -## 验收标准 - -- [ ] 在 Kimi Code 选项卡下进入供应商编辑页面,"模型映射"标签页下方出现"全局配置"区域。 -- [ ] Pi 选项卡下不显示该全局配置区域。 -- [ ] "启用思考"复选框控制 `[thinking].enabled`。 -- [ ] 启用思考后,"思考等级"和"保留思考内容"可用;未启用时禁用。 -- [ ] "思考等级"分段按钮写入 `[thinking].effort`。 -- [ ] "保留思考内容"写入 `[thinking].keep = "all"`,未勾选时写入关值。 -- [ ] `[loop_control]` 和 `[background]` 的数字/复选框正确读写。 -- [ ] 权限规则列表可增删改,导出为 `[[permission.rules]]`。 -- [ ] 钩子列表可增删改,导出为 `[[hooks]]`。 -- [ ] 保存配置后,Kimi Code 的 `config.toml` 中对应顶层表内容正确。 -- [ ] 不修改模型映射表格中已有的 `max_context_size` 手动编辑逻辑。 -- [ ] 添加新模型时,`max_context_size` 默认按规则表自动填充,匹配不区分大小写,未命中规则时为 256000。 -- [ ] 用户仍可在模型映射表格中手动覆盖自动填充的 `max_context_size`。 -- [ ] `npm run build` 通过。