Compare commits

..
15 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
PiSwitch Dev 4d120ad2bb chore: remove stray screenshot files 2026-07-22 13:18:19 +08:00
KimiSwitch Dev 211986a1e5 feat: update provider/model config handling 2026-07-15 19:15:04 +08:00
KimiSwitch Dev a394e5f730 refactor: auto-generate model role from model name[provider name] format 2026-07-09 11:00:36 +08:00
KimiSwitch Dev b01a5661b4 feat: unify provider active logic and add env field support for kimi_code_io 2026-07-09 08:44:11 +08:00
KimiSwitch Dev 3b12844342 docs: add design specs and implementation plans for reload hint and global settings panel 2026-07-09 08:41:28 +08:00
KimiSwitch Dev 675e8ba24d fix: correct effort type assertion in AgentSettingsPanel 2026-07-09 01:05:41 +08:00
KimiSwitch Dev 5fcdc333b1 feat: apply smart default max_context_size when adding models 2026-07-09 01:04:24 +08:00
KimiSwitch Dev 2b25c62ffc chore: pass rawOther to ProviderEdit 2026-07-09 01:02:59 +08:00
KimiSwitch Dev 0cd70b9dbc feat: render AgentSettingsPanel in ProviderEdit for Kimi Code 2026-07-09 01:02:26 +08:00
KimiSwitch Dev 70c52aeaca feat: add AgentSettingsPanel component 2026-07-09 01:01:13 +08:00
42 changed files with 3234 additions and 316 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -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`.
---
@@ -0,0 +1,192 @@
# 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.
@@ -0,0 +1,937 @@
# 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.
@@ -0,0 +1,47 @@
# 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 编译与项目构建保持通过。
@@ -0,0 +1,253 @@
# 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` 通过。
+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

