Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948e96352e | ||
|
|
0ce89b09d9 | ||
|
|
0bf0f50aaa | ||
|
|
dd22d769f4 | ||
|
|
c32f3f458f | ||
|
|
4d120ad2bb | ||
|
|
211986a1e5 | ||
|
|
a394e5f730 | ||
|
|
b01a5661b4 | ||
|
|
3b12844342 | ||
|
|
675e8ba24d | ||
|
|
5fcdc333b1 | ||
|
|
2b25c62ffc | ||
|
|
0cd70b9dbc | ||
|
|
70c52aeaca |
No files matched your search
@@ -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.
|
||||
@@ -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) | **中文**
|
||||
|
||||
[](https://tauri.app)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](./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
|
||||
@@ -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)
|
||||
|
||||
[](https://tauri.app)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](./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
|
||||
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "piswitch",
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 506 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 15 KiB |
@@ -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();
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "语言",
|
||||
|
||||
@@ -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`). */
|
||||
|
||||