Compare commits

...
5 Commits
Author SHA1 Message Date
KimiSwitch Dev 948e96352e feat: v0.2.0 - add system tray, clean up unused role field
- Replace text '+' icon with centered SVG plus icon for the add-provider button
- Add system tray: minimize-to-tray and quit-from-tray menu (minimize hides window, close exits app)
- Remove unused model role field (no functional effect, only stored/displayed)
- Fix multiple providers showing as active in Kimi Code mode: only the default_model's provider is marked active
- Copy /reload to clipboard after provider switch as a convenience hint
- Bump version to 0.2.0
2026-07-25 16:39:20 +08:00
KimiSwitch Dev 0ce89b09d9 docs: add English README and cross-link both language versions
- New README_EN.md with full English documentation mirroring the Chinese README
- Add language switcher links (English | 中文) at the top of both files
2026-07-22 14:50:55 +08:00
KimiSwitch Dev 0bf0f50aaa docs: fix README inaccuracies found in audit
- Validation: validators.rs is a stub, i18n keys reserved but not wired.
  Reworded to 'reserved, not wired' instead of claiming active checks.
- Backup: actually 7-day retention by mtime, timestamp-based naming,
  stored in backups/ subdirectory (not 7 copies with .bak.N alongside).
- Wording: 'one edit, two effects' was wrong; Kimi Code and Pi have
  separate SQLite configs behind the agent tab.
- TOC: removed broken 'screenshot' anchor and stale 'License' anchor.
- Deduped the two trailing license lines.
- Rust version: drop unsupported '>= 1.77' claim; Cargo.toml has no
  rust-version field, so say 'stable latest'.