+6 -5
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)
}
@@ -87,12 +87,13 @@ fn active_provider_and_model(config: &Config) -> Option<(String, String)> {
}
fn build_active_config(config: &Config) -> Config {
let is_kimi_native = |p: &Provider| p.managed;
// Only the provider explicitly marked as active is written to the agent's
// 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
.iter()
.filter(|(_, p)| is_kimi_native(p) || p.active)
.filter(|(_, p)| p.active)
.map(|(k, p)| (k.clone(), p.clone()))
.collect();
+83 -1
View File
@@ -1,2 +1,84 @@
//! Configuration file I/O for KimiSwitch.
//! Configuration file I/O utilities for KimiSwitch.
//! Reads and writes the global config file and profile files.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{Duration, Local};
/// Creates a backup of `path` inside a `backups/` subdirectory next to the
/// original file, then removes backup files older than `retention_days`.
pub fn backup_file(path: &Path, retention_days: i64) -> Result<PathBuf> {
if !path.exists() {
return Ok(PathBuf::new());
}
let parent = path.parent().context("path has no parent directory")?;
let backups_dir = parent.join("backups");
std::fs::create_dir_all(&backups_dir)
.with_context(|| format!("failed to create backups directory {}", backups_dir.display()))?;
let file_name = path.file_name().context("path has no file name")?;
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%.3f").to_string();
let backup_name = format!("{}.bak.{}", file_name.to_string_lossy(), timestamp);
let mut backup_path = backups_dir.join(&backup_name);
if backup_path.exists() {
for n in 1..1000 {
let candidate = backups_dir.join(format!(
"{}.bak.{}.{:03}",
file_name.to_string_lossy(),
timestamp,
n
));
if !candidate.exists() {
backup_path = candidate;
break;
}
}
}
std::fs::copy(path, &backup_path).with_context(|| {
format!(
"failed to back up {} to {}",
path.display(),
backup_path.display()
)
})?;
cleanup_old_backups(&backups_dir, retention_days)?;
Ok(backup_path)
}
/// Removes backup files in `dir` older than `retention_days`, based on file
/// modification time. Only files whose names contain `.bak.` are deleted.
fn cleanup_old_backups(dir: &Path, retention_days: i64) -> Result<()> {
let cutoff = Local::now() - Duration::days(retention_days);
let entries = std::fs::read_dir(dir)
.with_context(|| format!("failed to read backups directory {}", dir.display()))?;
for entry in entries {
let entry = entry.context("failed to read directory entry")?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.contains(".bak.") {
continue;
}
let metadata = entry
.metadata()
.with_context(|| format!("failed to read metadata for {}", path.display()))?;
let modified = metadata
.modified()
.with_context(|| format!("failed to read modified time for {}", path.display()))?;
let modified: chrono::DateTime<Local> = modified.into();
if modified < cutoff {
std::fs::remove_file(&path)
.with_context(|| format!("failed to remove old backup {}", path.display()))?;
}
}
Ok(())
}
+38 -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(),
@@ -258,6 +281,7 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
fn provider_type_for_str(s: &str) -> ProviderType {
match s {
"anthropic" => ProviderType::Anthropic,
"openai" => ProviderType::Openai,
"openai_responses" => ProviderType::OpenaiResponses,
"google-genai" => ProviderType::GoogleGenai,
"vertexai" => ProviderType::Vertexai,
+149 -82
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;
@@ -11,14 +11,16 @@ use indexmap::IndexMap;
use serde_json::Value;
use toml::value::{Table, Value as TomlValue};
use crate::config_io::backup_file;
use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type KimiResult<T> = anyhow::Result<T>;
pub fn kimi_code_config_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".kimi-code"))
.expect("failed to resolve home directory")
std::env::var_os("KIMI_CODE_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".kimi-code")))
.expect("failed to resolve Kimi Code config directory")
}
pub fn kimi_code_config_path() -> PathBuf {
@@ -50,21 +52,9 @@ pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
}
if path.exists() {
let timestamp = chrono::Local::now()
.format("%Y%m%d_%H%M%S_%.3f")
.to_string();
let mut backup_path = path.with_extension(format!("toml.bak.{}", timestamp));
if backup_path.exists() {
for n in 1..1000 {
let candidate = path.with_extension(format!("toml.bak.{}.{:03}", timestamp, n));
if !candidate.exists() {
backup_path = candidate;
break;
}
}
}
std::fs::copy(&path, &backup_path)
.with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?;
backup_file(&path, 7).with_context(|| {
format!("failed to back up Kimi Code config {}", path.display())
})?;
}
let content = toml::to_string_pretty(value)
@@ -74,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();
@@ -86,51 +76,8 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let mut providers = IndexMap::new();
let mut models = IndexMap::new();
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();
let provider_type = table
.get("type")
.and_then(|v| v.as_str())
.map(provider_type_for_kimi_type)
.unwrap_or(ProviderType::Kimi);
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let raw_other = {
let mut rest = table.clone();
rest.remove("type");
rest.remove("base_url");
rest.remove("api_key");
rest.remove("managed");
rest.remove("enabled");
rest.remove("oauth");
toml_value_to_json(&TomlValue::Table(rest))
};
providers.insert(
name.clone(),
Provider {
name: name.clone(),
provider_type,
base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()),
env: IndexMap::new(),
note: None,
official_url: None,
managed,
enabled,
active: true,
raw_other,
},
);
}
}
// 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();
@@ -162,8 +109,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
.collect()
})
.unwrap_or_default();
let supports_1m = capabilities.iter().any(|c| c == "thinking")
|| max_context_size >= 1_000_000;
let supports_1m = max_context_size >= 1_000_000;
let raw_other = {
let mut rest = table.clone();
@@ -183,7 +129,6 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
model: model_id,
max_context_size,
display_name,
role: None,
supports_1m,
capabilities,
raw_other,
@@ -192,6 +137,67 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
}
}
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();
let provider_type = table
.get("type")
.and_then(|v| v.as_str())
.map(provider_type_for_kimi_type)
.unwrap_or(ProviderType::Kimi);
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let env: IndexMap<String, String> = table
.get("env")
.and_then(|v| v.as_table())
.map(|t| {
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
let raw_other = {
let mut rest = table.clone();
rest.remove("type");
rest.remove("base_url");
rest.remove("api_key");
rest.remove("managed");
rest.remove("enabled");
rest.remove("oauth");
rest.remove("env");
toml_value_to_json(&TomlValue::Table(rest))
};
providers.insert(
name.clone(),
Provider {
name: name.clone(),
provider_type,
base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()),
env,
note: None,
official_url: None,
managed,
enabled,
active: active_provider_name == Some(name.as_str()),
raw_other,
},
);
}
}
let raw_other = {
let mut rest = root;
rest.remove("default_model");
@@ -208,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.
@@ -226,13 +232,11 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
.unwrap_or(TomlValue::String("".to_string())),
);
let is_kimi_native = |p: &Provider| p.managed;
let mut providers_table = Table::new();
for (name, provider) in &config.providers {
// Skip inactive custom providers when writing to Kimi Code config.
// Kimi native (managed) providers are always preserved.
if !is_kimi_native(provider) && !provider.active {
// Only write the active provider to Kimi Code config so the agent
// follows Kimi Switch's selection.
if !provider.active {
continue;
}
let mut pt = Table::new();
@@ -244,6 +248,13 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
pt.insert("api_key".to_string(), TomlValue::String(api_key));
}
pt.insert("enabled".to_string(), TomlValue::Boolean(provider.enabled));
if !provider.env.is_empty() {
let mut env_table = Table::new();
for (k, v) in &provider.env {
env_table.insert(k.clone(), TomlValue::String(v.clone()));
}
pt.insert("env".to_string(), TomlValue::Table(env_table));
}
if provider.managed {
// Preserve existing oauth config if present, otherwise create a default entry.
let oauth = provider
@@ -258,9 +269,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
});
pt.insert("oauth".to_string(), oauth);
}
// Merge remaining raw fields (oauth is handled explicitly above).
// Merge remaining raw fields (oauth and env are handled explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth");
extra.remove("env");
for (k, v) in extra {
pt.insert(k, v);
}
@@ -273,7 +285,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
let active_provider_names: std::collections::HashSet<&str> = config
.providers
.values()
.filter(|p| is_kimi_native(p) || p.active)
.filter(|p| p.active)
.map(|p| p.name.as_str())
.collect();
@@ -292,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(),
@@ -454,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");
@@ -489,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,
@@ -520,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"
);
}
}
}
}
+74
View File
@@ -1,12 +1,86 @@
pub mod commands;
pub mod config_io;
pub mod db;
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
+19 -42
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;
@@ -11,15 +11,17 @@ use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config_io::backup_file;
use crate::models::{Config, Model, Provider, ProviderType};
pub type PiResult<T> = anyhow::Result<T>;
/// Returns the default Pi agent config directory for the current user.
pub fn pi_agent_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".pi").join("agent"))
.expect("failed to resolve home directory")
std::env::var_os("PI_CODING_AGENT_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".pi").join("agent")))
.expect("failed to resolve Pi agent config directory")
}
/// Returns the path to Pi's `models.json` file.
@@ -131,21 +133,9 @@ pub fn save_pi_models(file: &PiModelsFile) -> PiResult<()> {
}
if path.exists() {
let timestamp = chrono::Local::now()
.format("%Y%m%d_%H%M%S_%.3f")
.to_string();
let mut backup_path = path.with_extension(format!("json.bak.{}", timestamp));
if backup_path.exists() {
for n in 1..1000 {
let candidate = path.with_extension(format!("json.bak.{}.{:03}", timestamp, n));
if !candidate.exists() {
backup_path = candidate;
break;
}
}
}
std::fs::copy(&path, &backup_path)
.with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?;
backup_file(&path, 7).with_context(|| {
format!("failed to back up Pi models {}", path.display())
})?;
}
let content = serde_json::to_string_pretty(file)
@@ -160,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")]
@@ -195,21 +185,9 @@ pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> {
}
if path.exists() {
let timestamp = chrono::Local::now()
.format("%Y%m%d_%H%M%S_%.3f")
.to_string();
let mut backup_path = path.with_extension(format!("json.bak.{}", timestamp));
if backup_path.exists() {
for n in 1..1000 {
let candidate = path.with_extension(format!("json.bak.{}.{:03}", timestamp, n));
if !candidate.exists() {
backup_path = candidate;
break;
}
}
}
std::fs::copy(&path, &backup_path)
.with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?;
backup_file(&path, 7).with_context(|| {
format!("failed to back up Pi settings {}", path.display())
})?;
}
let content = serde_json::to_string_pretty(file)
@@ -219,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",
@@ -232,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,
@@ -244,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()
@@ -271,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()
@@ -332,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();
@@ -386,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,
@@ -405,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,
+108 -25
View File
@@ -5,9 +5,34 @@ import { useConfig } from "./hooks/useConfig";
import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit";
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;
const value = raw?.default_model;
return typeof value === "string" ? value : null;
}
function setProviderDefaultModel(provider: Provider, alias: string | null): Provider {
const raw = (provider.raw_other as Record<string, unknown> | undefined) ?? {};
if (alias) {
return { ...provider, raw_other: { ...raw, default_model: alias } };
}
const { default_model: _, ...rest } = raw;
return { ...provider, raw_other: rest };
}
function findFirstModelForProvider(models: Record<string, Model>, providerName: string): string | null {
for (const [key, m] of Object.entries(models)) {
if (m.provider === providerName) {
return key;
}
}
return null;
}
const AGENTS: { key: Agent; label: string }[] = [
{ key: "kimi_code", label: "Kimi Code" },
@@ -40,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);
@@ -188,38 +214,77 @@ export default function App() {
};
const handleSetDefaultModel = (alias: string) => {
updateConfig((cfg) => ({ ...cfg, default_model: alias }));
updateConfig((cfg) => {
const model = cfg.models[alias];
if (!model) return cfg;
const provider = cfg.providers[model.provider];
if (!provider) return cfg;
const providers = {
...cfg.providers,
[provider.name]: setProviderDefaultModel(provider, alias),
};
return { ...cfg, providers, default_model: alias };
});
};
const handleSwitchProvider = async (name: string) => {
updateConfig((cfg) => {
const target = cfg.providers[name];
if (!target || target.managed) return cfg;
if (!target) return cfg;
const isKimiNative = (p: Provider) => p.managed === true;
// Mark the selected provider as active and deactivate other custom providers.
// Kimi native providers are left untouched. No provider/model records are deleted.
const providers: Record<string, Provider> = {};
// Find the currently active provider so we can remember its default
// model before switching away.
let activeProviderName: string | null = null;
for (const [key, p] of Object.entries(cfg.providers)) {
providers[key] = { ...p, active: isKimiNative(p) ? undefined : key === name };
}
// Set default model to the first model of the selected provider.
let default_model: string | null = null;
for (const [key, m] of Object.entries(cfg.models)) {
if (m.provider === name) {
default_model = key;
if (p.active) {
activeProviderName = key;
break;
}
}
// Only the selected provider is active. All other providers (including
// Kimi native/managed and custom) are deactivated so the target agent
// 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 };
}
// Remember the active provider's current default model (if it belongs to
// that provider) so switching back later restores the user's choice.
if (activeProviderName && cfg.default_model) {
const activeProvider = providers[activeProviderName];
if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) {
providers[activeProviderName] = setProviderDefaultModel(
activeProvider,
cfg.default_model
);
}
}
// Prefer the target provider's remembered default model; fall back to
// its first model when there is no saved preference or it is invalid.
let default_model = getProviderDefaultModel(target);
if (!default_model || cfg.models[default_model]?.provider !== name) {
default_model = findFirstModelForProvider(cfg.models, name);
}
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[]) => {
@@ -246,7 +311,12 @@ export default function App() {
alias = `${m.alias}-${n}`;
n++;
}
updatedModels[alias] = { ...m, alias, provider: provider.name };
updatedModels[alias] = {
...m,
alias,
provider: provider.name,
max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model),
};
}
let default_model = cfg.default_model;
@@ -254,6 +324,13 @@ export default function App() {
default_model = null;
}
// Remember this provider's default model preference so it survives
// provider switches.
const updatedProvider = providers[provider.name];
if (updatedProvider && default_model && updatedModels[default_model]?.provider === provider.name) {
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
}
return { ...cfg, providers, models: updatedModels, default_model };
});
if (provider.name !== editingProvider) {
@@ -325,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
@@ -348,6 +431,10 @@ export default function App() {
(m) => m.provider === currentProvider.name
)}
defaultModel={config.default_model}
rawOther={config.raw_other}
onRawOtherChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
onChange={handleUpdateProvider}
onDelete={() => handleDeleteProvider(currentProvider.name)}
@@ -382,10 +469,7 @@ export default function App() {
});
}}
onModelAdd={() => {
const providerModels = Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
);
const alias = `${currentProvider.name}-${providerModels.length + 1}`;
const alias = `新模型[${currentProvider.name}]`;
updateConfig((cfg) => ({
...cfg,
models: {
@@ -394,11 +478,10 @@ export default function App() {
alias,
provider: currentProvider.name,
model: "",
max_context_size: 128000,
max_context_size: getDefaultMaxContextSize(alias),
display_name: null,
role: null,
supports_1m: false,
capabilities: [],
capabilities: agent === "kimi_code" ? ["thinking"] : [],
},
},
}));
+376
View File
@@ -0,0 +1,376 @@
import type { ReactNode } from "react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types";
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>
<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: effort as NonNullable<AgentSettings["thinking"]>["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>
<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>
<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>
<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>
<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>
);
}
+118 -51
View File
@@ -1,8 +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",
@@ -47,6 +69,8 @@ interface ProviderEditProps {
provider: Provider;
models: Model[];
defaultModel: string | null;
rawOther: unknown;
onRawOtherChange: (nextRawOther: unknown) => void;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
@@ -64,6 +88,8 @@ export function ProviderEdit({
provider,
models,
defaultModel,
rawOther,
onRawOtherChange,
onBack,
onChange,
onDelete,
@@ -281,17 +307,25 @@ export function ProviderEdit({
)}
{activeTab === "models" && (
<ModelMapping
agent={agent}
provider={provider}
models={models}
defaultModel={defaultModel}
onModelChange={onModelChange}
onModelDelete={onModelDelete}
onModelAdd={onModelAdd}
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
<>
<ModelMapping
agent={agent}
provider={provider}
models={models}
defaultModel={defaultModel}
onModelChange={onModelChange}
onModelDelete={onModelDelete}
onModelAdd={onModelAdd}
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
{agent === "kimi_code" && (
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
/>
)}
</>
)}
{activeTab === "json" && (
@@ -332,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);
@@ -365,15 +400,15 @@ function ModelMapping({
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
const max_context_size = dm.max_context_size ?? getDefaultMaxContextSize(dm.id);
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size: dm.max_context_size ?? 128000,
max_context_size,
display_name: dm.display_name,
role: null,
supports_1m: (dm.max_context_size ?? 0) >= 1_000_000,
capabilities: [],
supports_1m: max_context_size >= 1_000_000,
capabilities: fetchThinking ? ["thinking"] : [],
});
}
onBulkAdd(toAdd);
@@ -418,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
@@ -451,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>
@@ -467,29 +510,9 @@ function ModelMapping({
{models.map((m) => (
<tr key={m.alias} className="hover:bg-[#1c1c20]">
<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.role || ""}
placeholder={m.alias}
onChange={(e) =>
onModelChange({
...m,
role: e.target.value || null,
})
}
/>
</td>
<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.display_name || ""}
onChange={(e) =>
onModelChange({
...m,
display_name: e.target.value || null,
})
}
/>
<span className="text-sm text-gray-400">
{m.model ? `${m.model}[${m.provider}]` : m.alias}
</span>
</td>
<td className="px-4 py-2">
<input
@@ -531,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>
@@ -660,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>
);
}
+30 -7
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>
) : (
@@ -92,10 +116,9 @@ export function ProviderList({
(m) => m.provider === provider.name
);
const defaultModelName = defaultModel
? models[defaultModel]?.display_name || defaultModel
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
: null;
const isKimiNative = provider.managed === true;
const isActive = !isKimiNative && provider.active === true;
const isActive = provider.active === true;
return (
<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`). */