- Manual test list: removed Vertex validation item (validator not wired).
2026-07-22 13:53:59 +08:00
KimiSwitch Dev dd22d769f4 docs: add MIT LICENSE and update README license reference
- Add LICENSE file under MIT, copyright 2026 CodingPlan.site
- Replace 'All rights reserved' note in README with link to LICENSE
- Switch License badge from 'Private' to 'MIT'
2026-07-22 13:35:02 +08:00
KimiSwitch Dev c32f3f458f docs: rewrite README with full project documentation
Cover what the project does, architecture, supported providers, storage
locations, dev workflow, Tauri commands, shortcuts, i18n, testing, build,
FAQ, and security notes.
2026-07-22 13:32:59 +08:00
31 changed files with 1194 additions and 230 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 CodingPlan.site
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+328 -3
View File
@@ -1,5 +1,330 @@
# KimiSwitch
# Kimi Switch
A Windows x64 desktop config manager for Kimi Code CLI, built with Tauri v2, React, TypeScript, and Tailwind CSS.
> Windows 桌面端 LLM 供应商配置管理器,让你在 **Kimi Code CLI** 和 **Pi** 两个 Agent 之间无缝切换多家 LLM 供应商与模型。
KimiSwitch helps you manage multiple LLM provider configurations and switch between configuration profiles for Kimi Code CLI.
[English](./README_EN.md) | **中文**
[![Tauri](https://img.shields.io/badge/Tauri-v2-blue?logo=tauri)](https://tauri.app)
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
---
## 目录
- [这是什么](#这是什么)
- [核心特性](#核心特性)
- [架构总览](#架构总览)
- [支持的供应商类型](#支持的供应商类型)
- [数据存储位置](#数据存储位置)
- [快速上手](#快速上手)
- [开发指南](#开发指南)
- [键盘快捷键](#键盘快捷键)
- [国际化](#国际化)
- [测试](#测试)
- [打包发布](#打包发布)
- [常见问题](#常见问题)
- [安全提示](#安全提示)
---
## 这是什么
**Kimi Switch** 是一个为 LLM CLI 用户打造的 Windows 桌面配置管理工具。它解决两个痛点:
1. **多供应商管理繁琐**:在 Kimi / Anthropic / OpenAI / Google GenAI / 自建代理 等多家供应商之间切换时,需要反复手改 TOML/JSON,容易出错。
2. **多 Agent 配置割裂**:同时使用 **Kimi Code CLI** 和 **Pi** 两个 Agent 的开发者,每个 Agent 都有一套独立的配置格式,改一处要改两份。
Kimi Switch 提供统一的图形界面:
- 统一图形界面,分别管理 Kimi Code 和 Pi 两套 Agent 配置,顶部 Tab 切换
- 一键切换供应商,自动写入对应 Agent 的原生配置
- 一键拉取模型列表(OpenAI / Anthropic / Google GenAI)
- 切换后给出 `/reload` 提示,确保 Kimi Code 会话立即生效
## 核心特性
| 分类 | 功能 |
| --- | --- |
| **多 Agent** | 同时管理 Kimi Code 和 Pi 两套配置,互不干扰,顶部 Tab 切换 |
| **多供应商** | Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI |
| **模型映射** | 别名 ↔ 实际模型 ID,支持显示名、上下文长度、角色(Sonnet/Opus/Fable/Haiku)、1M 上下文声明 |
| **一键发现** | 根据供应商 API 自动拉取可用模型列表 |
| **托管供应商** | 标记 OAuth/managed 供应商,跳过凭证校验,保留 `oauth` 段写入 Kimi Code 配置 |
| **Env 凭证** | 既支持 `api_key` 字段,也支持 `env` 表里的环境变量名(如 `OPENAI_API_KEY`) |
| **全局设置** | 思考开关/等级、循环重试、后台任务、权限规则、生命周期钩子(仅 Kimi Code) |
| **JSON 直编** | 高级用户可手动编辑完整配置 JSON(保留未知字段,不会丢字段) |
| **i18n** | 简体中文 / English,运行时切换 |
| **自动备份** | 写入 Kimi Code / Pi 原生配置前自动备份,按时间戳命名,保留最近 7 天(见 `src-tauri/src/config_io.rs`) |
| **快捷键** | Ctrl+S 保存、Ctrl+R 重载、Ctrl+O 打开配置目录 |
| **校验(预留)** | i18n 已定义错误文案(名称重复、凭证缺失、Vertex 字段缺失等),后端 `validators.rs` 为占位实现,尚未接线 |
| **未保存提示** | 关闭窗口前检测未保存修改,标题栏加 `*` 前缀 |
## 架构总览
```
┌────────────────────────────────────────────────────────────────────┐
│ Kimi Switch (Tauri v2) │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
│ │ │ │ │ │
│ │ src/App.tsx │◄──►│ src-tauri/src/ │ │
│ │ src/components/ │ Tauri ├── commands.rs │ │
│ │ ProviderList │ invoke ├── db.rs │ │
│ │ ProviderEdit │ ├── kimi_code_io │ │
│ │ AgentSettingsPanel │ ├── pi_io.rs │ │
│ │ src/hooks/useConfig │ ├── config_io.rs │ │
│ │ src/i18n/{zh,en}.ts │ ├── validators.rs │ │
│ │ src/types/index.ts │ └── profile_manager│ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │ │ │
└──────────────────┼──────────────────────────────┼──────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ SQLite │ │ Agent 原生配置文件 │
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
│ kimi-switch.db │ │ │ └─ config.toml │
│ │ │ └─ ~/.pi/agent/ │
│ (Kimi Switch 内部状态) │ │ ├─ models.json │
└──────────────────────┘ │ └─ settings.json │
└──────────────────────────┘
```
**关键设计**:
- Kimi Switch 维护自己的 SQLite 数据库存**完整配置**(所有供应商 + 所有模型,含未激活的)
- 点「切换使用」时只把**当前激活的供应商**写入 Agent 原生配置,其他不写
- 这样 Kimi Code / Pi 始终只看到一个活跃供应商,不会被托管/OAuth 默认值干扰
- `raw_other` 字段透传未知键,保证前后往返不丢字段
## 支持的供应商类型
| 类型 | 标识符 | 默认 base_url | 模型发现 | 凭证 |
| --- | --- | --- | --- | --- |
| Kimi | `kimi` | `https://api.openai.com/v1` | ✅ OpenAI 协议 | `KIMI_API_KEY` 或 `env.KIMI_API_KEY` |
| Anthropic | `anthropic` | — | ✅ `/v1/models` | `ANTHROPIC_API_KEY` 或 `env` |
| OpenAI | `openai` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` 或 `env` |
| OpenAI Responses | `openai_responses` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` 或 `env` |
| Google GenAI | `google-genai` | `https://generativelanguage.googleapis.com` | ✅ `/v1beta/models` | `GOOGLE_API_KEY` 或 `env` |
| Vertex AI | `vertexai` | — | ⚠️ 待实现 | `VERTEXAI_API_KEY` + `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` |
凭证优先级:`api_key` 字段 > `env` 表里的同名键。
## 数据存储位置
| 文件 | 用途 | 备份 |
| --- | --- | --- |
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch 自己的 SQLite 数据库,存全量配置 | — |
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI 的 TOML 配置(**切换时写入**) | 同目录下 `backups/config.toml.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
| `%USERPROFILE%\.pi\agent\models.json` | Pi 的供应商+模型配置(**切换时写入**) | 同目录下 `backups/models.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
| `%USERPROFILE%\.pi\agent\settings.json` | Pi 的默认供应商/模型(**切换时写入**) | 同目录下 `backups/settings.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
| `localStorage[kimi-switch-agent]` | 前端记住上次选中的 Agent(kimi_code / pi) | — |
环境变量覆盖:
- `KIMI_CODE_HOME` 覆盖 Kimi Code 配置目录(默认 `~/.kimi-code`)
- `PI_CODING_AGENT_DIR` 覆盖 Pi Agent 配置目录(默认 `~/.pi/agent`)
## 快速上手
### 前置依赖
| 工具 | 版本 | 说明 |
| --- | --- | --- |
| Node.js | ≥ 18 | 前端构建 |
| Rust | stable 最新版(edition 2021) | Tauri 后端编译 |
| WebView2 Runtime | Windows 10/11 默认已装 | Tauri v2 运行时 |
| Microsoft C++ Build Tools | 最新版 | Rust 编译依赖 |
| WiX Toolset 3.14 | `src-tauri/wix314-binaries/` 目录 | MSI 打包(首次构建自动下载) |
### 安装
```bash
npm install
```
### 开发模式(热重载)
```bash
npm run tauri-dev
```
会同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口,修改前端代码会自动刷新,修改 Rust 代码会重新编译。
### 仅前端开发(无 Tauri 窗口)
```bash
npm run dev
```
适用于纯 UI 调试。
## 开发指南
### 项目结构
```
.
├── src/ # React 前端
│ ├── App.tsx # 主组件,路由 ProviderList/ProviderEdit
│ ├── main.tsx # React 入口 + ErrorBoundary + I18nProvider
│ ├── components/
│ │ ├── ProviderList.tsx # 供应商列表 + 切换按钮
│ │ ├── ProviderEdit.tsx # 编辑供应商 + 模型映射 + JSON 直编
│ │ └── AgentSettingsPanel.tsx# 全局设置(思考/循环/权限/钩子)
│ ├── hooks/
│ │ └── useConfig.ts # 加载/保存配置 hook
│ ├── lib/
│ │ ├── agent-settings.ts # AgentSettings 解析/序列化
│ │ └── model-defaults.ts # 模型默认上下文大小
│ ├── types/index.ts # Provider/Model/Config 类型定义
│ ├── i18n/
│ │ ├── zh.ts # 中文翻译(143 条目)
│ │ ├── en.ts # 英文翻译
│ │ └── index.tsx # useTranslation hook + Provider
│ └── index.css # Tailwind 入口
│
├── src-tauri/ # Rust 后端
│ ├── src/
│ │ ├── lib.rs # Tauri Builder + invoke_handler 注册
│ │ ├── main.rs # 二进制入口
│ │ ├── commands.rs # 7 个 Tauri Command
│ │ ├── db.rs # SQLite 持久化
│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml 读写
│ │ ├── pi_io.rs # ~/.pi/agent/*.json 读写
│ │ ├── config_io.rs # 文件备份工具
│ │ ├── models.rs # Config/Provider/Model 数据结构
│ │ ├── profile_manager.rs # 多 Profile 管理(占位)
│ │ └── validators.rs # 配置校验
│ ├── capabilities/ # Tauri 权限声明
│ ├── icons/ # 应用图标(脚本生成)
│ └── tauri.conf.json # Tauri 配置(窗口/打包/CSP)
│
├── scripts/generate-icons.py # 从 SVG 生成各尺寸图标
├── public/kimi.svg # 应用图标源(蓝紫渐变 π)
└── docs/superpowers/ # 设计规范与实施计划
```
### Tauri Commands(前端 ↔ 后端)
| 命令 | 说明 |
| --- | --- |
| `load_agent_config_command(agent)` | 加载配置:优先 SQLite,为空则从 Agent 原生配置导入 |
| `save_agent_config_command(agent, config)` | 保存全量配置到 SQLite |
| `activate_agent_config_command(agent)` | 把当前激活的供应商写入 Agent 原生配置 |
| `open_agent_config_dir(agent)` | 用系统资源管理器打开 Agent 配置目录 |
| `get_app_version()` | 返回 `Cargo.toml` 版本号 |
| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步) |
| `debug_log(message)` | 把前端日志打到 stderr(开发用) |
### 添加新供应商类型
1. 在 `src-tauri/src/models.rs` 的 `ProviderType` 枚举里加新变体
2. 在 `default_base_url()` 里加默认值
3. 在 `commands.rs::list_provider_models` 的 `match` 里加分发
4. 在 `kimi_code_io.rs::provider_type_for_kimi_type` 和 `pi_io.rs::provider_type_for_pi_api` 里加映射
5. 在 `src/components/ProviderEdit.tsx` 的 API 格式下拉里加选项
6. 在 `src/i18n/{zh,en}.ts` 里加新的 i18n 键
### 添加新的 Tauri Command
1. 在 `src-tauri/src/commands.rs` 里加 `#[tauri::command]`
2. 在 `src-tauri/src/lib.rs` 的 `tauri::generate_handler![...]` 列表里注册
3. 前端用 `import { invoke } from "@tauri-apps/api/core"` 调用
4. 在 `src-tauri/capabilities/default.json` 里加权限(如需访问文件系统)
## 键盘快捷键
| 快捷键 | 作用 |
| --- | --- |
| `Ctrl + S` | 保存当前修改到 SQLite |
| `Ctrl + R` | 重新读取配置(未保存时会提示) |
| `Ctrl + O` | 打开当前 Agent 的配置目录 |
## 国际化
- 翻译源文件:`src/i18n/zh.ts`(源)和 `src/i18n/en.ts`(目标)
- 新增 key 时**先在 `zh.ts` 里加**,`en.ts` 的 `Record<TranslationKey, string>` 类型会自动校验缺失条目(编译时报错)
- 运行时通过右上角下拉切换语言,记忆在组件内 state(未持久化到 SQLite)
## 测试
### Rust 单元测试
```bash
cd src-tauri
cargo test
```
当前覆盖:
- `kimi_code_io::tests` — TOML 导入/导出往返
- `pi_io::tests` — JSON 往返,包括 advanced fields(headers/compat/cost/extra)
### 前端
暂无自动化测试。建议手动验证清单:
- [ ] 切换 Agent 时配置互不污染
- [ ] 删除供应商时关联模型也清掉
- [ ] 切换供应商后默认模型正确回填
- [ ] 重命名供应商后引用它的模型也跟着改
- [ ] JSON 直编后 `raw_other` 不丢字段
## 打包发布
```bash
npm run tauri-build
```
产物位置:
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.1.0_x64_en-US.msi`
Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。
### 首次打包
首次运行会下载:
- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/`
- WebView2 bootstrapper → 打包进 MSI
## 常见问题
**Q: 切换供应商后 Kimi Code 没生效?**
A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~/.kimi-code/config.toml`)。应用会在 UI 上提示。
**Q: 改了配置但忘了保存就关了窗口?**
A: 关闭窗口前会弹原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
**Q: 怎么备份/迁移我的配置?**
A: 备份 `%USERPROFILE%\.kimi-switch\kimi-switch.db` 即可,里面存了全量配置(含未激活的供应商)。
**Q: Vertex AI 为什么不能拉取模型列表?**
A: Vertex 需要 GCP project/location 凭证,当前实现留了 TODO,等 GCP SDK 集成后再补。
**Q: 支持 macOS / Linux 吗?**
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `msi`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
## 安全提示
- API Key 以明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存**
- 不要把 `kimi-switch.db`、`config.toml`、`models.json` 提交到 Git
- 应用 CSP 已收紧(`default-src 'self'`),但 WebView2 仍可能缓存表单内容,注意在公共电脑用完退出
---
## 附录:相关项目
- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli) — 兼容的 Agent 之一
- [Tauri](https://tauri.app) — 桌面应用框架
- 设计文档见 `docs/superpowers/specs/`,实施计划见 `docs/superpowers/plans/`
---
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
+330
View File
@@ -0,0 +1,330 @@
# Kimi Switch
> A Windows desktop LLM provider config manager that lets you switch between multiple LLM providers and models across the **Kimi Code CLI** and **Pi** agents.
**English** | [中文](./README.md)
[![Tauri](https://img.shields.io/badge/Tauri-v2-blue?logo=tauri)](https://tauri.app)
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
---
## Table of Contents
- [What Is This](#what-is-this)
- [Key Features](#key-features)
- [Architecture Overview](#architecture-overview)
- [Supported Provider Types](#supported-provider-types)
- [Data Storage Locations](#data-storage-locations)
- [Getting Started](#getting-started)
- [Development Guide](#development-guide)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Internationalization](#internationalization)
- [Testing](#testing)
- [Build & Release](#build--release)
- [FAQ](#faq)
- [Security Notes](#security-notes)
---
## What Is This
**Kimi Switch** is a Windows desktop configuration tool built for LLM CLI users. It solves two pain points:
1. **Tedious multi-provider management**: switching between Kimi / Anthropic / OpenAI / Google GenAI / self-hosted proxies requires repeatedly hand-editing TOML/JSON, which is error-prone.
2. **Fragmented multi-agent configs**: developers using both **Kimi Code CLI** and **Pi** have to maintain two independent config formats, duplicating every change.
Kimi Switch provides a unified GUI:
- One unified interface to manage two separate agent configs (Kimi Code and Pi), switched via a top tab
- One-click provider switching that writes the active provider into the target agent's native config
- One-click model-list fetching (OpenAI / Anthropic / Google GenAI)
- A `/reload` hint after switching so Kimi Code sessions take effect immediately
## Key Features
| Category | Feature |
| --- | --- |
| **Multi-agent** | Manage Kimi Code and Pi configs independently without interference, via a top tab |
| **Multi-provider** | Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI |
| **Model mapping** | Alias ↔ real model ID, with display name, context size, role (Sonnet/Opus/Fable/Haiku), and 1M-context declaration |
| **One-click discovery** | Auto-fetch available model lists from the provider API |
| **Managed providers** | Flag OAuth/managed providers to skip credential validation and preserve the `oauth` section in Kimi Code config |
| **Env credentials** | Supports both the `api_key` field and env-table keys (e.g. `OPENAI_API_KEY`) |
| **Global settings** | Thinking toggle/level, loop retries, background tasks, permission rules, lifecycle hooks (Kimi Code only) |
| **Raw JSON editing** | Power users can hand-edit the full config JSON (unknown fields are preserved, never dropped) |
| **i18n** | Simplified Chinese / English, switched at runtime |
| **Auto backup** | Before writing the Kimi Code / Pi native configs, back up with timestamp-based names and keep the last 7 days (see `src-tauri/src/config_io.rs`) |
| **Shortcuts** | Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir |
| **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 `*` |
## Architecture Overview
```
┌────────────────────────────────────────────────────────────────────┐
│ Kimi Switch (Tauri v2) │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
│ │ │ │ │ │
│ │ src/App.tsx │◄──►│ src-tauri/src/ │ │
│ │ src/components/ │ Tauri ├── commands.rs │ │
│ │ ProviderList │ invoke ├── db.rs │ │
│ │ ProviderEdit │ ├── kimi_code_io │ │
│ │ AgentSettingsPanel │ ├── pi_io.rs │ │
│ │ src/hooks/useConfig │ ├── config_io.rs │ │
│ │ src/i18n/{zh,en}.ts │ ├── validators.rs │ │
│ │ src/types/index.ts │ └── profile_manager│ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │ │ │
└──────────────────┼──────────────────────────────┼──────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ SQLite │ │ Agent native configs │
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
│ kimi-switch.db │ │ │ └─ config.toml │
│ │ │ └─ ~/.pi/agent/ │
│ (Kimi Switch internal)│ │ ├─ models.json │
└──────────────────────┘ │ └─ settings.json │
└──────────────────────────┘
```
**Key design points**:
- Kimi Switch keeps its own SQLite database for the **full config** (all providers + all models, including inactive ones)
- Clicking "Switch to" writes **only the active provider** into the agent's native config; the others are not written
- This way Kimi Code / Pi always see exactly one active provider and are never confused by managed/OAuth defaults
- The `raw_other` field passes unknown keys through untouched, so round-trips never lose fields
## Supported Provider Types
| Type | Identifier | Default base_url | Model discovery | Credentials |
| --- | --- | --- | --- | --- |
| Kimi | `kimi` | `https://api.openai.com/v1` | ✅ OpenAI protocol | `KIMI_API_KEY` or `env.KIMI_API_KEY` |
| Anthropic | `anthropic` | — | ✅ `/v1/models` | `ANTHROPIC_API_KEY` or `env` |
| OpenAI | `openai` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` or `env` |
| OpenAI Responses | `openai_responses` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` or `env` |
| Google GenAI | `google-genai` | `https://generativelanguage.googleapis.com` | ✅ `/v1beta/models` | `GOOGLE_API_KEY` or `env` |
| Vertex AI | `vertexai` | — | ⚠️ Not yet implemented | `VERTEXAI_API_KEY` + `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` |
Credential precedence: the `api_key` field wins over the same-named key in the `env` table.
## Data Storage Locations
| File | Purpose | Backup |
| --- | --- | --- |
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite database holding the full config | — |
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI's TOML config (**written on switch**) | `backups/config.toml.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
| `%USERPROFILE%\.pi\agent\models.json` | Pi's provider+model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
| `%USERPROFILE%\.pi\agent\settings.json` | Pi's default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
| `localStorage[kimi-switch-agent]` | Frontend remembers the last selected agent (kimi_code / pi) | — |
Environment-variable overrides:
- `KIMI_CODE_HOME` overrides the Kimi Code config dir (default `~/.kimi-code`)
- `PI_CODING_AGENT_DIR` overrides the Pi agent config dir (default `~/.pi/agent`)
## Getting Started
### Prerequisites
| Tool | Version | Purpose |
| --- | --- | --- |
| Node.js | ≥ 18 | Frontend build |
| Rust | Latest stable (edition 2021) | Tauri backend compilation |
| WebView2 Runtime | Bundled with Windows 10/11 | Tauri v2 runtime |
| Microsoft C++ Build Tools | Latest | Rust compile dependency |
| WiX Toolset 3.14 | `src-tauri/wix314-binaries/` | MSI bundling (auto-downloaded on first build) |
### Install
```bash
npm install
```
### Dev mode (hot reload)
```bash
npm run tauri-dev
```
Starts both the Vite dev server (port 1420) and the Tauri window. Frontend edits hot-reload; Rust edits trigger a recompile.
### Frontend-only dev (no Tauri window)
```bash
npm run dev
```
Good for pure UI debugging.
## Development Guide
### Project structure
```
.
├── src/ # React frontend
│ ├── App.tsx # Root component routing ProviderList/ProviderEdit
│ ├── main.tsx # React entry + ErrorBoundary + I18nProvider
│ ├── components/
│ │ ├── ProviderList.tsx # Provider list + switch button
│ │ ├── ProviderEdit.tsx # Edit provider + model mapping + raw JSON
│ │ └── AgentSettingsPanel.tsx# Global settings (thinking/loop/permissions/hooks)
│ ├── hooks/
│ │ └── useConfig.ts # Config load/save hook
│ ├── lib/
│ │ ├── agent-settings.ts # AgentSettings parse/serialize
│ │ └── model-defaults.ts # Default model context sizes
│ ├── types/index.ts # Provider/Model/Config type definitions
│ ├── i18n/
│ │ ├── zh.ts # Chinese translations (143 keys)
│ │ ├── en.ts # English translations
│ │ └── index.tsx # useTranslation hook + Provider
│ └── index.css # Tailwind entry
│
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── lib.rs # Tauri Builder + invoke_handler registration
│ │ ├── main.rs # Binary entry
│ │ ├── commands.rs # 7 Tauri commands
│ │ ├── db.rs # SQLite persistence
│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml read/write
│ │ ├── pi_io.rs # ~/.pi/agent/*.json read/write
│ │ ├── config_io.rs # File backup utilities
│ │ ├── models.rs # Config/Provider/Model data structures
│ │ ├── profile_manager.rs # Multi-profile management (stub)
│ │ └── validators.rs # Config validation
│ ├── capabilities/ # Tauri permission declarations
│ ├── icons/ # App icons (script-generated)
│ └── tauri.conf.json # Tauri config (window/bundle/CSP)
│
├── 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
```
### Tauri commands (frontend ↔ backend)
| Command | Description |
| --- | --- |
| `load_agent_config_command(agent)` | Load config: SQLite first, falling back to the agent's native config on first use |
| `save_agent_config_command(agent, config)` | Save the full config to SQLite |
| `activate_agent_config_command(agent)` | Write the active provider into the agent's native config |
| `open_agent_config_dir(agent)` | Open the agent's config dir in the system file manager |
| `get_app_version()` | Return the `Cargo.toml` version |
| `list_provider_models(provider)` | Fetch model list from the provider API (async) |
| `debug_log(message)` | Forward frontend logs to stderr (dev use) |
### Adding a new provider type
1. Add a new variant to the `ProviderType` enum in `src-tauri/src/models.rs`
2. Add a default in `default_base_url()`
3. Add a dispatch arm in `commands.rs::list_provider_models`
4. Add mappings in `kimi_code_io.rs::provider_type_for_kimi_type` and `pi_io.rs::provider_type_for_pi_api`
5. Add an option to the API-format dropdown in `src/components/ProviderEdit.tsx`
6. Add new i18n keys in `src/i18n/{zh,en}.ts`
### Adding a new Tauri command
1. Add a `#[tauri::command]` in `src-tauri/src/commands.rs`
2. Register it in the `tauri::generate_handler![...]` list in `src-tauri/src/lib.rs`
3. Call it from the frontend with `import { invoke } from "@tauri-apps/api/core"`
4. Add a permission in `src-tauri/capabilities/default.json` (if filesystem access is needed)
## Keyboard Shortcuts
| Shortcut | Action |
| --- | --- |
| `Ctrl + S` | Save current changes to SQLite |
| `Ctrl + R` | Reload config (prompts if unsaved) |
| `Ctrl + O` | Open the current agent's config dir |
## Internationalization
- Translation sources: `src/i18n/zh.ts` (source) and `src/i18n/en.ts` (target)
- When adding a key, **add it to `zh.ts` first** — the `Record<TranslationKey, string>` type in `en.ts` will flag any missing entry at compile time
- Language is switched at runtime via the top-right dropdown and held in component state (not persisted to SQLite)
## Testing
### Rust unit tests
```bash
cd src-tauri
cargo test
```
Currently covered:
- `kimi_code_io::tests` — TOML import/export round-trips
- `pi_io::tests` — JSON round-trips, including advanced fields (headers/compat/cost/extra)
### Frontend
No automated tests yet. Suggested manual checklist:
- [ ] Switching agents doesn't cross-contaminate configs
- [ ] Deleting a provider also deletes its models
- [ ] The default model restores correctly after switching providers
- [ ] Renaming a provider updates models that reference it
- [ ] Raw-JSON editing preserves `raw_other` fields
## Build & Release
```bash
npm run tauri-build
```
Output:
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.1.0_x64_en-US.msi`
A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced.
### First build
The first run downloads:
- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/`
- WebView2 bootstrapper → embedded into the MSI
## FAQ
**Q: Kimi Code didn't pick up the change after switching providers?**
A: Run `/reload` inside the Kimi Code session (the CLI only re-reads `~/.kimi-code/config.toml` on reload). The app shows this hint in the UI.
**Q: I edited the config but closed the window without saving?**
A: A native `beforeunload` prompt appears before closing, and the title bar shows a `*` prefix.
**Q: How do I back up / migrate my config?**
A: Back up `%USERPROFILE%\.kimi-switch\kimi-switch.db`; it holds the full config (including inactive providers).
**Q: Why can't Vertex AI fetch the model list?**
A: Vertex requires GCP project/location credentials. The current implementation leaves a TODO pending GCP SDK integration.
**Q: macOS / Linux support?**
A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targets `msi` for bundling. In theory, changing `bundle.targets` to `["app", "dmg"]` etc. would enable cross-platform builds, but this is unverified.
## Security Notes
- API keys are stored in plaintext in the local SQLite database and agent native configs — **do not store them on shared computers**
- Do not commit `kimi-switch.db`, `config.toml`, or `models.json` to Git
- The app CSP is tightened (`default-src 'self'`), but WebView2 may still cache form content — log out when finished on public machines
---
## Appendix: Related Projects
- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli) — one of the compatible agents
- [Tauri](https://tauri.app) — the desktop app framework
- Design specs in `docs/superpowers/specs/`, implementation plans in `docs/superpowers/plans/`
---
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
@@ -229,16 +229,16 @@ Search `src/components/` and `src/App.tsx` for any literal Chinese or English UI
**Files:**
- Modify: `index.html`
- Create/overwrite: `public/pi.svg`
- 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 Pi Switch icons**
- [ ] **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 `/pi.svg`.
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": "Pi Switch"`. Ensure `index.html` `<title>` is also `Pi Switch`.
`tauri.conf.json` already has `"title": "Kimi Switch"`. Ensure `index.html` `<title>` is also `Kimi Switch`.
---
@@ -2,7 +2,7 @@
## 背景
Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Pi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。
Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Kimi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。
## 目标
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/pi.svg" />
<link rel="icon" type="image/svg+xml" href="/kimi.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi Switch</title>
<title>Kimi Switch</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "piswitch",
"name": "kimiswitch",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "piswitch",
"name": "kimiswitch",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "piswitch",
"name": "kimiswitch",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="22" fill="url(#g)"/>
<text x="48" y="70" text-anchor="middle" font-size="52" font-weight="700"
fill="white" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">K</text>
<circle cx="74" cy="26" r="9" fill="white" opacity="0.35"/>
<circle cx="74" cy="26" r="6.5" fill="white" opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
</svg>

Before

Width:  |  Height:  |  Size: 444 B

+76 -48
View File
@@ -3,78 +3,106 @@ import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(ROOT, "src-tauri", "icons")
SVG_PATH = os.path.join(ROOT, "public", "pi.svg")
SVG_PATH = os.path.join(ROOT, "public", "kimi.svg")
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(os.path.dirname(SVG_PATH), exist_ok=True)
# Brand colors: blue -> purple diagonal gradient
C_TOP = (59, 130, 246) # #3b82f6
C_BOT = (139, 92, 246) # #8b5cf6
DOT = (255, 255, 255, 230)
def lerp(a, b, t):
return int(a + (b - a) * t)
def rounded_mask(size, radius):
m = Image.new("L", (size, size), 0)
d = ImageDraw.Draw(m)
d.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
return m
def draw_icon(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
# Render at 4x then downscale for smooth edges
S = size * 4
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
# Diagonal gradient background
grad = Image.new("RGBA", (S, S))
gd = ImageDraw.Draw(grad)
for y in range(S):
for x in range(0, S, 4): # step to speed up; visually identical after downscale
t = (x + y) / (2 * S)
gd.line([(x, y), (min(x + 3, S - 1), y)],
fill=(lerp(C_TOP[0], C_BOT[0], t),
lerp(C_TOP[1], C_BOT[1], t),
lerp(C_TOP[2], C_BOT[2], t), 255))
img.paste(grad, (0, 0), rounded_mask(S, int(S * 0.22)))
draw = ImageDraw.Draw(img)
# Gradient-ish circular background: blue -> purple
for y in range(size):
ratio = y / size
r = int(59 + (139 - 59) * ratio)
g = int(130 + (92 - 130) * ratio)
b = int(246 + (246 - 246) * ratio)
draw.line([(0, y), (size, y)], fill=(r, g, b, 255))
# Clip to circle
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img.putalpha(mask)
# Draw π symbol
font_size = int(size * 0.55)
try:
font = ImageFont.truetype("segoeui.ttf", font_size)
except Exception:
# Bold "K"
font_size = int(S * 0.52)
font = None
for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"):
try:
font = ImageFont.truetype("arial.ttf", font_size)
font = ImageFont.truetype(name, font_size)
break
except Exception:
font = ImageFont.load_default()
continue
if font is None:
font = ImageFont.load_default()
draw = ImageDraw.Draw(img)
text = "π"
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x = (size - text_w) // 2 - bbox[0]
y = (size - text_h) // 2 - bbox[1]
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
return img
bbox = draw.textbbox((0, 0), "K", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Optical center: shift slightly left/down to balance the toggle dot
x = (S - tw) // 2 - bbox[0] - int(S * 0.02)
y = (S - th) // 2 - bbox[1] + int(S * 0.02)
draw.text((x, y), "K", font=font, fill=(255, 255, 255, 255))
sizes = [32, 128, 256]
for s in sizes:
# Toggle dot (top-right) — the "switch" cue
cx, cy = int(S * 0.74), int(S * 0.26)
r = int(S * 0.075)
ring = int(S * 0.018)
draw.ellipse((cx - r - ring, cy - r - ring, cx + r + ring, cy + r + ring),
fill=(255, 255, 255, 90))
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=DOT)
return img.resize((size, size), Image.LANCZOS)
# --- Tauri bundle icons ---
for s in (32, 128, 256):
img = draw_icon(s)
if s == 128:
img.save(os.path.join(OUT_DIR, "128x128.png"))
img2x = draw_icon(256)
img2x.save(os.path.join(OUT_DIR, "128x128@2x.png"))
elif s == 32:
if s == 32:
img.save(os.path.join(OUT_DIR, "32x32.png"))
elif s == 128:
img.save(os.path.join(OUT_DIR, "128x128.png"))
draw_icon(256).save(os.path.join(OUT_DIR, "128x128@2x.png"))
else:
img.save(os.path.join(OUT_DIR, "icon.png"))
# Multi-size ICO
ico_sizes = [(32, 32), (64, 64), (128, 128), (256, 256)]
imgs = [draw_icon(s[0]) for s in ico_sizes]
imgs[0].save(
os.path.join(OUT_DIR, "icon.ico"),
format="ICO",
sizes=ico_sizes,
)
imgs[0].save(os.path.join(OUT_DIR, "icon.ico"), format="ICO", sizes=ico_sizes)
# Simple SVG favicon for the web
# --- Web favicon / app SVG ---
svg = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
<rect width="100" height="100" rx="22" fill="url(#g)"/>
<text x="48" y="70" text-anchor="middle" font-size="52" font-weight="700"
fill="white" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">K</text>
<circle cx="74" cy="26" r="9" fill="white" opacity="0.35"/>
<circle cx="74" cy="26" r="6.5" fill="white" opacity="0.92"/>
</svg>'''
with open(SVG_PATH, "w", encoding="utf-8") as f:
f.write(svg)
+3 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "kimiswitch"
version = "0.1.0"
description = "Pi Switch - model config manager"
version = "0.2.0"
description = "Kimi Switch - model config manager"
authors = ["you"]
edition = "2021"
@@ -9,7 +9,7 @@ edition = "2021"
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
tauri = { version = "2.0.0", features = [] }
tauri = { version = "2.0.0", features = ["tray-icon"] }
tauri-plugin-opener = "2.0.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+3 -3
View File
@@ -18,7 +18,7 @@ pub fn debug_log(message: String) {
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
// Load from Pi Switch's own SQLite database first.
// Load from Kimi Switch's own SQLite database first.
match db::load_config(&agent) {
Ok(config) if !config.providers.is_empty() => Ok(config),
_ => {
@@ -51,7 +51,7 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
#[tauri::command]
pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
// Save the full Pi Switch configuration to local SQLite.
// Save the full Kimi Switch configuration to local SQLite.
db::save_config(&agent, &config).map_err(fmt_anyhow)
}
@@ -88,7 +88,7 @@ fn active_provider_and_model(config: &Config) -> Option<(String, String)> {
fn build_active_config(config: &Config) -> Config {
// Only the provider explicitly marked as active is written to the agent's
// native config. This ensures Kimi Code / Pi follow Pi Switch's choice
// native config. This ensures Kimi Code / Pi follow Kimi Switch's choice
// instead of falling back to a managed/native provider.
let providers: IndexMap<String, Provider> = config
.providers
+37 -14
View File
@@ -1,6 +1,6 @@
//! Pi Switch local SQLite storage.
//! Kimi Switch local SQLite storage.
//!
//! This module stores the full Pi Switch configuration (all providers and models
//! This module stores the full Kimi Switch configuration (all providers and models
//! for both Kimi Code and Pi agents) in a local SQLite database. It is separate
//! from the agent-specific config files that are written when the user activates
//! a provider.
@@ -16,17 +16,43 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type DbResult<T> = anyhow::Result<T>;
pub fn pi_switch_data_dir() -> PathBuf {
pub fn kimi_switch_data_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".pi-switch"))
.map(|h| h.join(".kimi-switch"))
.expect("failed to resolve home directory")
}
pub fn db_path() -> PathBuf {
pi_switch_data_dir().join("pi-switch.db")
kimi_switch_data_dir().join("kimi-switch.db")
}
/// One-time migration: if the legacy `~/.pi-switch/` data directory exists and
/// `~/.kimi-switch/` does not, move it so existing users keep their saved
/// configuration. Safe to call on every startup — it is a no-op once the new
/// directory exists.
fn migrate_legacy_data_dir() {
let new_dir = kimi_switch_data_dir();
if new_dir.exists() {
return;
}
let old_dir = match dirs::home_dir() {
Some(h) => h.join(".pi-switch"),
None => return,
};
if !old_dir.exists() {
return;
}
// Best-effort move; failures are silently ignored so the app can still start.
if let Some(new_parent) = new_dir.parent() {
if let Err(_) = std::fs::create_dir_all(new_parent) {
return;
}
}
let _ = std::fs::rename(&old_dir, &new_dir);
}
pub fn init_db() -> DbResult<Connection> {
migrate_legacy_data_dir();
let path = db_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
@@ -64,7 +90,6 @@ pub fn init_db() -> DbResult<Connection> {
model TEXT NOT NULL,
max_context_size INTEGER NOT NULL,
display_name TEXT,
role TEXT,
supports_1m INTEGER NOT NULL DEFAULT 0,
capabilities TEXT,
raw_other TEXT,
@@ -128,20 +153,19 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut models = IndexMap::new();
{
let mut stmt = tx.prepare(
"SELECT alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other
"SELECT alias, provider_name, model, max_context_size, display_name, supports_1m, capabilities, raw_other
FROM models WHERE agent = ?1 ORDER BY id",
)?;
let model_rows = stmt.query_map(params![agent.as_str()], |row| {
let caps_json: Option<String> = row.get(7)?;
let raw_json: Option<String> = row.get(8)?;
let caps_json: Option<String> = row.get(6)?;
let raw_json: Option<String> = row.get(7)?;
Ok(Model {
alias: row.get(0)?,
provider: row.get(1)?,
model: row.get(2)?,
max_context_size: row.get::<_, i64>(3)? as u64,
display_name: row.get(4)?,
role: row.get(5)?,
supports_1m: row.get::<_, i32>(6)? != 0,
supports_1m: row.get::<_, i32>(5)? != 0,
capabilities: caps_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(),
@@ -202,8 +226,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
{
let mut insert_model = tx.prepare(
"INSERT INTO models
(agent, alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
(agent, alias, provider_name, model, max_context_size, display_name, supports_1m, capabilities, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for model in config.models.values() {
@@ -214,7 +238,6 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
model.model,
model.max_context_size as i64,
model.display_name,
model.role,
model.supports_1m as i32,
serde_json::to_string(&model.capabilities).ok(),
serde_json::to_string(&model.raw_other).ok(),
+132 -72
View File
@@ -2,7 +2,7 @@
//!
//! Kimi Code stores its configuration in `~/.kimi-code/config.toml`.
//! This module reads/writes that file and converts between Kimi's TOML
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
//! format and Kimi Switch's internal `Config`/`Provider`/`Model` types.
use std::path::PathBuf;
@@ -64,7 +64,7 @@ pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
Ok(())
}
/// Import a Kimi Code TOML config into Pi Switch's internal `Config`.
/// Import a Kimi Code TOML config into Kimi Switch's internal `Config`.
pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let root = value.as_table().cloned().unwrap_or_default();
@@ -76,6 +76,72 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let mut providers = IndexMap::new();
let mut models = IndexMap::new();
// Read models first so we can determine which provider the default_model
// belongs to. Only that provider should be marked active in the UI.
if let Some(models_table) = root.get("models").and_then(|v| v.as_table()) {
for (alias, mv) in models_table {
let table = mv.as_table().cloned().unwrap_or_default();
let provider = table
.get("provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let model_id = table
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let max_context_size = table
.get("max_context_size")
.and_then(|v| v.as_integer())
.map(|n| n as u64)
.unwrap_or(128_000);
let display_name = table
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let capabilities: Vec<String> = table
.get("capabilities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let supports_1m = max_context_size >= 1_000_000;
let raw_other = {
let mut rest = table.clone();
rest.remove("provider");
rest.remove("model");
rest.remove("max_context_size");
rest.remove("display_name");
rest.remove("capabilities");
toml_value_to_json(&TomlValue::Table(rest))
};
models.insert(
alias.clone(),
Model {
alias: alias.clone(),
provider,
model: model_id,
max_context_size,
display_name,
supports_1m,
capabilities,
raw_other,
},
);
}
}
let active_provider_name = default_model
.as_ref()
.and_then(|alias| models.get(alias))
.map(|m| m.provider.as_str());
if let Some(providers_table) = root.get("providers").and_then(|v| v.as_table()) {
for (name, pv) in providers_table {
let table = pv.as_table().cloned().unwrap_or_default();
@@ -125,68 +191,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
official_url: None,
managed,
enabled,
active: true,
raw_other,
},
);
}
}
if let Some(models_table) = root.get("models").and_then(|v| v.as_table()) {
for (alias, mv) in models_table {
let table = mv.as_table().cloned().unwrap_or_default();
let provider = table
.get("provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let model_id = table
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let max_context_size = table
.get("max_context_size")
.and_then(|v| v.as_integer())
.map(|n| n as u64)
.unwrap_or(128_000);
let display_name = table
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let capabilities: Vec<String> = table
.get("capabilities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let supports_1m = capabilities.iter().any(|c| c == "thinking")
|| max_context_size >= 1_000_000;
let raw_other = {
let mut rest = table.clone();
rest.remove("provider");
rest.remove("model");
rest.remove("max_context_size");
rest.remove("display_name");
rest.remove("capabilities");
toml_value_to_json(&TomlValue::Table(rest))
};
models.insert(
alias.clone(),
Model {
alias: alias.clone(),
provider,
model: model_id,
max_context_size,
display_name,
role: None,
supports_1m,
capabilities,
active: active_provider_name == Some(name.as_str()),
raw_other,
},
);
@@ -209,7 +214,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
}
}
/// Export Pi Switch's internal `Config` to a Kimi Code TOML config value.
/// Export Kimi Switch's internal `Config` to a Kimi Code TOML config value.
///
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
/// preserved; otherwise a fresh TOML table is used.
@@ -230,7 +235,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
let mut providers_table = Table::new();
for (name, provider) in &config.providers {
// Only write the active provider to Kimi Code config so the agent
// follows Pi Switch's selection.
// follows Kimi Switch's selection.
if !provider.active {
continue;
}
@@ -299,10 +304,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let Some(display_name) = model.display_name.clone().filter(|s| !s.is_empty()) {
mt.insert("display_name".to_string(), TomlValue::String(display_name));
}
let mut capabilities = model.capabilities.clone();
if model.supports_1m && !capabilities.iter().any(|c| c == "thinking") {
capabilities.push("thinking".to_string());
}
let capabilities = model.capabilities.clone();
if !capabilities.is_empty() {
mt.insert(
"capabilities".to_string(),
@@ -461,7 +463,8 @@ api_key = ""
assert_eq!(model.model, "kimi-for-coding");
assert_eq!(model.max_context_size, 262144);
assert!(model.capabilities.contains(&"thinking".to_string()));
assert!(model.supports_1m);
// supports_1m reflects context size only, not thinking capability.
assert!(!model.supports_1m);
// Unknown top-level sections are preserved.
let services = config.raw_other.get("services");
@@ -496,7 +499,6 @@ api_key = ""
model: "glm-5.2".to_string(),
max_context_size: 900_000,
display_name: None,
role: None,
supports_1m: true,
capabilities: vec!["thinking".to_string()],
raw_other: Value::Null,
@@ -527,4 +529,62 @@ api_key = ""
let caps = model.get("capabilities").unwrap().as_array().unwrap();
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_does_not_inject_thinking_from_supports_1m() {
let mut providers = IndexMap::new();
providers.insert(
"test-provider".to_string(),
Provider {
name: "test-provider".to_string(),
provider_type: ProviderType::Anthropic,
base_url: Some("https://example.com".to_string()),
api_key: Some("sk-test".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
raw_other: Value::Null,
},
);
let mut models = IndexMap::new();
models.insert(
"big-model".to_string(),
Model {
alias: "big-model".to_string(),
provider: "test-provider".to_string(),
model: "big-model".to_string(),
max_context_size: 2_000_000,
display_name: None,
supports_1m: true,
capabilities: vec![],
raw_other: Value::Null,
},
);
let config = Config {
default_model: Some("big-model".to_string()),
providers,
models,
raw_other: Value::Null,
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let models_table = root.get("models").unwrap().as_table().unwrap();
let model = models_table.get("big-model").unwrap().as_table().unwrap();
// supports_1m=true but capabilities empty → must NOT inject "thinking".
let caps = model.get("capabilities");
match caps {
None => {}
Some(v) => {
let arr = v.as_array().unwrap();
assert!(
!arr.iter().any(|c| c.as_str() == Some("thinking")),
"thinking should not be injected from supports_1m"
);
}
}
}
}
+73
View File
@@ -5,9 +5,82 @@ pub mod kimi_code_io;
pub mod models;
pub mod pi_io;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder};
use tauri::Manager;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
println!("[Tauri] Setup started");
let window = app.get_webview_window("main").unwrap();
println!("[Tauri] Window label: {}", window.label());
// Tray menu: show / separator / quit
let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[&show_i, &PredefinedMenuItem::separator(app)?, &quit_i],
)?;
// Tray icon: reuse the window icon
let icon = app.default_window_icon().unwrap().clone();
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button,
button_state,
..
} = event
{
if button == MouseButton::Left && button_state == MouseButtonState::Up {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(true) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
}
})
.build(app)?;
// Minimize to tray: restore then hide so the taskbar button disappears
let window_clone = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Resized(_) => {
if window_clone.is_minimized().unwrap_or(false) {
let _ = window_clone.unminimize();
let _ = window_clone.hide();
}
}
_ => {}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::load_agent_config_command,
commands::save_agent_config_command,
-4
View File
@@ -129,8 +129,6 @@ pub struct Model {
pub model: String,
pub max_context_size: u64,
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_1m: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -147,7 +145,6 @@ impl std::fmt::Debug for Model {
.field("model", &self.model)
.field("max_context_size", &self.max_context_size)
.field("display_name", &self.display_name)
.field("role", &self.role)
.field("supports_1m", &self.supports_1m)
.field("capabilities", &self.capabilities)
.field("raw_other", &"<json>")
@@ -162,7 +159,6 @@ impl PartialEq for Model {
&& self.model == other.model
&& self.max_context_size == other.max_context_size
&& self.display_name == other.display_name
&& self.role == other.role
&& self.supports_1m == other.supports_1m
&& self.capabilities == other.capabilities
&& self.raw_other == other.raw_other
+8 -9
View File
@@ -2,7 +2,7 @@
//!
//! Pi stores custom providers and models in `~/.pi/agent/models.json`.
//! This module reads and writes that file and converts between Pi's JSON
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
//! format and Kimi Switch's internal `Config`/`Provider`/`Model` types.
use std::path::PathBuf;
@@ -150,7 +150,7 @@ pub fn pi_settings_path() -> PathBuf {
pi_agent_dir().join("settings.json")
}
/// Pi's `settings.json` file (only the fields Pi Switch manipulates).
/// Pi's `settings.json` file (only the fields Kimi Switch manipulates).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PiSettingsFile {
#[serde(rename = "defaultProvider", default, skip_serializing_if = "Option::is_none")]
@@ -197,7 +197,7 @@ pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> {
Ok(())
}
/// Convert a Pi Switch provider type to the Pi `api` field value.
/// Convert a Kimi Switch provider type to the Pi `api` field value.
pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
match provider_type {
ProviderType::Openai => "openai-completions",
@@ -210,7 +210,7 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
}
}
/// Convert a Pi `api` field value back to a Pi Switch provider type.
/// Convert a Pi `api` field value back to a Kimi Switch provider type.
pub fn provider_type_for_pi_api(api: &str) -> ProviderType {
match api {
"openai-responses" => ProviderType::OpenaiResponses,
@@ -222,7 +222,7 @@ pub fn provider_type_for_pi_api(api: &str) -> ProviderType {
}
/// Merge provider-level fields that have dedicated `PiProvider` fields into the
/// `raw_other` blob so they survive the round-trip through Pi Switch's internal
/// `raw_other` blob so they survive the round-trip through Kimi Switch's internal
/// `Config`.
fn merge_provider_known_into_raw(pi_provider: &PiProvider, raw: &mut Value) {
let has_known = pi_provider.headers.is_some()
@@ -249,7 +249,7 @@ fn merge_provider_known_into_raw(pi_provider: &PiProvider, raw: &mut Value) {
}
/// Merge model-level fields that have dedicated `PiModel` fields into the
/// `raw_other` blob so they survive the round-trip through Pi Switch's internal
/// `raw_other` blob so they survive the round-trip through Kimi Switch's internal
/// `Config`.
fn merge_model_known_into_raw(pi_model: &PiModel, raw: &mut Value) {
let has_known = pi_model.cost.is_some()
@@ -310,7 +310,7 @@ fn extract_model_fields(raw: &Value) -> (Option<PiCost>, Option<Value>, Option<u
(cost, compat, max_tokens, extra)
}
/// Import a Pi `models.json` into a Pi Switch `Config`.
/// Import a Pi `models.json` into a Kimi Switch `Config`.
pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
let mut providers = IndexMap::new();
let mut models = IndexMap::new();
@@ -364,7 +364,6 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
model: pi_model.id.clone(),
max_context_size: pi_model.context_window,
display_name,
role: None,
supports_1m: pi_model.reasoning || pi_model.context_window >= 1_000_000,
capabilities: vec![],
raw_other: model_raw,
@@ -383,7 +382,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
}
}
/// Export a Pi Switch `Config` to a Pi `models.json`.
/// Export a Kimi Switch `Config` to a Pi `models.json`.
pub fn config_to_pi_file(config: &Config) -> PiModelsFile {
let mut providers = IndexMap::new();
+4 -4
View File
@@ -1,7 +1,7 @@
{
"productName": "Pi Switch",
"version": "0.1.0",
"identifier": "com.piswitch.app",
"productName": "Kimi Switch",
"version": "0.2.0",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
@@ -11,7 +11,7 @@
"app": {
"windows": [
{
"title": "Pi Switch",
"title": "Kimi Switch",
"width": 1200,
"height": 800,
"minWidth": 1000,
+21 -10
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
import type { Agent, Model, Provider } from "./types";
const AGENT_STORAGE_KEY = "pi-switch-agent";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
function getProviderDefaultModel(provider: Provider): string | null {
const raw = provider.raw_other as Record<string, unknown> | undefined;
@@ -65,6 +65,7 @@ export default function App() {
const [view, setView] = useState<"list" | "edit">("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const setAgent = (next: Agent) => {
setAgentState(next);
@@ -243,7 +244,7 @@ export default function App() {
// Only the selected provider is active. All other providers (including
// Kimi native/managed and custom) are deactivated so the target agent
// follows Pi Switch's choice exactly.
// follows Kimi Switch's choice exactly.
const providers: Record<string, Provider> = {};
for (const [key, p] of Object.entries(cfg.providers)) {
providers[key] = { ...p, active: key === name };
@@ -271,9 +272,19 @@ export default function App() {
return { ...cfg, providers, default_model };
});
// Persist the full config to Pi Switch's SQLite and activate the selected provider.
// Persist the full config to Kimi Switch's SQLite and activate the selected provider.
await save();
await invoke("activate_agent_config_command", { agent });
// /reload is an interactive Kimi Code TUI command with no CLI equivalent,
// so we copy it to the clipboard as a convenience.
try {
await navigator.clipboard.writeText("/reload");
setSwitchMessage(t("reloadCopiedHint"));
setTimeout(() => setSwitchMessage(null), 4000);
} catch {
// Clipboard may be unavailable; ignore silently.
}
};
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
@@ -391,6 +402,12 @@ export default function App() {
</div>
)}
{switchMessage && (
<div className="bg-green-900/20 text-green-400 p-2 text-sm border-b border-green-500/20 text-center">
{switchMessage}
</div>
)}
<main className="flex-1 overflow-hidden relative">
{view === "list" ? (
<ProviderList
@@ -452,11 +469,6 @@ export default function App() {
});
}}
onModelAdd={() => {
const providerModels = Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
);
const roles = ["Sonnet", "Opus", "Fable", "Haiku"];
const defaultRole = roles[providerModels.length % roles.length];
const alias = `新模型[${currentProvider.name}]`;
updateConfig((cfg) => ({
...cfg,
@@ -468,9 +480,8 @@ export default function App() {
model: "",
max_context_size: getDefaultMaxContextSize(alias),
display_name: null,
role: defaultRole,
supports_1m: false,
capabilities: [],
capabilities: agent === "kimi_code" ? ["thinking"] : [],
},
},
}));
+87 -28
View File
@@ -1,10 +1,30 @@
import { useEffect, useId, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
"thinking",
"always_thinking",
"image_in",
"video_in",
"tool_use",
] as const;
const CAPABILITY_LABELS: Record<
(typeof KNOWN_CAPABILITIES)[number],
TranslationKey
> = {
thinking: "capThinking",
always_thinking: "capAlwaysThinking",
image_in: "capImageIn",
video_in: "capVideoIn",
tool_use: "capToolUse",
};
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
@@ -346,6 +366,7 @@ function ModelMapping({
const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [fetchThinking, setFetchThinking] = useState(true);
const handleDiscover = async () => {
setDiscovering(true);
@@ -379,7 +400,6 @@ function ModelMapping({
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
const role = `${dm.id}[${provider.name}]`;
const max_context_size = dm.max_context_size ?? getDefaultMaxContextSize(dm.id);
toAdd.push({
alias,
@@ -387,9 +407,8 @@ function ModelMapping({
model: dm.id,
max_context_size,
display_name: dm.display_name,
role,
supports_1m: max_context_size >= 1_000_000,
capabilities: [],
capabilities: fetchThinking ? ["thinking"] : [],
});
}
onBulkAdd(toAdd);
@@ -434,6 +453,15 @@ function ModelMapping({
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<label className="flex items-center gap-2 text-sm text-[#e5e5e7] cursor-pointer">
<input
type="checkbox"
checked={fetchThinking}
onChange={(e) => setFetchThinking(e.target.checked)}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
{t("fetchEnableThinking")}
</label>
<div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => (
<label
@@ -467,7 +495,6 @@ function ModelMapping({
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-[#1f1f23] text-gray-400">
<tr>
<th className="text-left px-4 py-3 font-medium">{t("modelRole")}</th>
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
@@ -482,18 +509,6 @@ function ModelMapping({
<tbody className="divide-y divide-[#2a2a2e]">
{models.map((m) => (
<tr key={m.alias} className="hover:bg-[#1c1c20]">
<td className="px-4 py-2">
<select
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.role || "Sonnet"}
onChange={(e) => onModelChange({ ...m, role: e.target.value })}
>
<option value="Sonnet">Sonnet</option>
<option value="Opus">Opus</option>
<option value="Fable">Fable</option>
<option value="Haiku">Haiku</option>
</select>
</td>
<td className="px-4 py-2">
<span className="text-sm text-gray-400">
{m.model ? `${m.model}[${m.provider}]` : m.alias}
@@ -539,18 +554,10 @@ function ModelMapping({
</td>
{agent === "kimi_code" && (
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={(m.capabilities || []).join(", ")}
placeholder="thinking, image_in"
onChange={(e) =>
onModelChange({
...m,
capabilities: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
<CapabilitiesCell
capabilities={m.capabilities || []}
onChange={(next) =>
onModelChange({ ...m, capabilities: next })
}
/>
</td>
@@ -668,3 +675,55 @@ function JsonPreview({
</div>
);
}
function CapabilitiesCell({
capabilities,
onChange,
}: {
capabilities: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslation();
const known = new Set<string>(KNOWN_CAPABILITIES);
const customs = capabilities.filter((c) => !known.has(c));
const toggle = (cap: string, on: boolean) => {
if (on) {
onChange([...capabilities.filter((c) => c !== cap), cap]);
} else {
onChange(capabilities.filter((c) => c !== cap));
}
};
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{KNOWN_CAPABILITIES.map((cap) => (
<label
key={cap}
className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer"
>
<input
type="checkbox"
checked={capabilities.includes(cap)}
onChange={(e) => toggle(cap, e.target.checked)}
className="w-3.5 h-3.5 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
{t(CAPABILITY_LABELS[cap])}
</label>
))}
<input
className="w-28 bg-transparent border border-[#2a2a2e] rounded px-1.5 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={customs.join(", ")}
placeholder={t("customCapabilities")}
onChange={(e) => {
const newCustoms = e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const kept = capabilities.filter((c) => known.has(c));
onChange([...kept, ...newCustoms]);
}}
/>
</div>
);
}
+28 -4
View File
@@ -66,9 +66,21 @@ export function ProviderList({
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white text-xl shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
+
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
@@ -80,9 +92,21 @@ export function ProviderList({
<button
type="button"
onClick={onAdd}
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white text-2xl shadow-lg flex items-center justify-center mx-auto"
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg flex items-center justify-center mx-auto"
>
+
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
) : (
+9 -2
View File
@@ -2,7 +2,7 @@ import type { TranslationKey } from "./zh";
export const enTranslations: Record<TranslationKey, string> = {
// App / global
appTitle: "Pi Switch",
appTitle: "Kimi Switch",
loading: "Loading...",
loadTimeout: "Loading is taking a while. Try restarting the app if it stays stuck.",
loadFailed: "Failed to load:",
@@ -58,7 +58,6 @@ export const enTranslations: Record<TranslationKey, string> = {
fetchingModels: "Fetching...",
discoveredModels: "Discovered {count} models",
addSelected: "Add selected",
modelRole: "Model role",
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
@@ -88,6 +87,7 @@ export const enTranslations: Record<TranslationKey, string> = {
switchTo: "Switch to",
inUse: "In use",
switchReloadHint: "After switching providers, run /reload in Kimi Code to apply the change.",
reloadCopiedHint: "Provider switched. /reload copied to clipboard; paste it in Kimi Code.",
unsavedChanges: "Unsaved changes",
qwenActivateNotImplemented: "Qwen Code activation will be implemented in a follow-up.",
agentSettings: "Global Settings",
@@ -123,6 +123,13 @@ export const enTranslations: Record<TranslationKey, string> = {
importConfigConfirm: "Importing will overwrite unsaved changes. Continue?",
importConfigFailed: "Import failed: {message}",
capabilities: "Capabilities",
fetchEnableThinking: "Enable thinking for fetched models",
capThinking: "Thinking",
capAlwaysThinking: "Always Thinking",
capImageIn: "Image Input",
capVideoIn: "Video Input",
capToolUse: "Tool Use",
customCapabilities: "Other capabilities (comma-separated)",
// Languages
language: "Language",
+1 -1
View File
@@ -27,7 +27,7 @@ interface I18nContextValue {
const I18nContext = createContext<I18nContextValue | null>(null);
const STORAGE_KEY = "pi-switch-lang";
const STORAGE_KEY = "kimi-switch-lang";
function getInitialLang(): Language {
try {
+9 -2
View File
@@ -1,6 +1,6 @@
export const zhTranslations = {
// App / global
appTitle: "Pi Switch",
appTitle: "Kimi Switch",
loading: "加载中...",
loadTimeout: "加载时间较长,如果一直卡住,请尝试重新启动应用。",
loadFailed: "加载失败:",
@@ -56,7 +56,6 @@ export const zhTranslations = {
fetchingModels: "获取中...",
discoveredModels: "发现 {count} 个模型",
addSelected: "添加选中的",
modelRole: "模型角色",
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
@@ -86,6 +85,7 @@ export const zhTranslations = {
switchTo: "切换使用",
inUse: "使用中",
switchReloadHint: "切换供应商后,请在 Kimi Code 中执行 /reload 以生效。",
reloadCopiedHint: "已切换供应商,/reload 已复制到剪贴板,请在 Kimi Code 中粘贴执行。",
unsavedChanges: "未保存修改",
qwenActivateNotImplemented: "Qwen Code 启用功能将在后续实现。",
agentSettings: "全局配置",
@@ -121,6 +121,13 @@ export const zhTranslations = {
importConfigConfirm: "导入会覆盖当前未保存的修改,是否继续?",
importConfigFailed: "导入失败:{message}",
capabilities: "能力标签",
fetchEnableThinking: "为获取的模型启用思考",
capThinking: "思考",
capAlwaysThinking: "始终思考",
capImageIn: "图像输入",
capVideoIn: "视频输入",
capToolUse: "工具调用",
customCapabilities: "其他能力(逗号分隔)",
// Languages
language: "语言",
-2
View File
@@ -34,8 +34,6 @@ export interface Model {
model: string;
max_context_size: number;
display_name: string | null;
/** Optional model role, e.g. Sonnet/Opus/Fable/Haiku for Claude-style mapping. */
role?: string | null;
/** Whether the model declares extended context / thinking support. */
supports_1m?: boolean;
/** Agent-specific capability flags (e.g. Kimi Code `capabilities`). */