Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1fdc6c93d | ||
|
|
3dd1fd93fe | ||
|
|
6f2b927914 | ||
|
|
b1d137f072 | ||
|
|
6b172914be |
No files matched your search
@@ -10,3 +10,6 @@ src-tauri/wix314-binaries/
|
||||
Thumbs.db
|
||||
Snipaste_*.png
|
||||
.worktrees/
|
||||
|
||||
# 内部设计规范与实施计划,仅供本地查阅,不入版本库
|
||||
docs/superpowers/
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kimi Switch
|
||||
|
||||
> Windows 桌面端 LLM 供应商配置管理器,让你在 **Kimi Code CLI** 和 **Pi** 两个 Agent 之间无缝切换多家 LLM 供应商与模型。
|
||||
> Windows 桌面端的 **Kimi Code CLI** 配置管理器——统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
|
||||
|
||||
[English](./README_EN.md) | **中文**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
|
||||
[](./LICENSE)
|
||||
|
||||
---
|
||||
@@ -17,7 +17,9 @@
|
||||
|
||||
- [这是什么](#这是什么)
|
||||
- [核心特性](#核心特性)
|
||||
- [界面预览](#界面预览)
|
||||
- [架构总览](#架构总览)
|
||||
- [功能详情](#功能详情)
|
||||
- [支持的供应商类型](#支持的供应商类型)
|
||||
- [数据存储位置](#数据存储位置)
|
||||
- [快速上手](#快速上手)
|
||||
@@ -26,83 +28,216 @@
|
||||
- [国际化](#国际化)
|
||||
- [测试](#测试)
|
||||
- [打包发布](#打包发布)
|
||||
- [发布历史](#发布历史)
|
||||
- [常见问题](#常见问题)
|
||||
- [安全提示](#安全提示)
|
||||
- [致谢](#致谢)
|
||||
|
||||
---
|
||||
|
||||
## 这是什么
|
||||
|
||||
**Kimi Switch** 是一个为 LLM CLI 用户打造的 Windows 桌面配置管理工具。它解决两个痛点:
|
||||
**Kimi Switch** 是一个为 [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code) 用户打造的 Windows 桌面配置管理工具。它把 `~/.kimi-code/config.toml` 的手改工作搬到图形界面里,并补上一系列 CLI 自身没提供的便利:
|
||||
|
||||
1. **多供应商管理繁琐**:在 Kimi / Anthropic / OpenAI / Google GenAI / 自建代理 等多家供应商之间切换时,需要反复手改 TOML/JSON,容易出错。
|
||||
2. **多 Agent 配置割裂**:同时使用 **Kimi Code CLI** 和 **Pi** 两个 Agent 的开发者,每个 Agent 都有一套独立的配置格式,改一处要改两份。
|
||||
|
||||
Kimi Switch 提供统一的图形界面:
|
||||
|
||||
- 统一图形界面,分别管理 Kimi Code 和 Pi 两套 Agent 配置,顶部 Tab 切换
|
||||
- 一键切换供应商,更新对应 Agent 原生配置的 `default_model`,保留全部供应商
|
||||
- 一键拉取模型列表(OpenAI / Anthropic / Google GenAI)
|
||||
- 切换后给出 `/reload` 提示,确保 Kimi Code 会话立即生效
|
||||
- **多供应商统一管理**:Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI,一个界面搞定;新增的供应商会**自动提升到列表最前**,再切换不会覆盖丢失
|
||||
- **模型一键发现**:根据供应商 API 自动拉取可用模型列表,模型显示名、上下文大小、能力等**优先取自 models.dev** 缓存(缺字段时再由后端/UI 兜底)
|
||||
- **连通性测试**:参考 cc-switch 语义对 `base_url` 发 GET 请求,显示延迟气泡(绿/橙/红),6 秒自动消失
|
||||
- **重复供应商**:一键深拷贝现有供应商及其模型,改 key 到 `xxx-copy` 即可二次定制
|
||||
- **图标系统**:内置 100+ 主流供应商品牌图标,参考 cc-switch 实现;无匹配的供应商自动按命名规则生成首字母默认图标
|
||||
- **用量仪表盘**:移植自 kimicode-dashboard,KPI / 热力图 / 按模型分色每日趋势 / 最近请求翻页
|
||||
- **会话管理**:按工作区浏览、预览、归档、批量删除 Kimi Code 会话
|
||||
- **版本更新**:进入应用时自动检测(可关闭),并支持一键下载安装
|
||||
- **主题/语言切换**:设置面板中切换深色/浅色/跟随系统,简体中文/English
|
||||
- **Windows 友好**:单实例;最小化保留任务栏按钮;关闭按钮隐藏到托盘
|
||||
|
||||
## 核心特性
|
||||
|
||||
| 分类 | 功能 |
|
||||
| --- | --- |
|
||||
| **多 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`) |
|
||||
| **多 Agent** | 主目标 Kimi Code,Pi 代码保留但 UI 隐藏 |
|
||||
| **图标系统** | 100+ 品牌图标 + 首字母默认图标;图标选择器按 brands / inference 分类 |
|
||||
| **快捷切换** | 新增或激活的供应商自动提升到列表最前;切换只改 `default_model`,不丢其他供应商 |
|
||||
| **连通性测试** | 实测 `base_url` 延迟,绿/橙/红彩色气泡,6 秒自动消失 |
|
||||
| **重复供应商** | 一键深拷贝供应商 + 全部模型,key 改 `xxx-copy` |
|
||||
| **图标按钮操作** | 启用 / 编辑 / 复制 / 测试连通 / 删除 全图标化(lucide-react) |
|
||||
| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、1M 上下文声明、能力 |
|
||||
| **自动上下文** | 拉取模型时 **API 返回 > models.dev ref > 正则兜底** 三级优先级自动适配 |
|
||||
| **能力自动推导** | `image_in / video_in / tool_use` 全部由 models.dev 推得;UI 仅暴露 `thinking` 一个手动开关 |
|
||||
| **全局设置** | `[thinking]` 表完整支持(enabled / effort / keep),仅 Kimi Code 生效 |
|
||||
| **JSON 直编** | 高级用户可手动编辑完整配置 JSON,未知字段通过 `raw_other` 透传不丢 |
|
||||
| **i18n / 主题** | 简体中文 / English;深色 / 浅色 / 跟随系统;设置持久化 |
|
||||
| **自动备份** | 写入 `config.toml` 前自动备份,按时间戳命名,保留最近 7 天 |
|
||||
| **快捷键** | Ctrl+S 保存、Ctrl+R 重载、Ctrl+O 打开配置目录 |
|
||||
| **用量仪表盘** | Token 用量统计、每日趋势(按模型分色)、全年热力图、最近请求翻页、双击查看模型分布 |
|
||||
| **会话管理** | 按工作区浏览、预览、归档、批量删除会话,逐行流式读取防崩溃(20MB 上限 + 500 字符折叠) |
|
||||
| **未保存提示** | 关闭窗口前检测未保存修改,标题栏加 `*` 前缀 |
|
||||
| **用量仪表盘** | 8 项 KPI、每日趋势(按模型分色堆叠)、全年热力图、最近请求翻页、双击柱状图查看模型分布 |
|
||||
| **会话管理** | 按工作区浏览、归档/复活、批量删除;流式逐行预览(20MB 字节上限 + 500 字符折叠) |
|
||||
| **版本更新** | 启动自动 + 每 8 小时周期 + 手动检查;下载进度条;下载完成后引导安装 |
|
||||
| **未保存提示** | 关闭窗口前原生 `beforeunload` 提示 + 标题栏 `*` 前缀 |
|
||||
| **窗口/托盘** | 最小化保留任务栏;关闭 X 隐藏到托盘;托盘左键始终显示并聚焦 |
|
||||
|
||||
## 界面预览
|
||||
|
||||
**供应商列表**(浅色主题)
|
||||
|
||||

|
||||
|
||||
展示当前激活的供应商、默认模型、延迟气泡、可用模型数量与品牌图标;支持快速切换、复制、测试连通、跳转官网、编辑、删除。
|
||||
|
||||
**编辑供应商 — 基本信息**
|
||||
|
||||

|
||||
|
||||
包括供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key、请求地址。
|
||||
|
||||
**编辑供应商 — 模型映射**
|
||||
|
||||

|
||||
|
||||
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、1M 上下文声明、能力(仅"思考")、设为默认、删除。
|
||||
|
||||
**用量仪表盘**
|
||||
|
||||

|
||||
|
||||
8 项 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图,贴底布局)+ 模型用量明细 + 最近请求(分页)。
|
||||
|
||||
**会话管理**
|
||||
|
||||

|
||||
|
||||
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 (Tauri v2) │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ src/App.tsx │ │ src-tauri/src/ │ │
|
||||
│ │ src/components/ │◄──►│ ├── lib.rs │ │
|
||||
│ │ ProviderList │ │ ├── main.rs │ │
|
||||
│ │ ProviderEdit │ │ ├── commands.rs │ │
|
||||
│ │ AgentSettingsPanel │ │ ├── db.rs │ │
|
||||
│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │
|
||||
│ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │
|
||||
│ │ IconPicker │ │ ├── config_io.rs │ │
|
||||
│ │ dashboard/ │ │ ├── models.rs │ │
|
||||
│ │ sessions/ │ │ ├── validators.rs │ │
|
||||
│ │ src/hooks/ │ │ ├── dashboard.rs │ │
|
||||
│ │ useConfig / Dashboard│ │ └── profile_manager.rs │ │
|
||||
│ │ / Sessions / Theme │ │ │ │
|
||||
│ │ / UpdateCheck │ │ │ │
|
||||
│ │ src/lib/ │ │ │ │
|
||||
│ │ models-dev.ts │ │ │ │
|
||||
│ │ model-defaults.ts │ │ │ │
|
||||
│ │ agent-settings.ts │ │ │ │
|
||||
│ │ src/icons/ │ │ │ │
|
||||
│ │ brands / inference / │ │ │ │
|
||||
│ │ extracted (cc-switch)│ │ │ │
|
||||
│ │ src/i18n/{zh,en}.ts │ │ │ │
|
||||
│ │ src/types/{...} │ │ │ │
|
||||
│ └──────────────────────────┘ └──────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└──────────────────┼──────────────────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ SQLite │ │ Agent 原生配置 │
|
||||
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
|
||||
│ kimi-switch.db │ │ │ └─ config.toml │
|
||||
│ (元数据 + 兜底) │ │ └─ ~/.pi/agent/ │
|
||||
│ + localStorage │ │ ├─ models.json │
|
||||
│ (主题/语言/检查) │ │ └─ settings.json │
|
||||
└──────────────────────┘ └──────────────────────────┘
|
||||
▲
|
||||
│
|
||||
┌──────────────────────────────┐
|
||||
│ models.dev 快照(前端) │
|
||||
│ src/lib/models-dev.json │
|
||||
│ + 脚本 scripts/fetch-models- │
|
||||
│ dev.mjs(可选定时刷新) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
|
||||
- 对 Kimi Code,`config.toml` 是供应商/模型数据的**权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)
|
||||
- SQLite 只存 Kimi Switch 专有元数据(备注、官网、每个供应商记住的默认模型),并在 `config.toml` 不完整时兜底
|
||||
- 加载时以 `config.toml` 为准,因此通过 `/provider` 在 CLI 端增/改/删的供应商都会被正确反映,切换不再覆盖丢失
|
||||
- `raw_other` 字段透传未知键(含 `[oauth]` 段),保证前后往返不丢字段
|
||||
- Pi 的行为不变:切换时仍只写活跃供应商到 `models.json`
|
||||
- **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖
|
||||
- **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底
|
||||
- **`raw_other` 透传未知字段**:含 `[oauth]` 段,前后往返不丢字段
|
||||
- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name`
|
||||
- **启动版本与配置目录**:`KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` 覆盖 Kimi Code / Pi 的目录;Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置))
|
||||
|
||||
## 功能详情
|
||||
|
||||
### 供应商优先级与切换
|
||||
|
||||
- **新增/激活自动提升到列表最前**:添加或切换供应商时会把它移到 `db.providers` 排序的第一位,UI 直接渲染最新顺序
|
||||
- **切换只改 `default_model`**:所有供应商写入 `config.toml`,但只有被切换的成为 `default_model`,与 Kimi Code CLI `/provider` 命令一致
|
||||
- **不会覆盖丢失**:通过 `/provider` 在 CLI 端增/改/删的供应商,加载时以 `config.toml` 为准,下一次保存时全部回写
|
||||
|
||||
### 图标系统
|
||||
|
||||
- 来自 cc-switch 的 `src/icons/extracted/` 库(100+ 主流供应商品牌图标)
|
||||
- 找不到精确匹配的供应商时,按命名规则生成**首字母默认图标**(例如 `kimi-code` → `K`,`deepseek-v4` → `D`)
|
||||
- `IconPicker` 内置 brands / inference 分类筛选;选中的图标保存到 `provider.icon` 字段
|
||||
|
||||
### 连通性测试
|
||||
|
||||
- 调 Rust 端 `test_connectivity` 命令:GET `provider.base_url`,任何 HTTP 响应 = 可达
|
||||
- 返回 `{ ok, latency_ms, status_code, error }`
|
||||
- 前端以**彩色气泡**形式内联展示(绿/橙/红 + 毫秒),6 秒后自动消失
|
||||
- 测速气泡位置在"使用中/切换"按钮之前,不挡眼
|
||||
|
||||
### 重复供应商
|
||||
|
||||
- 点击"复制"图标按钮 → 深拷贝供应商 + 全部模型
|
||||
- `provider.name` 加 `-copy` 后缀,模型自动改 key 为 `xxx-copy`
|
||||
- 立即持久化到 SQLite + `config.toml`,toast 提示
|
||||
|
||||
### 自动上下文与能力
|
||||
|
||||
- 拉取模型时 `max_context_size` 三级优先级:**API 响应 > models.dev ref > 正则兜底**
|
||||
- models.dev ref → `capabilities = ["thinking","image_in","video_in","tool_use"]`(按字段真值选)
|
||||
- UI 上**只暴露"思考"复选框**(其它能力自动写入但不便手动编辑——保持与 Kimi Code 语义一致:能力只追加不能移除)
|
||||
- `always_thinking` 只能手动加(models.dev 推导不出来),但当前 UI 屏蔽;需要时改 `config.toml` 即可
|
||||
|
||||
### 能力 vs 思考开关
|
||||
|
||||
- **模型能力**(`capabilities`)= "能不能":决定模型是否支持思考,没声明 `thinking` 即使全局开关开着也不生效
|
||||
- **全局 `[thinking]`**(设置面板)= "要不要":新会话默认开/关、强度(low/medium/high/max)、保留思考内容
|
||||
- `always_thinking` 锁死为开,忽略全局开关
|
||||
- 全局配置仅 Kimi Code 生效
|
||||
|
||||
### 用量仪表盘
|
||||
|
||||
- 8 项 KPI、每日趋势(按模型分色堆叠,**贴底布局**)、全年热力图(按 Token 量 5 级着色)、最近请求(30 条/页)
|
||||
- 双击每日趋势柱状图 → 弹出 `DailyDetailModal` 显示每个模型的用量分布
|
||||
- 数据源:`src-tauri/src/dashboard.rs` + `src/hooks/useDashboard.ts`
|
||||
|
||||
### 会话管理
|
||||
|
||||
- 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选
|
||||
- 流式逐行预览(20MB 字节上限,500 字符折叠可展开)
|
||||
- 归档 / 取消归档 / 批量删除
|
||||
- 早期版本的"闪崩"已通过流式读取 + 限制解决
|
||||
|
||||
### 设置面板
|
||||
|
||||
- **主题**:深色 / 浅色 / 跟随系统(`useTheme`,持久化到前端 `localStorage`)
|
||||
- **语言**:简体中文 / English
|
||||
- **版本**:当前版本 + 上次检查时间
|
||||
- **更新检查**:启动自动 + 每 8 小时周期 + 手动检查;下载带进度条;下载完成后引导安装
|
||||
|
||||
### 窗口与托盘
|
||||
|
||||
- **最小化**:保留任务栏按钮,不再被劫持到托盘
|
||||
- **关闭按钮(X)**:`prevent_close` + `hide` → 隐藏到托盘而非退出
|
||||
- **托盘菜单**:显示 / 退出
|
||||
- **托盘左键**:始终 `show + unminimize + focus`(不再 toggle hide)
|
||||
- **第二次启动**:`single_instance` 插件接住,回前台 + 聚焦
|
||||
|
||||
## 支持的供应商类型
|
||||
|
||||
@@ -121,16 +256,18 @@ Kimi Switch 提供统一的图形界面:
|
||||
|
||||
| 文件 | 用途 | 备份 |
|
||||
| --- | --- | --- |
|
||||
| `%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) | — |
|
||||
| `%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 天 |
|
||||
| WebView2 `localStorage` | 前端状态:`kimi-switch-theme` / `kimi-switch-lang` / `kimi-switch-last-update-check` / `kimi-switch-agent` / `kimi-switch-dashboard-range`(主题 / 语言 / 上次更新检查 / 上次选中 Agent / 仪表盘时间范围) | — |
|
||||
| `src/lib/models-dev.json` | models.dev `api.json` 快照(前端内置) | — |
|
||||
|
||||
环境变量覆盖:
|
||||
|
||||
- `KIMI_CODE_HOME` 覆盖 Kimi Code 配置目录(默认 `~/.kimi-code`)
|
||||
- `PI_CODING_AGENT_DIR` 覆盖 Pi Agent 配置目录(默认 `~/.pi/agent`)
|
||||
- Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,**暂无环境变量覆盖**
|
||||
|
||||
## 快速上手
|
||||
|
||||
@@ -142,7 +279,7 @@ Kimi Switch 提供统一的图形界面:
|
||||
| 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 打包(首次构建自动下载) |
|
||||
| WiX Toolset 3.14 | `src-tauri/wix314-binaries/` | MSI 打包(首次构建自动下载) |
|
||||
|
||||
### 安装
|
||||
|
||||
@@ -156,7 +293,7 @@ npm install
|
||||
npm run tauri-dev
|
||||
```
|
||||
|
||||
会同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口,修改前端代码会自动刷新,修改 Rust 代码会重新编译。
|
||||
同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口;前端热重载,Rust 自动重编译。
|
||||
|
||||
### 仅前端开发(无 Tauri 窗口)
|
||||
|
||||
@@ -172,87 +309,134 @@ npm run dev
|
||||
|
||||
```
|
||||
.
|
||||
├── src/ # React 前端
|
||||
│ ├── App.tsx # 主组件,路由 ProviderList/ProviderEdit
|
||||
│ ├── main.tsx # React 入口 + ErrorBoundary + I18nProvider
|
||||
├── src/ # React 前端
|
||||
│ ├── App.tsx # 主组件,路由 ProviderList/ProviderEdit + 仪表盘/会话
|
||||
│ ├── main.tsx # React 入口 + ErrorBoundary + I18nProvider
|
||||
│ ├── components/
|
||||
│ │ ├── ProviderList.tsx # 供应商列表 + 切换按钮
|
||||
│ │ ├── ProviderEdit.tsx # 编辑供应商 + 模型映射 + JSON 直编
|
||||
│ │ └── AgentSettingsPanel.tsx# 全局设置(思考/循环/权限/钩子)
|
||||
│ │ ├── ProviderList.tsx # 供应商列表 + 切换 / 复制 / 测试连通 / 编辑 / 删除
|
||||
│ │ ├── ProviderEdit.tsx # 供应商编辑 + 模型映射 + JSON 直编 + 能力
|
||||
│ │ ├── AgentSettingsPanel.tsx # Kimi Code 全局设置(思考/循环/权限/钩子)
|
||||
│ │ ├── SettingsModal.tsx # 设置弹窗(主题 / 语言 / 版本 / 检查更新)
|
||||
│ │ ├── ProviderIcon.tsx # 供商品牌图标(带首字母兜底)
|
||||
│ │ ├── IconPicker.tsx # 图标选择器(brands / inference 分类)
|
||||
│ │ ├── dashboard/ # 用量仪表盘
|
||||
│ │ │ ├── DashboardPage.tsx
|
||||
│ │ │ ├── DailyBars.tsx
|
||||
│ │ │ ├── DailyDetailModal.tsx
|
||||
│ │ │ └── Heatmap.tsx
|
||||
│ │ └── sessions/ # 会话管理
|
||||
│ │ └── SessionsPage.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useConfig.ts # 加载/保存配置 hook
|
||||
│ │ ├── useConfig.ts # 加载/保存配置
|
||||
│ │ ├── useDashboard.ts # 仪表盘数据
|
||||
│ │ ├── useSessions.ts # 会话数据
|
||||
│ │ ├── useTheme.ts # 主题切换
|
||||
│ │ └── useUpdateCheck.ts # 版本检查 + 下载
|
||||
│ ├── lib/
|
||||
│ │ ├── agent-settings.ts # AgentSettings 解析/序列化
|
||||
│ │ └── model-defaults.ts # 模型默认上下文大小
|
||||
│ ├── types/index.ts # Provider/Model/Config 类型定义
|
||||
│ │ ├── agent-settings.ts # AgentSettings 解析/序列化
|
||||
│ │ ├── model-defaults.ts # 模型默认上下文大小
|
||||
│ │ ├── models-dev.ts # models.dev 快照查找 + 能力映射
|
||||
│ │ ├── models-dev.json # 内置快照
|
||||
│ │ └── dashboard-format.ts # 仪表盘格式化
|
||||
│ ├── icons/
|
||||
│ │ ├── brands.ts # 品牌图标入口
|
||||
│ │ ├── inference.ts # 推理服务图标
|
||||
│ │ └── extracted/ # 来自 cc-switch 的图标库
|
||||
│ │ ├── index.ts
|
||||
│ │ └── metadata.ts
|
||||
│ ├── types/
|
||||
│ │ ├── index.ts # Provider/Model/Config
|
||||
│ │ ├── dashboard.ts
|
||||
│ │ ├── sessions.ts
|
||||
│ │ └── icon.ts
|
||||
│ ├── i18n/
|
||||
│ │ ├── zh.ts # 中文翻译(143 条目)
|
||||
│ │ ├── en.ts # 英文翻译
|
||||
│ │ └── index.tsx # useTranslation hook + Provider
|
||||
│ └── index.css # Tailwind 入口
|
||||
│ │ ├── zh.ts # 中文翻译(源)
|
||||
│ │ ├── en.ts # 英文翻译
|
||||
│ │ └── index.tsx # useTranslation hook + Provider
|
||||
│ └── index.css # Tailwind 入口
|
||||
│
|
||||
├── src-tauri/ # Rust 后端
|
||||
├── 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)
|
||||
│ │ ├── lib.rs # Tauri Builder + 托盘 + 窗口事件 + invoke_handler
|
||||
│ │ ├── main.rs # 二进制入口
|
||||
│ │ ├── commands.rs # ~14 个 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 # 配置校验
|
||||
│ │ └── dashboard.rs # 会话/用量数据聚合
|
||||
│ ├── capabilities/ # Tauri 权限声明
|
||||
│ ├── icons/ # 应用图标(脚本生成)
|
||||
│ └── tauri.conf.json # Tauri 配置(窗口/打包/CSP)
|
||||
│
|
||||
├── scripts/generate-icons.py # 从 SVG 生成各尺寸图标
|
||||
├── public/kimi.svg # 应用图标源(蓝紫渐变 π)
|
||||
└── docs/superpowers/ # 设计规范与实施计划
|
||||
├── scripts/
|
||||
│ ├── fetch-models-dev.mjs # 刷新 models-dev.json 快照
|
||||
│ └── generate-icons.py # 从 SVG 生成各尺寸图标
|
||||
├── public/kimi.svg # 应用图标源(蓝紫渐变 π)
|
||||
└── docs/
|
||||
├── screenshots/ # README 引用的界面截图
|
||||
└── superpowers/ # 设计规范与实施计划
|
||||
```
|
||||
|
||||
### Tauri Commands(前端 ↔ 后端)
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `load_agent_config_command(agent)` | 加载配置:Kimi Code 以 `config.toml` 为权威数据源,SQLite 补充元数据;Pi 优先 SQLite |
|
||||
| `load_agent_config_command(agent)` | 加载配置:Kimi Code 以 `config.toml` 为权威 + SQLite 补充;Pi 优先 SQLite |
|
||||
| `save_agent_config_command(agent, config)` | 保存到 SQLite;Kimi Code 同时写入 `config.toml` |
|
||||
| `activate_agent_config_command(agent)` | 全量写入 Agent 原生配置(Kimi Code 写全部供应商,`default_model` 决定生效项;Pi 仅写活跃供应商) |
|
||||
| `open_agent_config_dir(agent)` | 用系统资源管理器打开 Agent 配置目录 |
|
||||
| `open_agent_config_dir(agent)` | 打开 Agent 配置目录 |
|
||||
| `get_app_version()` | 返回 `Cargo.toml` 版本号 |
|
||||
| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步) |
|
||||
| `debug_log(message)` | 把前端日志打到 stderr(开发用) |
|
||||
| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步,分页) |
|
||||
| `test_connectivity(provider)` | GET `base_url` 测连通性,返回 `{ ok, latency_ms, status_code, error }` |
|
||||
| `get_app_setting(key)` | 读取 App 设置(主题 / 语言 / 上次更新检查时间) |
|
||||
| `set_app_setting(key, value)` | 写入 App 设置 |
|
||||
| `check_for_update()` | 检查 GitHub releases,返回版本号 + 资产 URL |
|
||||
| `download_update(url, path)` | 流式下载更新包,发 `download-progress` / `download-complete` 事件 |
|
||||
| `open_installer(path)` | 用系统 shell 打开下载好的安装包 |
|
||||
| `dashboard::get_paths()` / `get_prices()` / `get_summary()` / `list_sessions()` / `archive_session()` / `unarchive_session()` / `delete_session()` / `delete_workspace()` / `get_session_preview()` | 用量与会话相关 |
|
||||
| `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 键
|
||||
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` 加映射
|
||||
5. `src/components/ProviderEdit.tsx` 的 API 格式下拉加选项
|
||||
6. `src/i18n/{zh,en}.ts` 加 i18n 键
|
||||
|
||||
### 添加新的 Tauri Command
|
||||
### 添加新 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` 里加权限(如需访问文件系统)
|
||||
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` 加权限(如需文件/网络)
|
||||
|
||||
### 添加新模型能力
|
||||
|
||||
1. `src/components/ProviderEdit.tsx` 的 `KNOWN_CAPABILITIES` 加新键
|
||||
2. `CAPABILITY_LABELS` 加 i18n 映射
|
||||
3. `src/i18n/{zh,en}.ts` 加翻译
|
||||
4. `src/lib/models-dev.ts` 的 `capabilitiesFromRef` 加推导(若可从 models.dev 推)
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
| 快捷键 | 作用 |
|
||||
| --- | --- |
|
||||
| `Ctrl + S` | 保存当前修改到 SQLite + config.toml(Kimi Code) |
|
||||
| `Ctrl + R` | 重新读取配置(未保存时会提示) |
|
||||
| `Ctrl + S` | 保存当前修改到 SQLite + `config.toml`(Kimi Code) |
|
||||
| `Ctrl + R` | 重新读取配置(未保存时提示) |
|
||||
| `Ctrl + O` | 打开当前 Agent 的配置目录 |
|
||||
|
||||
## 国际化
|
||||
|
||||
- 翻译源文件:`src/i18n/zh.ts`(源)和 `src/i18n/en.ts`(目标)
|
||||
- 新增 key 时**先在 `zh.ts` 里加**,`en.ts` 的 `Record<TranslationKey, string>` 类型会自动校验缺失条目(编译时报错)
|
||||
- 运行时通过右上角下拉切换语言,记忆在组件内 state(未持久化到 SQLite)
|
||||
- 翻译源:`src/i18n/zh.ts`(源) + `src/i18n/en.ts`(目标)
|
||||
- 新增 key **先在 `zh.ts` 里加**,`en.ts` 的 `Record<TranslationKey, string>` 类型会自动校验缺失条目(编译时报错)
|
||||
- `useTranslation` hook 暴露 `{ t, lang, setLang }`
|
||||
- 运行时切换由 `SettingsModal` 提供,持久化到前端 `localStorage`(`kimi-switch-lang`)
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -266,17 +450,24 @@ cargo test
|
||||
当前覆盖:
|
||||
|
||||
- `kimi_code_io::tests` — TOML 导入/导出往返
|
||||
- `pi_io::tests` — JSON 往返,包括 advanced fields(headers/compat/cost/extra)
|
||||
- `pi_io::tests` — JSON 往返,含 advanced fields(headers/compat/cost/extra)
|
||||
- `validators::tests` — 配置校验
|
||||
- `dashboard::tests` — 用量聚合与时区处理
|
||||
|
||||
### 前端
|
||||
|
||||
暂无自动化测试。建议手动验证清单:
|
||||
暂未自动化测试,建议手动验证清单:
|
||||
|
||||
- [ ] 切换 Agent 时配置互不污染
|
||||
- [ ] 删除供应商时关联模型也清掉
|
||||
- [ ] 切换供应商后默认模型正确回填
|
||||
- [ ] 重命名供应商后引用它的模型也跟着改
|
||||
- [ ] JSON 直编后 `raw_other` 不丢字段
|
||||
- [ ] 添加新供应商自动提升到列表最前
|
||||
- [ ] 重复供应商生成的 key 不冲突
|
||||
- [ ] 测试连通性气泡在 6 秒后自动消失
|
||||
- [ ] 关闭 X 隐藏到托盘;最小化保留任务栏
|
||||
- [ ] 主题切换(深色 / 浅色 / 跟随系统)即时生效
|
||||
- [ ] 切换语言后 UI 文案立即更新
|
||||
- [ ] 启动检查更新 → 下载 → 引导安装
|
||||
- [ ] 拉取模型时 `max_context_size` 自动填充
|
||||
- [ ] 切换供应商后 Kimi Code 会话 `/reload` 立即生效
|
||||
|
||||
## 打包发布
|
||||
|
||||
@@ -286,20 +477,12 @@ npm run tauri-build
|
||||
|
||||
产物位置:
|
||||
|
||||
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.3.0_x64_en-US.msi`
|
||||
```
|
||||
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
|
||||
```
|
||||
|
||||
Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。
|
||||
|
||||
### 发布版本
|
||||
|
||||
**v0.3.0** — 新增用量仪表盘与会话管理功能(基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) 移植,感谢原作者 [JochenYang](https://github.com/JochenYang) 的开源贡献):
|
||||
|
||||
- 用量仪表盘:8 项 KPI、每日趋势图(按模型分色堆叠柱状图,双击查看模型分布弹窗)、全年热力图(按 Token 量 5 级着色,鼠标悬浮 tooltip)、最近请求翻页(30 条/页)
|
||||
- 会话管理:按工作区隔离浏览、预览会话内容(流式逐行读取防崩溃,20MB 字节上限,500 字符折叠可展开)、归档/取消归档/批量删除
|
||||
- 时间线修复:`today` 范围、每日热力图和 `day_key` 分桶改用本地时区日历日,UTC+8 用户跨零点后不再数据错位
|
||||
- 隐藏 Pi 选项:左侧导航栏 Pi 入口已移除(代码保留,仅 UI 隐藏)
|
||||
- 支持供应商:Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI
|
||||
|
||||
### 首次打包
|
||||
|
||||
首次运行会下载:
|
||||
@@ -307,43 +490,95 @@ Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已
|
||||
- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/`
|
||||
- WebView2 bootstrapper → 打包进 MSI
|
||||
|
||||
### 常见构建问题
|
||||
|
||||
- **`os error 5`**(WiX light 步骤):先 `taskkill //F //IM kimiswitch.exe` 杀掉占用进程后重试
|
||||
- **prebuild 拉 models.dev 超时**:属正常,本地快照兜底不影响构建
|
||||
|
||||
## 发布历史
|
||||
|
||||
### v0.5.1(最新)
|
||||
|
||||
- 窗口/托盘:最小化保留任务栏;关闭 X 隐藏到托盘;托盘左键始终显示并聚焦
|
||||
- 模型用量表:模型列宽度自适应,长模型名完整显示不再截断
|
||||
- 模型能力编辑:只保留"思考",其余能力仍由 models.dev 自动推导
|
||||
- 模型映射表格:行分隔线改为主题感知,修复浅色模式突兀问题
|
||||
|
||||
### v0.5.0
|
||||
|
||||
- 供应商操作图标化(lucide-react):启用 / 编辑 / 复制 / 测试连通 / 删除
|
||||
- 重复供应商(深拷贝供应商 + 全部模型)
|
||||
- 连通性测试(绿/橙/红气泡 + 6 秒自动消失)
|
||||
- 每日趋势柱状图锚定到面板底部
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- 模型发现分页(OpenAI / Anthropic / Google),减少大列表命中限制
|
||||
|
||||
### v0.4.0
|
||||
|
||||
- 完善的图标系统(移植自 cc-switch 图标库)
|
||||
- 设置面板(主题、语言)
|
||||
- 启动版本检查
|
||||
|
||||
### v0.3.0
|
||||
|
||||
[移植自 kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard):
|
||||
|
||||
- 用量仪表盘:8 KPI、每日趋势、全年热力图、最近请求翻页
|
||||
- 会话管理:按工作区浏览、预览、归档、批量删除
|
||||
- 时区修复:`today` / 热力图 / `day_key` 改用本地日历日
|
||||
- Pi 选项隐藏
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 切换供应商后 Kimi Code 没生效?**
|
||||
A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~/.kimi-code/config.toml`)。应用会在 UI 上提示。
|
||||
|
||||
**Q: 切换会覆盖其他供应商吗?**
|
||||
A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。
|
||||
|
||||
**Q: 新增/激活的供应商位置怎么变?**
|
||||
A: 自动提升到列表最前,UI 立即反映。
|
||||
|
||||
**Q: 为什么能力只显示"思考"?**
|
||||
A: Kimi Code 语义上 `capabilities` 只追加不能移除,models.dev 已有自动推导,再手动暴露只会增加误操作风险。需要声明 `always_thinking` 等特殊值时直接编辑 `config.toml` 即可。
|
||||
|
||||
**Q: 改了配置但忘了保存就关了窗口?**
|
||||
A: 关闭窗口前会弹原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
|
||||
A: 关闭窗口前有原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
|
||||
|
||||
**Q: 怎么备份/迁移我的配置?**
|
||||
A: 对 Kimi Code,`config.toml` 本身就是全量配置的权威来源,直接备份它即可;SQLite 存额外的备注/官网等元数据,需要时一并备份 `kimi-switch.db`。对 Pi,备份 `kimi-switch.db`。
|
||||
A: 对 Kimi Code,`config.toml` 本身就是全量权威,直接备份即可;`kimi-switch.db` 存备注 / 官网 / 排序,迁移时一并备份。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不是独立文件,迁移时通常无需单独处理。
|
||||
|
||||
**Q: Vertex AI 为什么不能拉取模型列表?**
|
||||
A: Vertex 需要 GCP project/location 凭证,当前实现留了 TODO,等 GCP SDK 集成后再补。
|
||||
A: Vertex 需要 GCP project / location 凭证,当前实现留 TODO,等 GCP SDK 集成后补。
|
||||
|
||||
**Q: 支持 macOS / Linux 吗?**
|
||||
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `msi`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
|
||||
|
||||
**Q: 怎么改主题 / 语言?**
|
||||
A: 顶部齿轮 → 设置弹窗 → 主题 / 语言。保存到前端 `localStorage`(WebView2),重启后保持。
|
||||
|
||||
**Q: 把窗口关了怎么再打开?**
|
||||
A: 关闭按钮(X)已改为隐藏到托盘。点击托盘图标(菜单栏 / 系统托盘)即可拉回;点击任务栏图标正常切换最小化/还原。
|
||||
|
||||
**Q: 检查更新是怎么触发的?**
|
||||
A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网络时静默失败,不弹错)。也可手动:设置弹窗 → 版本 → 检查更新。下载有进度条,下载完成后会有"打开安装包"按钮。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- API Key 以明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存**
|
||||
- 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/`
|
||||
|
||||
## 致谢
|
||||
|
||||
用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。原始项目实现了 Kimi Code CLI 的本地 Token 用量统计与会话管理,本项目的 Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)以及会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。
|
||||
用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)、会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。
|
||||
|
||||
供应商品牌图标资源与图标选择器实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),本项目复制了其 `src/icons/extracted/` 图标库与 `IconPicker` 交互设计,感谢原作者的开源贡献。
|
||||
供应商品牌图标资源(`src/icons/extracted/`)与图标选择器(`src/components/IconPicker.tsx`)参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),感谢原作者的开源贡献。
|
||||
|
||||
---
|
||||
|
||||
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
|
||||
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
|
||||
+364
-132
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
> A Windows desktop **Kimi Code CLI** configuration manager — unify multi-provider LLM setup, models, icons, connectivity testing, usage analytics, and update checks in one place.
|
||||
|
||||
**English** | [中文](./README.md)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
|
||||
[](./LICENSE)
|
||||
|
||||
---
|
||||
@@ -17,7 +17,9 @@
|
||||
|
||||
- [What Is This](#what-is-this)
|
||||
- [Key Features](#key-features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Feature Details](#feature-details)
|
||||
- [Supported Provider Types](#supported-provider-types)
|
||||
- [Data Storage Locations](#data-storage-locations)
|
||||
- [Getting Started](#getting-started)
|
||||
@@ -26,82 +28,212 @@
|
||||
- [Internationalization](#internationalization)
|
||||
- [Testing](#testing)
|
||||
- [Build & Release](#build--release)
|
||||
- [Release History](#release-history)
|
||||
- [FAQ](#faq)
|
||||
- [Security Notes](#security-notes)
|
||||
- [Credits](#credits)
|
||||
|
||||
---
|
||||
|
||||
## What Is This
|
||||
|
||||
**Kimi Switch** is a Windows desktop configuration tool built for LLM CLI users. It solves two pain points:
|
||||
**Kimi Switch** is a Windows desktop configuration tool built for [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code) users. It pulls the hand-editing of `~/.kimi-code/config.toml` into a GUI and adds a pile of conveniences the CLI itself doesn't ship:
|
||||
|
||||
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 updates `default_model` in the target agent's native config while preserving all providers
|
||||
- One-click model-list fetching (OpenAI / Anthropic / Google GenAI)
|
||||
- A `/reload` hint after switching so Kimi Code sessions take effect immediately
|
||||
- **Unified multi-provider management** — Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI in one view; newly added providers are **automatically promoted to the top of the list**, and switching never overwrites or loses them
|
||||
- **One-click model discovery** — fetch the available model list from the provider API; display names, context size, and capabilities **prefer the models.dev cache** (backend/UI fallback for missing fields)
|
||||
- **Connectivity testing** — GET `base_url` (cc-switch semantics), with a coloured latency bubble (green / orange / red) that auto-dismisses in 6 seconds
|
||||
- **Duplicate provider** — deep-copy an existing provider along with all its models in one click; the key becomes `xxx-copy`
|
||||
- **Icon system** — 100+ first-party provider brand icons, adapted from cc-switch; unmatched providers get a deterministic initial-letter placeholder
|
||||
- **Usage dashboard** — ported from kimicode-dashboard: KPIs / heatmap / per-model daily trend stacked bars / paginated recent requests
|
||||
- **Session manager** — per-workspace browsing, preview, archive, and bulk delete for Kimi Code sessions
|
||||
- **Update check** — auto-check on launch (toggleable) and one-click download + install
|
||||
- **Theme / language** — dark / light / follow-system themes; Simplified Chinese / English
|
||||
- **Windows polish** — single instance; minimize keeps the taskbar button; close hides to tray
|
||||
|
||||
## 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 `*` |
|
||||
| **Multi-agent** | Primary target is Kimi Code; Pi code is preserved but hidden from the UI |
|
||||
| **Icon system** | 100+ first-party icons + initial-letter fallback; picker grouped by brands / inference |
|
||||
| **Quick switch** | Added/activated providers move to the top of the list; switching only changes `default_model` and never drops other providers |
|
||||
| **Connectivity test** | Real `base_url` latency, coloured bubble (green / orange / red), auto-dismiss in 6 seconds |
|
||||
| **Duplicate provider** | Deep-copy a provider + all its models; key auto-suffixed to `xxx-copy` |
|
||||
| **Iconified actions** | Activate / Edit / Duplicate / Test Connectivity / Delete via lucide-react |
|
||||
| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, 1M-context flag, and capabilities |
|
||||
| **Auto context size** | On model fetch: **API response > models.dev ref > regex fallback** — three-tier priority |
|
||||
| **Auto capabilities** | `image_in / video_in / tool_use` all derived from models.dev; UI only exposes `thinking` as a manual toggle |
|
||||
| **Global settings** | Full `[thinking]` table (enabled / effort / keep); Kimi Code only |
|
||||
| **Raw JSON editing** | Power users can hand-edit the full config; unknown fields pass through `raw_other` and never get dropped |
|
||||
| **i18n / Theme** | Simplified Chinese / English; dark / light / follow-system; persisted |
|
||||
| **Auto backup** | Before writing `config.toml`, backup with timestamp-based names kept 7 days |
|
||||
| **Shortcuts** | `Ctrl+S` save, `Ctrl+R` reload, `Ctrl+O` open config dir |
|
||||
| **Usage dashboard** | 8 KPIs, per-model stacked daily trend, full-year heatmap, paginated recent requests, double-click bars for per-model breakdown modal |
|
||||
| **Session manager** | Per-workspace browsing, archive/unarchive, bulk delete; streaming line-by-line preview (20MB cap + 500-char collapse) |
|
||||
| **Update check** | Auto-check on launch + every 8h periodic + manual; download progress bar; guided install after download |
|
||||
| **Unsaved-changes prompt** | Native `beforeunload` warning + `*` prefix in title bar |
|
||||
| **Window / tray** | Minimize keeps the taskbar button; close X hides to tray; tray left-click always shows + focuses |
|
||||
|
||||
## Screenshots
|
||||
|
||||
**Provider list** (light theme)
|
||||
|
||||

|
||||
|
||||
Active provider, default model, latency bubble, model count, and brand icons at a glance. Quick switch, copy, test connectivity, open website, edit, delete.
|
||||
|
||||
**Edit provider — basic info**
|
||||
|
||||

|
||||
|
||||
Provider name, notes, official URL, managed-provider toggle, API format, API key, and base URL.
|
||||
|
||||
**Edit provider — model mapping**
|
||||
|
||||

|
||||
|
||||
A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability (thinking only), default toggle, delete.
|
||||
|
||||
**Usage dashboard**
|
||||
|
||||

|
||||
|
||||
8 KPIs (requests, non-cached input, output, cache read / write / hit, total tokens, estimated cost) + full-year heatmap + per-model stacked daily trend (bottom-aligned) + per-model usage table + paginated recent requests.
|
||||
|
||||
**Session manager**
|
||||
|
||||

|
||||
|
||||
Per-workspace Kimi Code session browsing, active / archived / all filters; streaming line-by-line preview (20MB cap, 500-char collapse); archive/unarchive/bulk delete.
|
||||
|
||||
## 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 │
|
||||
└──────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Kimi Switch (Tauri v2) │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ src/App.tsx │ │ src-tauri/src/ │ │
|
||||
│ │ src/components/ │◄──►│ ├── lib.rs │ │
|
||||
│ │ ProviderList │ │ ├── main.rs │ │
|
||||
│ │ ProviderEdit │ │ ├── commands.rs │ │
|
||||
│ │ AgentSettingsPanel │ │ ├── db.rs │ │
|
||||
│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │
|
||||
│ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │
|
||||
│ │ IconPicker │ │ ├── config_io.rs │ │
|
||||
│ │ dashboard/ │ │ ├── models.rs │ │
|
||||
│ │ sessions/ │ │ ├── validators.rs │ │
|
||||
│ │ src/hooks/ │ │ ├── dashboard.rs │ │
|
||||
│ │ useConfig / Dashboard│ │ └── profile_manager.rs │ │
|
||||
│ │ / Sessions / Theme │ │ │ │
|
||||
│ │ / UpdateCheck │ │ │ │
|
||||
│ │ src/lib/ │ │ │ │
|
||||
│ │ src/icons/ │ │ │ │
|
||||
│ │ src/i18n/{zh,en}.ts │ │ │ │
|
||||
│ │ src/types/{...} │ │ │ │
|
||||
│ └──────────────────────────┘ └──────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└──────────────────┼──────────────────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ SQLite │ │ Agent native configs │
|
||||
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
|
||||
│ kimi-switch.db │ │ │ └─ config.toml │
|
||||
│ (metadata + fallback)│ │ └─ ~/.pi/agent/ │
|
||||
│ + localStorage │ │ ├─ models.json │
|
||||
│ (theme / lang / │ │ └─ settings.json │
|
||||
│ update-check) │ │ │
|
||||
└──────────────────────┘ └──────────────────────────┘
|
||||
▲
|
||||
│
|
||||
┌──────────────────────────────┐
|
||||
│ models.dev snapshot (FE) │
|
||||
│ src/lib/models-dev.json │
|
||||
│ + scripts/fetch-models- │
|
||||
│ dev.mjs (optional refresh) │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key design points**:
|
||||
|
||||
- For Kimi Code, `config.toml` is the **authoritative source** for provider/model data: all providers and models are always written in full, and `default_model` selects the active one (matching the CLI's native `/provider` behaviour)
|
||||
- SQLite stores only Kimi Switch-private metadata (notes, official URLs, per-provider remembered default model) and acts as a fallback when `config.toml` is incomplete
|
||||
- On load, `config.toml` takes precedence, so providers added/edited/deleted via the CLI's `/provider` are correctly reflected — switching no longer overwrites or loses them
|
||||
- The `raw_other` field passes unknown keys through untouched (including `[oauth]` blocks), so round-trips never lose fields
|
||||
- Pi behaviour is unchanged: switching still writes only the active provider to `models.json`
|
||||
- **`config.toml` is the authoritative source for Kimi Code**: all providers and models are always written in full; `default_model` selects the active one (matching the CLI's native `/provider` behaviour). Switching only changes `default_model`; newly added providers are auto-promoted to the top of the list and never get overwritten
|
||||
- **SQLite holds Kimi Switch-private metadata only**: notes, official URLs, per-agent remembered default model (the `settings` table), ordering. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not under `~/.kimi-switch`. It acts as a fallback when `config.toml` is incomplete
|
||||
- **`raw_other` passes unknown fields through untouched**, including `[oauth]` blocks — round-trips never drop fields
|
||||
- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name`
|
||||
- **Override env vars**: `KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` override the Kimi Code / Pi dirs; Kimi Switch's own data dir is fixed at `~/.kimi-switch` (no env override yet). See [Data Storage Locations](#data-storage-locations)
|
||||
|
||||
## Feature Details
|
||||
|
||||
### Provider priority & switching
|
||||
|
||||
- **Auto-promote to top on add/activate**: when you add or switch a provider, it moves to the first position in `db.providers`; the UI renders the freshest order immediately
|
||||
- **Switching only changes `default_model`**: all providers are written to `config.toml`, only the switched one becomes `default_model` — consistent with Kimi Code CLI's `/provider`
|
||||
- **Never overwrites**: providers added/edited/deleted via the CLI's `/provider` are correctly reflected on load (config.toml wins), and fully persisted on next save
|
||||
|
||||
### Icon system
|
||||
|
||||
- Library adapted from cc-switch (`src/icons/extracted/`, 100+ first-party brand icons)
|
||||
- Providers without an exact match get a deterministic **initial-letter placeholder** (e.g. `kimi-code` → `K`, `deepseek-v4` → `D`)
|
||||
- `IconPicker` has brands / inference groups; the chosen icon is saved to `provider.icon`
|
||||
|
||||
### Connectivity test
|
||||
|
||||
- Backend `test_connectivity` command: GET `provider.base_url`; any HTTP response = reachable
|
||||
- Returns `{ ok, latency_ms, status_code, error }`
|
||||
- Frontend renders a **coloured bubble** (green / orange / red + ms) inline; auto-dismisses in 6 seconds
|
||||
- The bubble sits before the "active / switch" button so it doesn't block the sight
|
||||
|
||||
### Duplicate provider
|
||||
|
||||
- Click the duplicate icon → deep-copy the provider + all its models
|
||||
- `provider.name` gets a `-copy` suffix; model keys auto-suffix to `xxx-copy`
|
||||
- Immediately persisted to SQLite + `config.toml`; toast confirmation
|
||||
|
||||
### Auto context size & capabilities
|
||||
|
||||
- On fetch, `max_context_size` priority: **API response > models.dev ref > regex fallback**
|
||||
- models.dev ref → `capabilities = ["thinking","image_in","video_in","tool_use"]` (filtered by truthy fields)
|
||||
- UI exposes **only the `thinking` checkbox**. Other capabilities are still auto-written but not manually editable — this matches Kimi Code's semantics: capabilities can only be added, never removed
|
||||
- `always_thinking` can only be set manually (models.dev can't derive it). The UI hides it; edit `config.toml` directly when needed
|
||||
|
||||
### Capability vs thinking switch
|
||||
|
||||
- **Model capability** (`capabilities`) = "can it": declares whether the model supports thinking. Without `thinking`, the global switch has no effect on this model
|
||||
- **Global `[thinking]`** (settings panel) = "do we want": new sessions default on/off, effort level (low/medium/high/max), and whether to keep thinking content
|
||||
- `always_thinking` forces thinking on, ignoring the global switch
|
||||
- Global settings apply to Kimi Code only
|
||||
|
||||
### Usage dashboard
|
||||
|
||||
- 8 KPIs, per-model stacked daily trend (**bottom-aligned layout**), full-year heatmap (5-level token colouring), paginated recent requests (30/page)
|
||||
- Double-click a daily bar → `DailyDetailModal` with per-model breakdown
|
||||
- Data source: `src-tauri/src/dashboard.rs` + `src/hooks/useDashboard.ts`
|
||||
|
||||
### Session manager
|
||||
|
||||
- Per-workspace Kimi Code session browsing, active / archived / all filters
|
||||
- Streaming line-by-line preview (20MB cap, 500-char collapse with expand)
|
||||
- Archive / unarchive / bulk delete
|
||||
- Earlier "instant crash" issues fixed via streaming reads + size limits
|
||||
|
||||
### Settings modal
|
||||
|
||||
- **Theme**: dark / light / follow-system (`useTheme`, persisted to frontend `localStorage`)
|
||||
- **Language**: Simplified Chinese / English
|
||||
- **Version**: current version + last-checked timestamp
|
||||
- **Update check**: launch auto + every 8h periodic + manual; download with progress bar; guided install after completion
|
||||
|
||||
### Window & tray
|
||||
|
||||
- **Minimize**: keeps the taskbar button (no longer hijacked to tray)
|
||||
- **Close (X)**: `prevent_close` + `hide` → hides to tray instead of quitting
|
||||
- **Tray menu**: Show / Quit
|
||||
- **Tray left-click**: always `show + unminimize + focus` (no toggle hide)
|
||||
- **Second launch**: `single_instance` plugin catches it, brings window to front and focuses
|
||||
|
||||
## Supported Provider Types
|
||||
|
||||
@@ -114,22 +246,24 @@ Kimi Switch provides a unified GUI:
|
||||
| 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.
|
||||
Credential precedence: `api_key` field > same-named key in `env` table.
|
||||
|
||||
## Data Storage Locations
|
||||
|
||||
| File | Purpose | Backup |
|
||||
| --- | --- | --- |
|
||||
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite database, holding metadata (notes/official URLs/remembered models) + migration fallback | — |
|
||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI's TOML config (**authoritative source, written on switch/save**) | `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) | — |
|
||||
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite — metadata (notes / official URLs / remembered default model + ordering) + fallback | — |
|
||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI TOML config (**authoritative source, written on switch/save**) | `backups/config.toml.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||
| `%USERPROFILE%\.pi\agent\models.json` | Pi provider + model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||
| `%USERPROFILE%\.pi\agent\settings.json` | Pi default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||
| WebView2 `localStorage` | Frontend state: `kimi-switch-theme` / `kimi-switch-lang` / `kimi-switch-last-update-check` / `kimi-switch-agent` / `kimi-switch-dashboard-range` (theme / language / last-update-check / last-selected agent / dashboard range) | — |
|
||||
| `src/lib/models-dev.json` | models.dev `api.json` snapshot (bundled with the frontend) | — |
|
||||
|
||||
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`)
|
||||
- Kimi Switch's own data dir is fixed at `~/.kimi-switch` (**no env override yet**)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -171,56 +305,95 @@ Good for pure UI debugging.
|
||||
|
||||
```
|
||||
.
|
||||
├── src/ # React frontend
|
||||
│ ├── App.tsx # Root component routing ProviderList/ProviderEdit
|
||||
│ ├── main.tsx # React entry + ErrorBoundary + I18nProvider
|
||||
├── src/ # React frontend
|
||||
│ ├── App.tsx # Root component routing provider list / edit / dashboard / sessions
|
||||
│ ├── 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)
|
||||
│ │ ├── ProviderList.tsx # Provider list + switch / duplicate / test / edit / delete
|
||||
│ │ ├── ProviderEdit.tsx # Edit provider + model mapping + raw JSON + capabilities
|
||||
│ │ ├── AgentSettingsPanel.tsx # Kimi Code global settings (thinking / loop / permissions / hooks)
|
||||
│ │ ├── SettingsModal.tsx # Settings modal (theme / language / version / update check)
|
||||
│ │ ├── ProviderIcon.tsx # Provider brand icons (with initial-letter fallback)
|
||||
│ │ ├── IconPicker.tsx # Icon picker (brands / inference groups)
|
||||
│ │ ├── dashboard/ # Usage dashboard
|
||||
│ │ │ ├── DashboardPage.tsx
|
||||
│ │ │ ├── DailyBars.tsx
|
||||
│ │ │ ├── DailyDetailModal.tsx
|
||||
│ │ │ └── Heatmap.tsx
|
||||
│ │ └── sessions/ # Session manager
|
||||
│ │ └── SessionsPage.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useConfig.ts # Config load/save hook
|
||||
│ │ ├── useConfig.ts # Config load/save
|
||||
│ │ ├── useDashboard.ts # Dashboard data
|
||||
│ │ ├── useSessions.ts # Session data
|
||||
│ │ ├── useTheme.ts # Theme switch
|
||||
│ │ └── useUpdateCheck.ts # Update check + download
|
||||
│ ├── lib/
|
||||
│ │ ├── agent-settings.ts # AgentSettings parse/serialize
|
||||
│ │ └── model-defaults.ts # Default model context sizes
|
||||
│ ├── types/index.ts # Provider/Model/Config type definitions
|
||||
│ │ ├── agent-settings.ts # AgentSettings parse/serialize
|
||||
│ │ ├── model-defaults.ts # Default model context sizes
|
||||
│ │ ├── models-dev.ts # models.dev snapshot lookup + capability mapping
|
||||
│ │ ├── models-dev.json # Bundled snapshot
|
||||
│ │ └── dashboard-format.ts # Dashboard formatting
|
||||
│ ├── icons/
|
||||
│ │ ├── brands.ts # Brand icon entry
|
||||
│ │ ├── inference.ts # Inference-service icons
|
||||
│ │ └── extracted/ # Icon library adapted from cc-switch
|
||||
│ │ ├── index.ts
|
||||
│ │ └── metadata.ts
|
||||
│ ├── types/
|
||||
│ │ ├── index.ts # Provider / Model / Config
|
||||
│ │ ├── dashboard.ts
|
||||
│ │ ├── sessions.ts
|
||||
│ │ └── icon.ts
|
||||
│ ├── i18n/
|
||||
│ │ ├── zh.ts # Chinese translations (143 keys)
|
||||
│ │ ├── en.ts # English translations
|
||||
│ │ └── index.tsx # useTranslation hook + Provider
|
||||
│ └── index.css # Tailwind entry
|
||||
│ │ ├── zh.ts # Chinese translations (source)
|
||||
│ │ ├── en.ts # English translations
|
||||
│ │ └── index.tsx # useTranslation hook + Provider
|
||||
│ └── index.css # Tailwind entry
|
||||
│
|
||||
├── src-tauri/ # Rust backend
|
||||
├── 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)
|
||||
│ │ ├── lib.rs # Tauri Builder + tray + window events + invoke_handler
|
||||
│ │ ├── main.rs # Binary entry
|
||||
│ │ ├── commands.rs # ~14 Tauri commands
|
||||
│ │ ├── db.rs # SQLite persistence
|
||||
│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml read/write
|
||||
│ │ ├── pi_io.rs # ~/.pi/agent/*.json read/write (preserved)
|
||||
│ │ ├── config_io.rs # File backup utilities
|
||||
│ │ ├── models.rs # Config / Provider / Model data structures
|
||||
│ │ ├── profile_manager.rs # Multi-profile management (stub)
|
||||
│ │ ├── validators.rs # Config validation
|
||||
│ │ └── dashboard.rs # Session / usage data aggregation
|
||||
│ ├── 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
|
||||
├── scripts/
|
||||
│ ├── fetch-models-dev.mjs # Refresh models-dev.json snapshot
|
||||
│ └── generate-icons.py # Generate all icon sizes from SVG
|
||||
├── public/kimi.svg # App icon source (blue-purple gradient π)
|
||||
└── docs/
|
||||
├── screenshots/ # Screenshots referenced by the README
|
||||
└── superpowers/ # Design specs & implementation plans
|
||||
```
|
||||
|
||||
### Tauri commands (frontend ↔ backend)
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `load_agent_config_command(agent)` | Load config: Kimi Code reads `config.toml` as the authoritative source, enriched with SQLite metadata; Pi reads SQLite first |
|
||||
| `load_agent_config_command(agent)` | Load config: Kimi Code reads `config.toml` as authoritative + SQLite metadata; Pi reads SQLite first |
|
||||
| `save_agent_config_command(agent, config)` | Save to SQLite; for Kimi Code also writes `config.toml` |
|
||||
| `activate_agent_config_command(agent)` | Write to the agent's native config (Kimi Code writes all providers — `default_model` picks the active one; Pi writes only the active provider) |
|
||||
| `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) |
|
||||
| `list_provider_models(provider)` | Fetch the model list from the provider API (async, paginated) |
|
||||
| `test_connectivity(provider)` | GET `base_url` connectivity test, returns `{ ok, latency_ms, status_code, error }` |
|
||||
| `get_app_setting(key)` | Read an app setting (theme / language / last-update-check) |
|
||||
| `set_app_setting(key, value)` | Write an app setting |
|
||||
| `check_for_update()` | Check GitHub releases; returns version + asset URL |
|
||||
| `download_update(url, path)` | Stream-download the update; emits `download-progress` / `download-complete` events |
|
||||
| `open_installer(path)` | Open the downloaded installer via the system shell |
|
||||
| `dashboard::get_paths()` / `get_prices()` / `get_summary()` / `list_sessions()` / `archive_session()` / `unarchive_session()` / `delete_session()` / `delete_workspace()` / `get_session_preview()` | Dashboard & session commands |
|
||||
| `debug_log(message)` | Forward frontend logs to stderr (dev use) |
|
||||
|
||||
### Adding a new provider type
|
||||
@@ -228,30 +401,38 @@ Good for pure UI debugging.
|
||||
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`
|
||||
4. Add a mapping in `kimi_code_io.rs::provider_type_for_kimi_type`
|
||||
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`
|
||||
2. Register it in `tauri::generate_handler![...]` 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)
|
||||
4. Add a permission in `src-tauri/capabilities/default.json` (if filesystem/network is needed)
|
||||
|
||||
### Adding a new model capability
|
||||
|
||||
1. Add a new key to `KNOWN_CAPABILITIES` in `src/components/ProviderEdit.tsx`
|
||||
2. Add an i18n mapping in `CAPABILITY_LABELS`
|
||||
3. Add translations in `src/i18n/{zh,en}.ts`
|
||||
4. Add derivation in `capabilitiesFromRef` in `src/lib/models-dev.ts` (if it can be derived from models.dev)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| `Ctrl + S` | Save current changes to SQLite + config.toml (Kimi Code) |
|
||||
| `Ctrl + S` | Save current changes to SQLite + `config.toml` (Kimi Code) |
|
||||
| `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)
|
||||
- Sources: `src/i18n/zh.ts` (source) + `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 missing entries at compile time
|
||||
- `useTranslation` hook exposes `{ t, lang, setLang }`
|
||||
- Runtime switch is provided by `SettingsModal`; persisted to frontend `localStorage` (`kimi-switch-lang`)
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -265,17 +446,24 @@ 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)
|
||||
- `pi_io::tests` — JSON round-trips, including advanced fields (headers / compat / cost / extra)
|
||||
- `validators::tests` — Config validation
|
||||
- `dashboard::tests` — Usage aggregation and timezone handling
|
||||
|
||||
### 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
|
||||
- [ ] Default model restores correctly after switching providers
|
||||
- [ ] Newly added providers auto-promote to the top of the list
|
||||
- [ ] Duplicate provider generates non-conflicting keys
|
||||
- [ ] Connectivity test bubble auto-dismisses after 6 seconds
|
||||
- [ ] Close X hides to tray; minimize keeps taskbar button
|
||||
- [ ] Theme switch (dark / light / follow-system) takes effect immediately
|
||||
- [ ] Language switch updates UI text in place
|
||||
- [ ] Update check → download → guided install
|
||||
- [ ] `max_context_size` auto-fills on model fetch
|
||||
- [ ] Kimi Code `/reload` picks up new config after switching
|
||||
|
||||
## Build & Release
|
||||
|
||||
@@ -285,20 +473,12 @@ npm run tauri-build
|
||||
|
||||
Output:
|
||||
|
||||
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.3.0_x64_en-US.msi`
|
||||
```
|
||||
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
|
||||
```
|
||||
|
||||
A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced.
|
||||
|
||||
### v0.3.0 Release Notes
|
||||
|
||||
New dashboard and session management features, ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT, © JochenYang). Many thanks to the original author for their open-source contribution.
|
||||
|
||||
- **Dashboard**: 8 KPI cards, daily trend chart (per-model stacked bars, double-click for per-model breakdown modal), full-year heatmap (5-level token coloring with hover tooltip), paginated recent requests (30/page)
|
||||
- **Session management**: browse by workspace, preview (streaming line-by-line read, 20MB byte cap, 500-char collapse with expand toggle), archive/unarchive/bulk delete
|
||||
- **Timezone fix**: `today` range, heatmap, and `day_key` now use the local calendar day — UTC+8 users no longer see empty data after midnight
|
||||
- **Pi option hidden**: the Pi tab has been removed from the navigation bar (code preserved, UI only)
|
||||
- **Supported providers**: Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI
|
||||
|
||||
### First build
|
||||
|
||||
The first run downloads:
|
||||
@@ -306,43 +486,95 @@ The first run downloads:
|
||||
- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/`
|
||||
- WebView2 bootstrapper → embedded into the MSI
|
||||
|
||||
### Common build issues
|
||||
|
||||
- **`os error 5`** (WiX light step): kill the process first with `taskkill //F //IM kimiswitch.exe`, then retry
|
||||
- **prebuild times out pulling models.dev**: expected; the local snapshot keeps the build going
|
||||
|
||||
## Release History
|
||||
|
||||
### v0.5.1 (latest)
|
||||
|
||||
- Window / tray: minimize keeps the taskbar button; close X hides to tray; tray left-click always shows + focuses
|
||||
- Usage table: model column auto-width, long names no longer truncated
|
||||
- Capability editor: only `thinking` exposed; other capabilities still auto-derived from models.dev
|
||||
- Model mapping table: theme-aware row divider, no more harsh line in light mode
|
||||
|
||||
### v0.5.0
|
||||
|
||||
- Iconified actions (lucide-react): activate / edit / duplicate / test / delete
|
||||
- Duplicate provider (deep-copy a provider + all its models)
|
||||
- Connectivity test (green / orange / red bubble + 6s auto-dismiss)
|
||||
- Daily trend bars anchored to the panel bottom
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- Model discovery pagination (OpenAI / Anthropic / Google), fewer large-list limits hit
|
||||
|
||||
### v0.4.0
|
||||
|
||||
- Polished icon system (library adapted from cc-switch)
|
||||
- Settings modal (theme / language)
|
||||
- Launch-time update check
|
||||
|
||||
### v0.3.0
|
||||
|
||||
Ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard):
|
||||
|
||||
- Dashboard: 8 KPIs, daily trend, full-year heatmap, paginated recent requests
|
||||
- Session manager: per-workspace browsing, preview, archive, bulk delete
|
||||
- Timezone fix: `today` / heatmap / `day_key` now use local calendar day
|
||||
- Pi option hidden from the UI
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Kimi Code didn't pick up the change after switching providers?**
|
||||
**Q: Switching providers didn't take effect in Kimi Code?**
|
||||
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: Does switching overwrite other providers?**
|
||||
A: No. Kimi Code's `config.toml` is always written with all providers, only `default_model` decides which one is active. This matches the CLI's native `/provider` behaviour.
|
||||
|
||||
**Q: Where do new / activated providers go?**
|
||||
A: They auto-promote to the top of the list; the UI reflects it immediately.
|
||||
|
||||
**Q: Why does the capability editor only show "thinking"?**
|
||||
A: Kimi Code's `capabilities` field can only be added to, not removed. models.dev already auto-derives the rest; exposing more manually would just invite mistakes. Edit `config.toml` directly when you need to set `always_thinking` or other special values.
|
||||
|
||||
**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: For Kimi Code, `config.toml` itself is the authoritative full config — back it up directly. SQLite holds extra metadata (notes, official URLs); back up `kimi-switch.db` too if you need those. For Pi, back up `kimi-switch.db`.
|
||||
A: For Kimi Code, `config.toml` itself is the authoritative full config — back it up directly. SQLite holds notes / official URLs / ordering, back it up too if you need that. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not a standalone file, so they usually don't need separate handling on migration.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
**Q: How do I change the theme / language?**
|
||||
A: Top-right gear → Settings modal → Theme / Language. Saved to frontend `localStorage` (WebView2), persists across restarts.
|
||||
|
||||
**Q: I closed the window — how do I get it back?**
|
||||
A: The close X hides to the tray. Click the tray icon (menu bar / system tray) to bring it back; clicking the taskbar icon toggles minimize/restore normally.
|
||||
|
||||
**Q: How is the update check triggered?**
|
||||
A: It silently checks once on launch, then automatically every 8 hours (silent failure with no network, no error popup). You can also trigger it manually: Settings modal → Version → Update check. Downloads have a progress bar; once done, an "Open installer" button appears.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API keys are stored in plaintext in the local SQLite database and agent native configs — **do not store them on shared computers**
|
||||
- API keys are stored in plaintext in local SQLite 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/`
|
||||
|
||||
## Credits
|
||||
|
||||
The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The original project implements local token usage analytics and session management for Kimi Code CLI. The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author.
|
||||
The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author.
|
||||
|
||||
Provider brand icons and the icon picker are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). This project copies its `src/icons/extracted/` icon library and `IconPicker` interaction design. Many thanks to the original author.
|
||||
The provider brand icon library (`src/icons/extracted/`) and the icon picker (`src/components/IconPicker.tsx`) are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). Many thanks to the original author.
|
||||
|
||||
---
|
||||
|
||||
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
|
||||
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
File diff suppressed because it is too large.
Load diff
@@ -1,270 +0,0 @@
|
||||
# i18n / Agent Switch / UI Cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete Chinese/English i18n, replace the target dropdown with a CC Switch-style icon selector, clean up the header/footer layout, fix backend validation so managed providers and half-empty models don’t block saving, and ensure the app only fetches model lists on explicit user action.
|
||||
|
||||
**Architecture:** Keep the existing Rust backend commands and `ConfigTarget` abstraction. Front-end state stays in `App.tsx`; UI pieces live in `src/components/`. Validation/sanitization moves into a small Rust helper that normalizes config before validation, so legacy or in-progress configs can be saved without surprising the user.
|
||||
|
||||
**Tech Stack:** Tauri v2 (Rust), React + Tailwind CSS, TypeScript, `toml_edit`, `serde_yaml`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix backend validation / sanitization
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/validators.rs`
|
||||
- Modify: `src-tauri/src/commands.rs`
|
||||
- Test: `src-tauri/tests/validators.rs`
|
||||
|
||||
- [ ] **Step 1.1: Treat `managed:*` providers as managed**
|
||||
|
||||
In `validators.rs`, add a helper and use it in the credential check:
|
||||
|
||||
```rust
|
||||
fn is_managed_provider(provider: &Provider) -> bool {
|
||||
provider.managed || provider.name.starts_with("managed:")
|
||||
}
|
||||
```
|
||||
|
||||
Replace the credential guard condition:
|
||||
|
||||
```rust
|
||||
if !is_managed_provider(provider) && !matches!(provider.provider_type, ProviderType::Vertexai) {
|
||||
// existing ambiguous / missing checks
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Sanitize config before validation**
|
||||
|
||||
In `commands.rs`, add a `sanitize_config(config: &mut Config)` function before `validate_config` is called in `save_config_command`:
|
||||
|
||||
```rust
|
||||
fn sanitize_config(config: &mut Config) {
|
||||
// Managed:* providers should carry the managed flag.
|
||||
for provider in config.providers.values_mut() {
|
||||
if provider.name.starts_with("managed:") {
|
||||
provider.managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let first_provider = config.providers.keys().next().cloned();
|
||||
for model in config.models.values_mut() {
|
||||
if model.provider.trim().is_empty() {
|
||||
if let Some(name) = &first_provider {
|
||||
model.provider = name.clone();
|
||||
}
|
||||
}
|
||||
if model.model.trim().is_empty() {
|
||||
model.model = model.alias.clone();
|
||||
}
|
||||
if model.max_context_size == 0 {
|
||||
model.max_context_size = 262_144;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call it at the top of `save_config_command`:
|
||||
|
||||
```rust
|
||||
pub fn save_config_command(
|
||||
app: tauri::AppHandle,
|
||||
target: ConfigTarget,
|
||||
mut config: Config,
|
||||
) -> Result<(), String> {
|
||||
sanitize_config(&mut config);
|
||||
if let Err(errors) = validate_config(&config) { ... }
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Add/update tests**
|
||||
|
||||
Add tests in `src-tauri/tests/validators.rs` for:
|
||||
1. `managed:kimi-code` with empty credentials passes.
|
||||
2. `glm-5` with empty provider/model/max_context_size is sanitized and then passes.
|
||||
|
||||
Run: `cd src-tauri && cargo test`
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove implicit model auto-fetch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ProviderEdit.tsx`
|
||||
|
||||
- [ ] **Step 2.1: Delete the auto-fetch `useEffect`**
|
||||
|
||||
Remove:
|
||||
|
||||
```tsx
|
||||
const [autoFetched, setAutoFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0 && !autoFetched && !discovering) {
|
||||
setAutoFetched(true);
|
||||
handleDiscover();
|
||||
}
|
||||
}, [models.length, autoFetched, discovering, handleDiscover]);
|
||||
```
|
||||
|
||||
Keep the explicit `获取模型列表` button and the discovered-models panel. Models are now fetched only when the user clicks the button.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Redesign header and footer
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
- Modify: `src/i18n/zh.ts`
|
||||
- Modify: `src/i18n/en.ts`
|
||||
- Modify: `src/index.css` (if needed for footer)
|
||||
|
||||
- [ ] **Step 3.1: Replace target dropdown with icon selector**
|
||||
|
||||
In `App.tsx`, replace the `<select>` for `target` with a row of icon buttons:
|
||||
|
||||
```tsx
|
||||
function AgentSelector({ target, onChange }: { target: ConfigTarget; onChange: (t: ConfigTarget) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const agents: { key: ConfigTarget; label: string; icon: string }[] = [
|
||||
{ key: "kimi", label: t("targetKimi"), icon: "K" },
|
||||
{ key: "omp", label: t("targetOmp"), icon: "O" },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-[#1f1f23] border border-[#2a2a2e] rounded-lg p-1">
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
type="button"
|
||||
title={a.label}
|
||||
onClick={() => onChange(a.key)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
target === a.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7] hover:bg-[#2a2a2e]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-5 h-5 rounded-full bg-current/20 flex items-center justify-center text-xs font-bold">
|
||||
{a.icon}
|
||||
</span>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use `<AgentSelector target={target} onChange={handleSetTarget} />` in the header.
|
||||
|
||||
- [ ] **Step 3.2: Move global actions to a footer bar**
|
||||
|
||||
Render a footer below `<main>`:
|
||||
|
||||
```tsx
|
||||
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && <span className="text-orange-400">● {t("unsavedChanges")}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button ... onClick={save} disabled={!dirty}>{t("saveConfig")}</button>
|
||||
<button ... onClick={refresh}>{t("reloadConfig")}</button>
|
||||
<button ... onClick={openConfigDir}>{t("openConfigDir")}</button>
|
||||
</div>
|
||||
</footer>
|
||||
```
|
||||
|
||||
Remove the corresponding buttons from the top header.
|
||||
|
||||
- [ ] **Step 3.3: Keep language switch in header**
|
||||
|
||||
Keep the language `<select>` in the top-right of the header, next to the agent selector.
|
||||
|
||||
- [ ] **Step 3.4: Add missing translation keys**
|
||||
|
||||
Add `unsavedChanges: "未保存修改"` / `"Unsaved changes"` to both translation files.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Improve adaptive / maximize-friendly layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ProviderList.tsx`
|
||||
- Modify: `src/components/ProviderEdit.tsx`
|
||||
|
||||
- [ ] **Step 4.1: ProviderList cards adapt to width**
|
||||
|
||||
Ensure the list container is `flex-1 overflow-auto` and cards use `w-full`. The right-side action buttons should wrap on narrow widths by adding `flex-wrap` to their container.
|
||||
|
||||
- [ ] **Step 4.2: ProviderEdit table scrolls horizontally**
|
||||
|
||||
Wrap the model mapping table in a `div` with `overflow-auto`:
|
||||
|
||||
```tsx
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">...</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
Change the two-column basic-info grid to collapse on small widths by using `grid-cols-1 md:grid-cols-2`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Complete zh/en i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/zh.ts`
|
||||
- Modify: `src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 5.1: Audit for hardcoded strings**
|
||||
|
||||
Search `src/components/` and `src/App.tsx` for any literal Chinese or English UI text not using `t()`. Add keys and replace them. Ensure keys added to both `zh.ts` and `en.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rename / update logo and icons
|
||||
|
||||
**Files:**
|
||||
- Modify: `index.html`
|
||||
- Create/overwrite: `public/kimi.svg`
|
||||
- Create/overwrite: `src-tauri/icons/32x32.png`, `src-tauri/icons/128x128.png`, `src-tauri/icons/128x128@2x.png`, `src-tauri/icons/icon.ico`
|
||||
|
||||
- [ ] **Step 6.1: Generate Kimi Switch icons**
|
||||
|
||||
Use a Python script (Pillow) to generate PNG/ICO files with a π symbol. Update `index.html` favicon to `/kimi.svg`.
|
||||
|
||||
- [ ] **Step 6.2: Verify app title**
|
||||
|
||||
`tauri.conf.json` already has `"title": "Kimi Switch"`. Ensure `index.html` `<title>` is also `Kimi Switch`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Build and run
|
||||
|
||||
**Files:** N/A
|
||||
|
||||
- [ ] **Step 7.1: Run frontend build**
|
||||
|
||||
`npm run build`
|
||||
Expected: no TypeScript or build errors.
|
||||
|
||||
- [ ] **Step 7.2: Run Rust tests**
|
||||
|
||||
`cd src-tauri && cargo test`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 7.3: Start dev app**
|
||||
|
||||
`npm run tauri-dev`
|
||||
Expected: app window opens, agent switch works, save/reload/open buttons work, model fetch is manual.
|
||||
|
||||
---
|
||||
|
||||
### Self-Review
|
||||
|
||||
- **Spec coverage:** managed-provider error → Task 1.1; glm-5 empty fields → Task 1.2; manual model fetch → Task 2; CC Switch icon selector → Task 3.1; footer action placement → Task 3.2; adaptive layout → Task 4; i18n → Task 5; logo/title → Task 6.
|
||||
- **Placeholder scan:** no TBD/TODO/fill-in details; each step has file paths and code.
|
||||
- **Type consistency:** `ConfigTarget` values remain `"kimi"` / `"omp"`; translation keys match existing `TranslationKey` type.
|
||||
@@ -1,192 +0,0 @@
|
||||
# Kimi Code /reload Hint Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an info icon tooltip next to the "switch provider" button, visible only in the Kimi Code tab, reminding users to run `/reload` after switching providers.
|
||||
|
||||
**Architecture:** Extend `ProviderList` to receive the current `agent` and conditionally render a hint icon beside the switch button. Keep changes minimal and UI-only; no state or backend changes.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Tailwind CSS, custom i18n module (`src/i18n`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add i18n translation keys
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/zh.ts`
|
||||
- Modify: `src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: Add Chinese translation**
|
||||
|
||||
Insert `switchReloadHint` into the "Provider list" section of `src/i18n/zh.ts`, e.g. after `switchTo`:
|
||||
|
||||
```ts
|
||||
switchReloadHint: "切换供应商后,请在 Kimi Code 中执行 /reload 以生效。",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add English translation**
|
||||
|
||||
Insert the matching key into `src/i18n/en.ts`:
|
||||
|
||||
```ts
|
||||
switchReloadHint: "After switching providers, run /reload in Kimi Code to apply the change.",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/zh.ts src/i18n/en.ts
|
||||
git commit -m "i18n: add switchReloadHint for Kimi Code reload reminder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update ProviderList to accept agent and render hint
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ProviderList.tsx`
|
||||
|
||||
- [ ] **Step 1: Import Agent type and update props**
|
||||
|
||||
Add `Agent` to the import from `../types` and add `agent` to `ProviderListProps`:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { Agent, Model, Provider } from "../types";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Provider[];
|
||||
defaultModel: string | null;
|
||||
models: Record<string, Model>;
|
||||
onEdit: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onAdd: () => void;
|
||||
onSwitchProvider: (name: string) => void;
|
||||
agent: Agent;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Destructure agent in component signature**
|
||||
|
||||
```tsx
|
||||
export function ProviderList({
|
||||
providers,
|
||||
defaultModel,
|
||||
models,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAdd,
|
||||
onSwitchProvider,
|
||||
agent,
|
||||
}: ProviderListProps) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render hint icon next to the switch button**
|
||||
|
||||
In the supplier card actions area, place an info icon immediately after the "切换使用" / "Switch to" button, rendered only when `agent === "kimi_code"`:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSwitchProvider(provider.name);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
|
||||
isActive
|
||||
? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500"
|
||||
: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? t("inUse") : t("switchTo")}
|
||||
</button>
|
||||
{agent === "kimi_code" && (
|
||||
<span
|
||||
className="text-sm text-gray-500 hover:text-gray-300 cursor-help select-none"
|
||||
title={t("switchReloadHint")}
|
||||
aria-label={t("switchReloadHint")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/ProviderList.tsx
|
||||
git commit -m "feat: show /reload hint next to switch provider button in Kimi Code tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Pass agent from App to ProviderList
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Add agent prop to ProviderList usage**
|
||||
|
||||
Locate the `<ProviderList ... />` call in `src/App.tsx` and add `agent={agent}`:
|
||||
|
||||
```tsx
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
defaultModel={config.default_model}
|
||||
models={config.models}
|
||||
onEdit={(name) => {
|
||||
setEditingProvider(name);
|
||||
setView("edit");
|
||||
}}
|
||||
onDelete={handleDeleteProvider}
|
||||
onAdd={handleAddProvider}
|
||||
onSwitchProvider={handleSwitchProvider}
|
||||
agent={agent}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "chore: pass current agent to ProviderList"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify with type check and manual test
|
||||
|
||||
- [ ] **Step 1: Run TypeScript compilation**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `tsc` completes without errors and Vite builds successfully.
|
||||
|
||||
- [ ] **Step 2: Run the app**
|
||||
|
||||
```bash
|
||||
npm run tauri-dev
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
1. Select the **Kimi Code** tab.
|
||||
2. Hover over the `ⓘ` icon next to any provider's "切换使用" / "Switch to" button.
|
||||
3. Confirm the tooltip reads:
|
||||
- 中文:`切换供应商后,请在 Kimi Code 中执行 /reload 以生效。`
|
||||
- English: `After switching providers, run /reload in Kimi Code to apply the change.`
|
||||
4. Switch to the **Pi** tab and confirm the `ⓘ` icon is **not** present.
|
||||
|
||||
- [ ] **Step 4: Commit verification notes (optional)**
|
||||
|
||||
If any fixes were needed, commit them; otherwise no additional commit is required.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Every design requirement (icon next to switch button, Kimi Code only, i18n, no new dependencies) maps to a task above.
|
||||
- **Placeholder scan:** No TBD/TODO/fill-in-details present.
|
||||
- **Type consistency:** `Agent` type is imported from `../types` and used consistently in props and conditional rendering.
|
||||
@@ -1,937 +0,0 @@
|
||||
# Kimi Code 全局配置面板与模型默认上下文 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a Kimi Code global settings panel inside ProviderEdit and auto-set `max_context_size` for new models based on a model-ID lookup table.
|
||||
|
||||
**Architecture:** Extend the shared `Config` type with typed agent settings, add two pure helper modules (`agent-settings.ts` for `raw_other` IO and `model-defaults.ts` for context lookup), and render the panel in a new React component wired through `ProviderEdit`.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Tailwind CSS, custom i18n, Tauri v2 backend (no Rust changes).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add agent settings types
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types/index.ts`
|
||||
|
||||
- [ ] **Step 1: Append new types**
|
||||
|
||||
Insert at the end of `src/types/index.ts`:
|
||||
|
||||
```ts
|
||||
export interface ThinkingConfig {
|
||||
enabled?: boolean;
|
||||
effort?: "low" | "medium" | "high" | "max";
|
||||
keep?: "all" | false | 0 | "no" | "off" | "none" | null;
|
||||
}
|
||||
|
||||
export interface LoopControlConfig {
|
||||
max_retries_per_step?: number;
|
||||
reserved_context_size?: number;
|
||||
}
|
||||
|
||||
export interface BackgroundConfig {
|
||||
max_running_tasks?: number;
|
||||
keep_alive_on_exit?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionRule {
|
||||
decision?: "allow" | "deny" | "ask";
|
||||
scope?: string;
|
||||
pattern?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Hook {
|
||||
event?: string;
|
||||
matcher?: string;
|
||||
command?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface AgentSettings {
|
||||
thinking?: ThinkingConfig;
|
||||
loop_control?: LoopControlConfig;
|
||||
background?: BackgroundConfig;
|
||||
permission?: { rules?: PermissionRule[] };
|
||||
hooks?: Hook[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/types/index.ts
|
||||
git commit -m "types: add AgentSettings and related Kimi Code config types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create model default context size lookup
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/model-defaults.ts`
|
||||
|
||||
- [ ] **Step 1: Create the lookup module**
|
||||
|
||||
Create `src/lib/model-defaults.ts`:
|
||||
|
||||
```ts
|
||||
export const DEFAULT_MAX_CONTEXT_SIZE = 256000;
|
||||
|
||||
interface ModelContextRule {
|
||||
pattern: RegExp;
|
||||
max_context_size: number;
|
||||
}
|
||||
|
||||
const MODEL_CONTEXT_RULES: ModelContextRule[] = [
|
||||
// Kimi
|
||||
{ pattern: /^kimi-for-coding$/i, max_context_size: 262144 },
|
||||
{ pattern: /^kimi-k2\.5/i, max_context_size: 256000 },
|
||||
{ pattern: /^kimi-k2/i, max_context_size: 256000 },
|
||||
{ pattern: /^kimi-/i, max_context_size: 256000 },
|
||||
// GLM
|
||||
{ pattern: /^glm-5\.2/i, max_context_size: 1000000 },
|
||||
{ pattern: /^glm-5\.1/i, max_context_size: 256000 },
|
||||
{ pattern: /^glm-5/i, max_context_size: 256000 },
|
||||
{ pattern: /^glm-4/i, max_context_size: 128000 },
|
||||
{ pattern: /^glm-/i, max_context_size: 128000 },
|
||||
// MiniMax
|
||||
{ pattern: /^MiniMax-M3/i, max_context_size: 1000000 },
|
||||
{ pattern: /^MiniMax-Text-01/i, max_context_size: 400000 },
|
||||
{ pattern: /^MiniMax-/i, max_context_size: 256000 },
|
||||
// Qwen
|
||||
{ pattern: /^qwen2\.5/i, max_context_size: 128000 },
|
||||
{ pattern: /^qwen-max/i, max_context_size: 128000 },
|
||||
{ pattern: /^qwen-plus/i, max_context_size: 128000 },
|
||||
{ pattern: /^qwen-turbo/i, max_context_size: 128000 },
|
||||
{ pattern: /^qwen-coder/i, max_context_size: 128000 },
|
||||
{ pattern: /^qwen-/i, max_context_size: 128000 },
|
||||
// DeepSeek
|
||||
{ pattern: /^deepseek-r1/i, max_context_size: 64000 },
|
||||
{ pattern: /^deepseek-v3/i, max_context_size: 64000 },
|
||||
{ pattern: /^deepseek-coder/i, max_context_size: 64000 },
|
||||
{ pattern: /^deepseek-/i, max_context_size: 64000 },
|
||||
// Hunyuan
|
||||
{ pattern: /^hunyuan-pro/i, max_context_size: 32000 },
|
||||
{ pattern: /^hunyuan-standard/i, max_context_size: 32000 },
|
||||
{ pattern: /^hunyuan-lite/i, max_context_size: 32000 },
|
||||
{ pattern: /^hunyuan-/i, max_context_size: 32000 },
|
||||
// Doubao
|
||||
{ pattern: /^doubao-pro/i, max_context_size: 128000 },
|
||||
{ pattern: /^doubao-lite/i, max_context_size: 128000 },
|
||||
{ pattern: /^doubao-vision/i, max_context_size: 128000 },
|
||||
{ pattern: /^doubao-/i, max_context_size: 128000 },
|
||||
// ERNIE
|
||||
{ pattern: /^ernie-4\.0/i, max_context_size: 128000 },
|
||||
{ pattern: /^ernie-3\.5/i, max_context_size: 128000 },
|
||||
{ pattern: /^ernie-speed/i, max_context_size: 128000 },
|
||||
{ pattern: /^ernie-lite/i, max_context_size: 128000 },
|
||||
{ pattern: /^ernie-/i, max_context_size: 128000 },
|
||||
// Spark
|
||||
{ pattern: /^spark-v4/i, max_context_size: 32000 },
|
||||
{ pattern: /^spark-v3\.5/i, max_context_size: 32000 },
|
||||
{ pattern: /^spark-pro/i, max_context_size: 32000 },
|
||||
{ pattern: /^spark-max/i, max_context_size: 32000 },
|
||||
{ pattern: /^spark-/i, max_context_size: 32000 },
|
||||
// SenseChat
|
||||
{ pattern: /^sensechat-/i, max_context_size: 128000 },
|
||||
// Baichuan
|
||||
{ pattern: /^baichuan-4/i, max_context_size: 128000 },
|
||||
{ pattern: /^baichuan-3/i, max_context_size: 128000 },
|
||||
{ pattern: /^baichuan-/i, max_context_size: 128000 },
|
||||
// Yi
|
||||
{ pattern: /^yi-/i, max_context_size: 128000 },
|
||||
// Claude
|
||||
{ pattern: /^claude-opus/i, max_context_size: 200000 },
|
||||
{ pattern: /^claude-sonnet/i, max_context_size: 200000 },
|
||||
{ pattern: /^claude-haiku/i, max_context_size: 200000 },
|
||||
{ pattern: /^claude-/i, max_context_size: 200000 },
|
||||
// OpenAI
|
||||
{ pattern: /^gpt-4\.1/i, max_context_size: 1047576 },
|
||||
{ pattern: /^gpt-4o/i, max_context_size: 128000 },
|
||||
{ pattern: /^gpt-4-turbo/i, max_context_size: 128000 },
|
||||
{ pattern: /^gpt-4-/i, max_context_size: 128000 },
|
||||
// Gemini
|
||||
{ pattern: /^gemini-2\.0-flash/i, max_context_size: 1048576 },
|
||||
{ pattern: /^gemini-1\.5-pro/i, max_context_size: 2097152 },
|
||||
{ pattern: /^gemini-1\.5-flash/i, max_context_size: 1048576 },
|
||||
{ pattern: /^gemini-/i, max_context_size: 1048576 },
|
||||
];
|
||||
|
||||
export function getDefaultMaxContextSize(modelId: string): number {
|
||||
for (const rule of MODEL_CONTEXT_RULES) {
|
||||
if (rule.pattern.test(modelId)) {
|
||||
return rule.max_context_size;
|
||||
}
|
||||
}
|
||||
return DEFAULT_MAX_CONTEXT_SIZE;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/model-defaults.ts
|
||||
git commit -m "feat: add model default max_context_size lookup table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create agent settings IO helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/agent-settings.ts`
|
||||
|
||||
- [ ] **Step 1: Create helper module**
|
||||
|
||||
Create `src/lib/agent-settings.ts`:
|
||||
|
||||
```ts
|
||||
import type { AgentSettings } from "../types";
|
||||
|
||||
const DEFAULT_SETTINGS: AgentSettings = {
|
||||
thinking: {
|
||||
enabled: true,
|
||||
effort: "medium",
|
||||
keep: "all",
|
||||
},
|
||||
loop_control: {
|
||||
max_retries_per_step: 3,
|
||||
reserved_context_size: 50000,
|
||||
},
|
||||
background: {
|
||||
keep_alive_on_exit: false,
|
||||
},
|
||||
permission: { rules: [] },
|
||||
hooks: [],
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getSection<T>(rawOther: unknown, key: string): T | undefined {
|
||||
const root = asRecord(rawOther);
|
||||
const section = root[key];
|
||||
if (section === undefined || section === null) return undefined;
|
||||
return section as T;
|
||||
}
|
||||
|
||||
export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
return {
|
||||
thinking: {
|
||||
...DEFAULT_SETTINGS.thinking,
|
||||
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
|
||||
},
|
||||
loop_control: {
|
||||
...DEFAULT_SETTINGS.loop_control,
|
||||
...getSection<AgentSettings["loop_control"]>(rawOther, "loop_control"),
|
||||
},
|
||||
background: {
|
||||
...DEFAULT_SETTINGS.background,
|
||||
...getSection<AgentSettings["background"]>(rawOther, "background"),
|
||||
},
|
||||
permission: {
|
||||
rules: getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ?? [],
|
||||
},
|
||||
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function setAgentSettings(
|
||||
rawOther: unknown,
|
||||
patch: Partial<AgentSettings>
|
||||
): unknown {
|
||||
const root = { ...asRecord(rawOther) };
|
||||
const current = getAgentSettings(rawOther);
|
||||
const next: AgentSettings = {
|
||||
thinking: { ...current.thinking, ...patch.thinking },
|
||||
loop_control: { ...current.loop_control, ...patch.loop_control },
|
||||
background: { ...current.background, ...patch.background },
|
||||
permission: { rules: patch.permission?.rules ?? current.permission?.rules ?? [] },
|
||||
hooks: patch.hooks ?? current.hooks ?? [],
|
||||
};
|
||||
|
||||
if (next.thinking) root.thinking = next.thinking;
|
||||
if (next.loop_control) root.loop_control = next.loop_control;
|
||||
if (next.background) root.background = next.background;
|
||||
if (next.permission?.rules && next.permission.rules.length > 0) {
|
||||
root.permission = next.permission;
|
||||
} else {
|
||||
delete root.permission;
|
||||
}
|
||||
if (next.hooks && next.hooks.length > 0) {
|
||||
root.hooks = next.hooks;
|
||||
} else {
|
||||
delete root.hooks;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/agent-settings.ts
|
||||
git commit -m "feat: add agent settings read/write helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add i18n keys
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/zh.ts`
|
||||
- Modify: `src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: Add Chinese keys**
|
||||
|
||||
Insert into `src/i18n/zh.ts` in the "Agent / target" section:
|
||||
|
||||
```ts
|
||||
agentSettings: "全局配置",
|
||||
enableThinking: "启用思考",
|
||||
thinkingLevel: "思考等级",
|
||||
thinkingKeep: "保留思考内容",
|
||||
thinkingLow: "低",
|
||||
thinkingMedium: "中",
|
||||
thinkingHigh: "高",
|
||||
thinkingMax: "最大",
|
||||
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
|
||||
loopControlSettings: "循环控制",
|
||||
maxRetriesPerStep: "单步重试次数",
|
||||
reservedContextSize: "上下文预留大小",
|
||||
backgroundSettings: "后台任务",
|
||||
maxRunningTasks: "最大并发数",
|
||||
keepAliveOnExit: "退出时保持运行",
|
||||
permissionRules: "权限规则",
|
||||
permissionDecision: "处置",
|
||||
permissionPattern: "模式",
|
||||
permissionAllow: "允许",
|
||||
permissionDeny: "拒绝",
|
||||
permissionAsk: "询问",
|
||||
addRule: "+ 添加规则",
|
||||
addCommonRules: "添加常用规则",
|
||||
hooks: "生命周期钩子",
|
||||
hookEvent: "事件",
|
||||
hookMatcher: "匹配器",
|
||||
hookCommand: "命令",
|
||||
hookTimeout: "超时",
|
||||
addHook: "+ 添加钩子",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add English keys**
|
||||
|
||||
Insert matching keys into `src/i18n/en.ts`:
|
||||
|
||||
```ts
|
||||
agentSettings: "Global Settings",
|
||||
enableThinking: "Enable thinking",
|
||||
thinkingLevel: "Thinking level",
|
||||
thinkingKeep: "Keep thinking content",
|
||||
thinkingLow: "Low",
|
||||
thinkingMedium: "Medium",
|
||||
thinkingHigh: "High",
|
||||
thinkingMax: "Max",
|
||||
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
|
||||
loopControlSettings: "Loop Control",
|
||||
maxRetriesPerStep: "Max retries per step",
|
||||
reservedContextSize: "Reserved context size",
|
||||
backgroundSettings: "Background Tasks",
|
||||
maxRunningTasks: "Max running tasks",
|
||||
keepAliveOnExit: "Keep alive on exit",
|
||||
permissionRules: "Permission Rules",
|
||||
permissionDecision: "Decision",
|
||||
permissionPattern: "Pattern",
|
||||
permissionAllow: "Allow",
|
||||
permissionDeny: "Deny",
|
||||
permissionAsk: "Ask",
|
||||
addRule: "+ Add rule",
|
||||
addCommonRules: "Add common rules",
|
||||
hooks: "Lifecycle Hooks",
|
||||
hookEvent: "Event",
|
||||
hookMatcher: "Matcher",
|
||||
hookCommand: "Command",
|
||||
hookTimeout: "Timeout",
|
||||
addHook: "+ Add hook",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/zh.ts src/i18n/en.ts
|
||||
git commit -m "i18n: add agent settings panel translations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build the AgentSettingsPanel component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/AgentSettingsPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the panel component**
|
||||
|
||||
Create `src/components/AgentSettingsPanel.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from "../i18n";
|
||||
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
|
||||
import type { AgentSettings, PermissionRule, Hook } from "../types";
|
||||
import type { ReactNode } from "react";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
|
||||
interface AgentSettingsPanelProps {
|
||||
rawOther: unknown;
|
||||
onChange: (nextRawOther: unknown) => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVELS = ["low", "medium", "high", "max"] as const;
|
||||
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
|
||||
low: "thinkingLow",
|
||||
medium: "thinkingMedium",
|
||||
high: "thinkingHigh",
|
||||
max: "thinkingMax",
|
||||
};
|
||||
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
|
||||
const PERMISSION_LABELS: Record<(typeof PERMISSION_DECISIONS)[number], TranslationKey> = {
|
||||
allow: "permissionAllow",
|
||||
deny: "permissionDeny",
|
||||
ask: "permissionAsk",
|
||||
};
|
||||
const COMMON_EVENTS = ["PreToolUse", "PostToolUse"] as const;
|
||||
|
||||
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const settings = getAgentSettings(rawOther);
|
||||
|
||||
const update = (patch: Partial<AgentSettings>) => {
|
||||
onChange(setAgentSettings(rawOther, patch));
|
||||
};
|
||||
|
||||
const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => {
|
||||
update({ thinking: { ...settings.thinking, ...patch } });
|
||||
};
|
||||
|
||||
const updateLoopControl = (patch: Partial<AgentSettings["loop_control"]>) => {
|
||||
update({ loop_control: { ...settings.loop_control, ...patch } });
|
||||
};
|
||||
|
||||
const updateBackground = (patch: Partial<AgentSettings["background"]>) => {
|
||||
update({ background: { ...settings.background, ...patch } });
|
||||
};
|
||||
|
||||
const setRules = (rules: PermissionRule[]) => {
|
||||
update({ permission: { rules } });
|
||||
};
|
||||
|
||||
const setHooks = (hooks: Hook[]) => {
|
||||
update({ hooks });
|
||||
};
|
||||
|
||||
const thinkingEnabled = settings.thinking?.enabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-gray-400 text-sm font-medium">{t("agentSettings")}</h3>
|
||||
|
||||
{/* Thinking */}
|
||||
<Card title={t("enableThinking")}>
|
||||
<Checkbox
|
||||
label={t("enableThinking")}
|
||||
checked={thinkingEnabled}
|
||||
onChange={(checked) => updateThinking({ enabled: checked })}
|
||||
/>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-gray-400">{t("thinkingLevel")}</span>
|
||||
<Segmented
|
||||
options={THINKING_LEVELS.map((lvl) => ({ key: lvl, label: t(THINKING_LABELS[lvl]) }))}
|
||||
value={settings.thinking?.effort ?? "medium"}
|
||||
onChange={(effort) => updateThinking({ effort })}
|
||||
disabled={!thinkingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={t("thinkingKeep")}
|
||||
checked={settings.thinking?.keep === "all"}
|
||||
disabled={!thinkingEnabled}
|
||||
onChange={(checked) => updateThinking({ keep: checked ? "all" : false })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{t("thinkingContextHint")}</p>
|
||||
</Card>
|
||||
|
||||
{/* Loop Control */}
|
||||
<Card title={t("loopControlSettings")}>
|
||||
<NumberField
|
||||
label={t("maxRetriesPerStep")}
|
||||
value={settings.loop_control?.max_retries_per_step ?? 3}
|
||||
onChange={(v) => updateLoopControl({ max_retries_per_step: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label={t("reservedContextSize")}
|
||||
value={settings.loop_control?.reserved_context_size ?? 50000}
|
||||
onChange={(v) => updateLoopControl({ reserved_context_size: v })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Background */}
|
||||
<Card title={t("backgroundSettings")}>
|
||||
<NumberField
|
||||
label={t("maxRunningTasks")}
|
||||
value={settings.background?.max_running_tasks}
|
||||
onChange={(v) => updateBackground({ max_running_tasks: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("keepAliveOnExit")}
|
||||
checked={settings.background?.keep_alive_on_exit ?? false}
|
||||
onChange={(checked) => updateBackground({ keep_alive_on_exit: checked })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Permission Rules */}
|
||||
<Card title={t("permissionRules")}>
|
||||
<div className="space-y-2">
|
||||
{(settings.permission?.rules ?? []).map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<select
|
||||
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={rule.decision ?? "allow"}
|
||||
onChange={(e) => {
|
||||
const rules = [...(settings.permission?.rules ?? [])];
|
||||
rules[idx] = { ...rule, decision: e.target.value as PermissionRule["decision"] };
|
||||
setRules(rules);
|
||||
}}
|
||||
>
|
||||
{PERMISSION_DECISIONS.map((d) => (
|
||||
<option key={d} value={d}>{t(PERMISSION_LABELS[d])}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={rule.pattern ?? ""}
|
||||
placeholder={t("permissionPattern")}
|
||||
onChange={(e) => {
|
||||
const rules = [...(settings.permission?.rules ?? [])];
|
||||
rules[idx] = { ...rule, pattern: e.target.value };
|
||||
setRules(rules);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const rules = [...(settings.permission?.rules ?? [])];
|
||||
rules.splice(idx, 1);
|
||||
setRules(rules);
|
||||
}}
|
||||
className="px-2 py-1 text-sm text-red-400 hover:bg-red-900/20 rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRules([...(settings.permission?.rules ?? []), { decision: "allow", pattern: "" }]);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
|
||||
>
|
||||
{t("addRule")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRules([
|
||||
...(settings.permission?.rules ?? []),
|
||||
{ decision: "allow", pattern: "Read" },
|
||||
{ decision: "deny", pattern: "Bash(rm -rf*)" },
|
||||
]);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
|
||||
>
|
||||
{t("addCommonRules")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Hooks */}
|
||||
<Card title={t("hooks")}>
|
||||
<div className="space-y-2">
|
||||
{(settings.hooks ?? []).map((hook, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
list="hook-events"
|
||||
type="text"
|
||||
className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={hook.event ?? ""}
|
||||
placeholder={t("hookEvent")}
|
||||
onChange={(e) => {
|
||||
const hooks = [...(settings.hooks ?? [])];
|
||||
hooks[idx] = { ...hook, event: e.target.value };
|
||||
setHooks(hooks);
|
||||
}}
|
||||
/>
|
||||
<datalist id="hook-events">
|
||||
{COMMON_EVENTS.map((e) => <option key={e} value={e} />)}
|
||||
</datalist>
|
||||
<input
|
||||
type="text"
|
||||
className="w-24 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={hook.matcher ?? ""}
|
||||
placeholder={t("hookMatcher")}
|
||||
onChange={(e) => {
|
||||
const hooks = [...(settings.hooks ?? [])];
|
||||
hooks[idx] = { ...hook, matcher: e.target.value };
|
||||
setHooks(hooks);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={hook.command ?? ""}
|
||||
placeholder={t("hookCommand")}
|
||||
onChange={(e) => {
|
||||
const hooks = [...(settings.hooks ?? [])];
|
||||
hooks[idx] = { ...hook, command: e.target.value };
|
||||
setHooks(hooks);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
|
||||
value={hook.timeout ?? ""}
|
||||
placeholder={t("hookTimeout")}
|
||||
onChange={(e) => {
|
||||
const hooks = [...(settings.hooks ?? [])];
|
||||
hooks[idx] = { ...hook, timeout: e.target.value === "" ? undefined : Number(e.target.value) };
|
||||
setHooks(hooks);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const hooks = [...(settings.hooks ?? [])];
|
||||
hooks.splice(idx, 1);
|
||||
setHooks(hooks);
|
||||
}}
|
||||
className="px-2 py-1 text-sm text-red-400 hover:bg-red-900/20 rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHooks([...(settings.hooks ?? []), { event: "", matcher: "", command: "" }]);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
|
||||
>
|
||||
{t("addHook")}
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3">
|
||||
<h4 className="text-gray-400 text-sm font-medium">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-2 text-sm ${disabled ? "text-gray-600" : "text-[#e5e5e7]"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value?: number;
|
||||
onChange: (value: number | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 text-sm text-[#e5e5e7]">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
onChange(raw === "" ? undefined : Number(raw));
|
||||
}}
|
||||
className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Segmented({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
options: { key: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center rounded overflow-hidden border border-[#2a2a2e] ${disabled ? "opacity-50" : ""}`}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.key)}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
value === opt.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-[#1f1f23] text-gray-400 hover:bg-[#252529]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/AgentSettingsPanel.tsx
|
||||
git commit -m "feat: add AgentSettingsPanel component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire AgentSettingsPanel into ProviderEdit
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/ProviderEdit.tsx`
|
||||
|
||||
- [ ] **Step 1: Update ProviderEditProps**
|
||||
|
||||
Add `rawOther` and `onRawOtherChange` to the props interface:
|
||||
|
||||
```tsx
|
||||
interface ProviderEditProps {
|
||||
agent: Agent;
|
||||
provider: Provider;
|
||||
models: Model[];
|
||||
defaultModel: string | null;
|
||||
rawOther: unknown;
|
||||
onRawOtherChange: (nextRawOther: unknown) => void;
|
||||
onBack: () => void;
|
||||
// ... existing callbacks
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Destructure new props**
|
||||
|
||||
```tsx
|
||||
export function ProviderEdit({
|
||||
agent,
|
||||
provider,
|
||||
models,
|
||||
defaultModel,
|
||||
rawOther,
|
||||
onRawOtherChange,
|
||||
onBack,
|
||||
// ... existing
|
||||
}: ProviderEditProps) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render the panel in the model mapping tab**
|
||||
|
||||
Locate the model mapping tab content (around the model table and "+ 添加模型映射" button) and insert `<AgentSettingsPanel />` at the bottom, conditionally for Kimi Code:
|
||||
|
||||
```tsx
|
||||
{agent === "kimi_code" && (
|
||||
<AgentSettingsPanel rawOther={rawOther} onChange={onRawOtherChange} />
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/ProviderEdit.tsx
|
||||
git commit -m "feat: render AgentSettingsPanel in ProviderEdit for Kimi Code"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire ProviderEdit to App.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Pass rawOther and updater to ProviderEdit**
|
||||
|
||||
Find the `<ProviderEdit ... />` call and add:
|
||||
|
||||
```tsx
|
||||
<ProviderEdit
|
||||
agent={agent}
|
||||
provider={currentProvider}
|
||||
models={...}
|
||||
defaultModel={config.default_model}
|
||||
rawOther={config.raw_other}
|
||||
onRawOtherChange={(nextRawOther) =>
|
||||
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
|
||||
}
|
||||
// ... existing props
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "chore: pass rawOther to ProviderEdit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Apply smart max_context_size defaults when adding models
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Import helper in App.tsx**
|
||||
|
||||
In `src/App.tsx`, import:
|
||||
|
||||
```tsx
|
||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Use lookup in onModelAdd**
|
||||
|
||||
Find the `onModelAdd` prop passed to `<ProviderEdit />` and update the default `max_context_size`:
|
||||
|
||||
```tsx
|
||||
onModelAdd={() => {
|
||||
const providerModels = Object.values(config.models).filter(
|
||||
(m) => m.provider === currentProvider.name
|
||||
);
|
||||
const alias = `${currentProvider.name}-${providerModels.length + 1}`;
|
||||
updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
models: {
|
||||
...cfg.models,
|
||||
[alias]: {
|
||||
alias,
|
||||
provider: currentProvider.name,
|
||||
model: "",
|
||||
max_context_size: getDefaultMaxContextSize(alias),
|
||||
display_name: null,
|
||||
role: null,
|
||||
supports_1m: false,
|
||||
capabilities: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use lookup in handleApplyProviderJson**
|
||||
|
||||
Update the model creation loop in `handleApplyProviderJson`:
|
||||
|
||||
```tsx
|
||||
updatedModels[alias] = {
|
||||
...m,
|
||||
alias,
|
||||
provider: provider.name,
|
||||
max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.tsx
|
||||
git commit -m "feat: apply smart default max_context_size when adding models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Verify with build and manual checks
|
||||
|
||||
- [ ] **Step 1: Type check**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `tsc` and Vite build succeed.
|
||||
|
||||
- [ ] **Step 2: Run the app**
|
||||
|
||||
```bash
|
||||
npm run tauri-dev
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
1. Kimi Code tab → edit a provider → "模型映射" tab → scroll to bottom.
|
||||
2. Confirm "全局配置" panel shows 5 cards.
|
||||
3. Toggle "启用思考" and verify effort/keep enable/disable.
|
||||
4. Change thinking effort, save, open `~/.kimi-code/config.toml`, verify `[thinking]` block.
|
||||
5. Add a permission rule and a hook, save, verify `[[permission.rules]]` and `[[hooks]]` in TOML.
|
||||
6. Add a new model with alias `glm-5.2-xxx`, verify `max_context_size` defaults to `1000000`.
|
||||
7. Add a new model with unknown alias, verify `max_context_size` defaults to `256000`.
|
||||
8. Switch to Pi tab, confirm global settings panel is not visible.
|
||||
|
||||
- [ ] **Step 4: Commit any fixes**
|
||||
|
||||
If any fixes were needed during verification, commit them.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** Every requirement (5 cards, model defaults, i18n, Kimi-only visibility, manual override) maps to a task.
|
||||
- **Placeholder scan:** No TBD/TODO/fill-in-details; all code is shown.
|
||||
- **Type consistency:** `AgentSettings`, helper signatures, and component props match across tasks.
|
||||
@@ -1,420 +0,0 @@
|
||||
# KimiSwitch 设计文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
KimiSwitch 是一个 Windows 桌面配置管理工具,用于简化 Kimi Code CLI 的多供应商配置维护。它通过图形界面让用户增删改查 `[providers]` 与 `[models]`,支持多套配置**配置文件(Profile)**的保存与一键切换,最终生成符合 Kimi Code CLI 规范的 `~/.kimi-code/config.toml`。
|
||||
|
||||
## 2. 问题与目标
|
||||
|
||||
### 2.1 现状痛点
|
||||
|
||||
- Kimi Code CLI 的配置文件是 TOML 格式,需要手动编辑。
|
||||
- 维护多个供应商(Kimi、OpenAI、Anthropic、Gemini、Vertex 等)时,`base_url` 与 `api_key` 容易写错或重复。
|
||||
- 对 TOML 语法不熟的用户(例如 `[models."gpt-4.1"]` 需要引号)上手成本高。
|
||||
- 工作、个人、测试等场景需要多套 API 配置时,只能手动备份/替换 `config.toml`,切换麻烦且容易出错。
|
||||
|
||||
### 2.2 项目目标
|
||||
|
||||
- **优先做好 Windows 版本**:安装包、路径处理、UI 风格、权限行为均以 Windows 为首要目标平台。
|
||||
- 提供图形化界面,零 TOML 语法负担地维护供应商与模型别名。
|
||||
- **支持一键切换当前使用的供应商及其对应模型**:选择供应商后自动筛选关联模型,确认后立即将 `default_model` 写入 `config.toml`。
|
||||
- **支持多配置文件(Profile)管理**:保存多份独立配置,一键切换激活某一份到 `~/.kimi-code/config.toml`。
|
||||
- 支持 Kimi Code CLI 官方支持的全部供应商类型。
|
||||
- 自动处理凭证存储方式(直接字段 vs `[providers.<name>.env]` 子表)。
|
||||
- 以 Windows MSI 安装包形式分发给 Windows 用户。
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 In Scope
|
||||
|
||||
- 供应商(providers)管理:增删改查。
|
||||
- 模型别名(models)管理:增删改查。
|
||||
- **供应商与模型快速切换**:选择供应商后只显示使用该供应商的模型,一键设为 `default_model`。
|
||||
- **Profile(配置快照)管理**:新建、重命名、复制、删除、切换 Profile;Profile 保存在 `~/.kimi-code/profiles/` 下,切换时写入 `~/.kimi-code/config.toml`。
|
||||
- 读取现有 `~/.kimi-code/config.toml`。
|
||||
- 保存时自动备份原配置并写入新配置。
|
||||
- 基础校验:必填项、重复别名、缺失的供应商引用、缺失凭证。
|
||||
- 生成 Windows MSI 安装包。
|
||||
|
||||
### 3.2 Out of Scope
|
||||
|
||||
- 不实现加密存储:API Key 按 Kimi Code CLI 官方方式保存在 `config.toml` 中。
|
||||
- 不修改 `tui.toml`、`mcp.json`、OAuth 凭证等其他文件。
|
||||
- 不实现远程同步或云存储。
|
||||
- 不实现网络连通性测试或 API 调用验证。
|
||||
- 第一版不支持 macOS / Linux 安装包,仅输出 Windows x64 MSI;其他平台待 Windows x64 版本完成后再规划。
|
||||
|
||||
## 4. 技术方案
|
||||
|
||||
### 4.1 推荐方案
|
||||
|
||||
**Tauri(Rust + TypeScript)**
|
||||
|
||||
- **Tauri v2**:Rust 编写的跨平台桌面框架,内置 Windows MSI 打包,产物体积小、启动快。本设计基于 Tauri v2 的权限与命令体系。
|
||||
- **Rust**:负责文件 I/O、配置解析、Profile 管理、校验逻辑;类型安全、性能高。
|
||||
- **TypeScript + React + Tailwind CSS**:负责前端界面;Tailwind 提供基础样式,无需引入重型 UI 组件库。
|
||||
- **toml_edit**:Rust 生态中支持 round-trip 的 TOML 编辑库,保留注释与格式。
|
||||
- **IndexMap**:替代 `HashMap` 保留 Provider / Model 的插入顺序。
|
||||
- **Tauri Bundler**:一键生成 `.msi` 安装包,无需额外 WiX 手动编写。
|
||||
|
||||
### 4.2 备选方案
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| Tauri + Rust + Vanilla TS | 依赖更少,构建更快 | 大型表单状态管理需手写 |
|
||||
| Tauri + Rust + Vue/Svelte | 体积与 Vanilla 相近 | 团队需熟悉对应框架 |
|
||||
| egui / Iced(纯 Rust) | 无前端构建链 | UI 灵活度与组件生态不如 Web |
|
||||
|
||||
选择 **Tauri + Rust + React** 的原因是:Tauri 内置 MSI 打包且产物体积小;Rust 适合处理文件与配置逻辑;React 表单/表格生态成熟,能快速实现三栏配置界面。
|
||||
|
||||
## 5. 目录结构
|
||||
|
||||
```
|
||||
D:/AIGC/KimiSwitch
|
||||
├── src-tauri/
|
||||
│ ├── Cargo.toml
|
||||
│ ├── tauri.conf.json # Tauri 应用配置与打包选项
|
||||
│ └── src/
|
||||
│ ├── main.rs # 入口
|
||||
│ ├── lib.rs # 模块导出
|
||||
│ ├── models.rs # Provider / Model / Config / Profile 结构体
|
||||
│ ├── config_io.rs # 读写 config.toml 与 Profile 文件
|
||||
│ ├── profile_manager.rs # Profile 生命周期管理
|
||||
│ ├── validators.rs # 配置校验
|
||||
│ └── commands.rs # Tauri 暴露给前端的命令
|
||||
├── src/ # 前端源码
|
||||
│ ├── App.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── TopBar.tsx # Profile / 供应商 / 模型 / 设为默认
|
||||
│ │ ├── ProviderList.tsx
|
||||
│ │ ├── ProviderForm.tsx
|
||||
│ │ ├── ModelList.tsx
|
||||
│ │ ├── ModelForm.tsx
|
||||
│ │ └── BottomToolbar.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useConfig.ts # 配置状态与 Tauri 命令封装
|
||||
│ └── types/
|
||||
│ └── index.ts # 前端类型定义
|
||||
├── tests/
|
||||
│ ├── config_io.rs
|
||||
│ ├── profile_manager.rs
|
||||
│ └── validators.rs
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
├── Cargo.lock
|
||||
├── README.md
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## 6. 数据模型
|
||||
|
||||
### 6.1 ProviderType 枚举
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderType {
|
||||
Kimi,
|
||||
Anthropic,
|
||||
Openai,
|
||||
#[serde(rename = "openai_responses")]
|
||||
OpenaiResponses,
|
||||
#[serde(rename = "google-genai")]
|
||||
GoogleGenai,
|
||||
Vertexai,
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:`openai_responses` 与 `google-genai` 包含连字符,无法直接用 `rename_all = "snake_case"`,需单独 `#[serde(rename)]`。
|
||||
|
||||
### 6.2 Provider
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub name: String, // 供应商唯一标识
|
||||
pub provider_type: ProviderType, // 枚举
|
||||
pub base_url: Option<String>, // 直接字段
|
||||
pub api_key: Option<String>, // 直接字段
|
||||
pub env: IndexMap<String, String>, // [providers.<name>.env] 子表,保留顺序
|
||||
pub raw_other: Table, // 保留本工具不直接编辑的其他字段(如 custom_headers)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Model
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub alias: String, // 模型别名
|
||||
pub provider: String, // 引用的供应商 name
|
||||
pub model: String, // 实际模型 ID
|
||||
pub max_context_size: u64,
|
||||
pub display_name: Option<String>,
|
||||
pub raw_other: Table, // 保留 capabilities 等本工具不直接编辑的字段
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Config
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub default_model: Option<String>,
|
||||
pub providers: IndexMap<String, Provider>, // 保留顺序
|
||||
pub models: IndexMap<String, Model>, // 保留顺序
|
||||
pub raw_other: Table, // 保留 config.toml 中与本工具无关的其他字段
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Profile
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub name: String, // Profile 显示名称,可包含中文/空格
|
||||
pub filename: String, // 安全化的存储文件名,如 "my-profile.toml"
|
||||
pub config: Config, // 该 Profile 对应的完整配置内容
|
||||
pub is_active: bool, // 是否当前已激活
|
||||
}
|
||||
```
|
||||
|
||||
Profile 文件统一存放在 `~/.kimi-code/profiles/` 目录下。`default` Profile 特殊处理:它直接对应 `~/.kimi-code/config.toml`,不额外在 `profiles/` 下生成文件。
|
||||
|
||||
**文件名安全化**:Profile 显示名可包含中文、空格、大小写;存储时自动转译为合法 Windows 文件名。转译规则:保留 ASCII 字母数字,其他字符替换为 `-`,连续 `-` 合并,小写化;若结果为空(如纯中文名),则回退为基于名称哈希的短文件名(如 `profile-48291.toml`)。
|
||||
|
||||
## 7. 配置读写行为
|
||||
|
||||
### 7.1 读取流程
|
||||
|
||||
1. 定位 `~/.kimi-code/config.toml`(可通过 `KIMI_CODE_HOME` 环境变量覆盖;使用 Rust `std::path::PathBuf` 处理 Windows 路径)。
|
||||
2. 使用 `toml_edit` 解析 TOML(保留注释、空行与字段顺序)。
|
||||
3. 提取 `[providers.*]` 与 `[models.*]` 到 Rust 结构体。
|
||||
4. 保留文件中其他字段(如 `[thinking]`、`[loop_control]` 等),保存时原样写回。
|
||||
|
||||
### 7.2 写入流程
|
||||
|
||||
1. 若目标文件已存在,先复制一份带时间戳的备份:`config.toml.bak.YYYYMMDD_HHMMSS_<毫秒>`(如 1 秒内多次保存,追加自增序号)。
|
||||
2. 使用 `toml_edit` 加载并修改 `[providers]`、`[models]` 与 `default_model`,保留原文件中的注释、空行与字段顺序。
|
||||
3. 合并保留的其他字段。
|
||||
4. 写回文件。
|
||||
5. 写操作使用临时文件 + 原子重命名(`write to tmp → fsync → rename`),降低写损风险。
|
||||
|
||||
> 第一版即使用 `toml_edit` 而非 `toml`/`serde_toml`,避免用户的既有注释和格式在保存后被清空。
|
||||
|
||||
### 7.3 Tauri 权限配置
|
||||
|
||||
Tauri 默认禁止前端访问文件系统,需在 `tauri.conf.json`(或 `capabilities/` 目录)中显式声明:
|
||||
|
||||
- `fs:allow-read-file`:`~/.kimi-code/config.toml`、Profile 文件。
|
||||
- `fs:allow-write-file`:`~/.kimi-code/config.toml`、Profile 文件。
|
||||
- `fs:allow-read-dir`:`~/.kimi-code/`、`~/.kimi-code/profiles/`。
|
||||
- `fs:allow-create-dir`:首次运行时创建 `~/.kimi-code/profiles/`。
|
||||
- `core:default`:基础窗口与事件权限。
|
||||
|
||||
所有路径使用 Tauri 的 `BaseDirectory::Home` 解析,避免硬编码绝对路径。
|
||||
|
||||
### 7.4 Tauri 命令清单
|
||||
|
||||
Rust 后端通过 `commands.rs` 暴露以下命令给前端调用:
|
||||
|
||||
| 命令 | 输入 | 输出 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `load_config` | 无 | `Config` | 读取 `~/.kimi-code/config.toml`,若不存在则初始化空配置。 |
|
||||
| `save_config` | `Config` | `Result<(), String>` | 校验并写入 `config.toml`,失败返回错误信息。 |
|
||||
| `list_profiles` | 无 | `Vec<ProfileSummary>` | 列出 `profiles/` 下所有 Profile 及其激活状态。 |
|
||||
| `load_profile` | `filename: String` | `Config` | 读取指定 Profile 文件内容。 |
|
||||
| `save_profile` | `filename: String, config: Config` | `Result<(), String>` | 保存或覆盖指定 Profile 文件。 |
|
||||
| `switch_profile` | `filename: String` | `Result<Config, String>` | 将指定 Profile 内容写入 `config.toml`(带备份)并返回新配置。 |
|
||||
| `rename_profile` | `old_filename, new_name` | `Result<String, String>` | 重命名 Profile,返回新的安全文件名。 |
|
||||
| `delete_profile` | `filename: String` | `Result<(), String>` | 删除指定 Profile 文件。 |
|
||||
| `open_config_dir` | 无 | `Result<(), String>` | 调用系统文件管理器打开配置目录。 |
|
||||
| `get_app_version` | 无 | `String` | 返回当前应用版本号。 |
|
||||
|
||||
### 7.5 凭证存储策略
|
||||
|
||||
根据 [Kimi Code CLI 配置覆盖文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/overrides.html),凭证解析优先级为:
|
||||
|
||||
1. `[providers.<name>].api_key` / `.base_url`
|
||||
2. `[providers.<name>.env]` 中对应键
|
||||
3. 两者都缺失 → CLI 启动报错
|
||||
|
||||
KimiSwitch 默认将凭证写入 `[providers.<name>.env]` 子表,使用官方约定键名,同时提供「使用直接字段」开关,允许用户将 `api_key` / `base_url` 写到 `[providers.<name>]` 顶层。UI 中两种方式互斥:
|
||||
|
||||
- **env 子表模式**:`api_key` / `base_url` 输入框的值映射到 `env` 中的约定键名;保存时顶层 `api_key` / `base_url` 字段被移除。
|
||||
- **直接字段模式**:`api_key` / `base_url` 写入顶层字段;保存时 `env` 中的对应约定键名被移除,其他自定义 `env` 键保留。
|
||||
|
||||
避免直接字段与 `env` 同时存在导致优先级迷惑。
|
||||
|
||||
各供应商的默认 Base URL 与 `env` 键名如下(参考 [Kimi Code CLI 平台与模型](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)):
|
||||
|
||||
| 供应商类型 | 默认 Base URL | API Key 键名 | Base URL 键名 | 特殊说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `KIMI_API_KEY` | `KIMI_BASE_URL` | 支持视频上传 |
|
||||
| `anthropic` | Anthropic SDK 默认 | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` | Claude 模型自动识别 thinking / 视觉 / 工具调用 |
|
||||
| `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | 兼容 DeepSeek、Qwen 等第三方网关 |
|
||||
| `openai_responses` | `https://api.openai.com/v1` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | 使用 OpenAI Responses API |
|
||||
| `google-genai` | `https://generativelanguage.googleapis.com` | `GOOGLE_API_KEY` | `GOOGLE_GEMINI_BASE_URL` | 只填主机根地址,不要带 `/v1beta` |
|
||||
| `vertexai` | 区域化 `*-aiplatform.googleapis.com` | 通常不需要(走 ADC) | `GOOGLE_VERTEX_BASE_URL` | 必须提供 `GOOGLE_CLOUD_PROJECT` 与 `GOOGLE_CLOUD_LOCATION`;只填主机根地址 |
|
||||
|
||||
创建供应商时,UI 根据所选 `type` 自动填入默认 Base URL 与推荐 `env` 键名,用户可手动覆盖。
|
||||
|
||||
### 7.6 Profile 生命周期
|
||||
|
||||
Profile 是 `~/.kimi-code/config.toml` 的命名快照,存放在 `~/.kimi-code/profiles/` 目录下。
|
||||
|
||||
#### 7.6.1 初始化
|
||||
|
||||
- 工具首次启动时,若 `~/.kimi-code/config.toml` 已存在,自动生成一个名为 `default` 的 Profile,其内容即为当前 `config.toml`。
|
||||
- 若 `config.toml` 不存在,创建一个空 `default` Profile。
|
||||
- `default` Profile 始终直接对应 `~/.kimi-code/config.toml`,不在 `profiles/` 下额外生成文件。
|
||||
|
||||
**文件名安全化**:Profile 显示名可包含中文、空格、大小写;存储时自动转译为合法 Windows 文件名。转译规则:保留 ASCII 字母数字,其他字符替换为 `-`,连续 `-` 合并,小写化;若结果为空(如纯中文名),则回退为基于名称哈希的短文件名(如 `profile-48291.toml`)。
|
||||
|
||||
#### 7.6.2 操作
|
||||
|
||||
| 操作 | 行为 |
|
||||
| --- | --- |
|
||||
| 新建 Profile | 用户输入显示名,自动生成安全文件名;可选择「从当前配置复制」或「空白」;保存到 `profiles/<safe-name>.toml`。 |
|
||||
| 重命名 Profile | 仅重命名非 `default` 的 Profile,修改显示名并重新生成安全文件名;原文件删除,新文件写入。 |
|
||||
| 复制 Profile | 以 `<原显示名> Copy` 创建副本,自动生成新的安全文件名。 |
|
||||
| 删除 Profile | 删除 `profiles/<safe-name>.toml`;`default` 不可删除。 |
|
||||
| 切换 Profile | 将选中的 Profile 内容写入 `~/.kimi-code/config.toml`(先备份当前 `config.toml`),然后重新加载界面。 |
|
||||
| 保存当前到 Profile | 把当前编辑内容导出为新的 Profile 文件,不影响当前激活状态。 |
|
||||
|
||||
#### 7.6.3 激活状态判定
|
||||
|
||||
- 工具启动时将 `config.toml` 解析为 `Config`,再与各 Profile 的 `Config` 做深度比较(而非字符串比较),语义一致即标记为 `is_active`。
|
||||
- 若没有任何 Profile 与当前 `config.toml` 语义一致,则 `default` Profile 标记为 `is_active`,并在界面上提示「当前 config.toml 与所有 Profile 不完全匹配」。
|
||||
|
||||
#### 7.6.4 未保存变更处理
|
||||
|
||||
- 切换 Profile 或退出前若存在未保存的编辑,弹出对话框:「保存当前修改 / 丢弃 / 取消」。
|
||||
- 标题栏或状态栏显示「已修改」指示器。
|
||||
|
||||
## 8. 界面设计
|
||||
|
||||
前端使用 React + TypeScript + Tailwind CSS 实现,主窗口采用三栏布局,顶部增加「快速切换」工具条。前端通过 Tauri `invoke` 调用 Rust 命令完成所有文件操作。
|
||||
|
||||
- **窗口约束**:最小宽度 1000px,最小高度 700px;标题栏显示 `[未保存] Profile 名 - KimiSwitch`。
|
||||
- **快捷键**:`Ctrl + S` 保存当前配置,`Ctrl + R` 重新读取,`Ctrl + O` 打开配置目录。第一版不单独做主题切换,跟随系统亮/暗模式。
|
||||
|
||||
### 8.1 顶部工具条
|
||||
|
||||
从左到右依次为:
|
||||
|
||||
- **Profile 下拉框**:列出所有 Profile(含 `default`),当前激活项高亮显示。
|
||||
- **Profile 操作按钮**:「新建」「复制」「重命名」「删除」「切换」。
|
||||
- **当前供应商** 下拉框:列出当前 Profile 中已配置供应商。
|
||||
- **当前模型** 下拉框:根据所选供应商,只列出引用该供应商的模型别名;若该供应商下没有模型,则提示「未绑定模型,请先在右侧添加」。
|
||||
- **设为默认** 按钮:将当前选中的模型写入 `default_model` 字段,并立即保存到当前 Profile 与 `config.toml`。
|
||||
- 启动时自动高亮当前 `default_model` 对应的供应商和模型。
|
||||
|
||||
### 8.2 左栏:供应商列表
|
||||
|
||||
- 显示所有 provider name 与 type。
|
||||
- 支持「添加」与「删除」按钮。
|
||||
- 切换左栏选中项时,顶部「当前供应商」下拉框同步联动,右栏模型列表按该供应商过滤。
|
||||
|
||||
### 8.3 中栏:供应商详情表单
|
||||
|
||||
- `type` 下拉选择框;切换时自动填充默认 `base_url` 与推荐 `env` 键名。
|
||||
- `base_url` 输入框。
|
||||
- `api_key` 输入框(默认掩码,可切换明文)。
|
||||
- 凭证存储方式单选:直接字段 / env 子表;两种方式互斥,保存时只写入被选中的那一种。
|
||||
- `env` 键值对表格(可增删改行)。
|
||||
|
||||
### 8.4 右栏:模型别名列表与详情
|
||||
|
||||
- 显示当前选中供应商下的 model alias(也可切换显示全部)。
|
||||
- 表单包含 `alias`、`provider` 下拉选择、`model`、`max_context_size`、`display_name`。
|
||||
- 提供「设为默认模型」按钮,等同于顶部快速切换区的「设为默认」。
|
||||
|
||||
### 8.5 状态与错误处理
|
||||
|
||||
- **未保存指示**:标题栏在配置有未保存变更时显示 `*` 前缀;状态栏显示「已修改」或「已保存」。
|
||||
- **切换/退出前检查**:存在未保存变更时,弹出「保存 / 丢弃 / 取消」三选一对话框。
|
||||
- **文件不存在**:首次启动或配置目录不存在时,自动创建 `~/.kimi-code/` 目录与空 `config.toml`,不报错。
|
||||
- **文件只读/权限不足**:保存失败时给出明确提示,并建议检查目录权限或以管理员身份运行。
|
||||
- **校验失败**:保存前校验不通过时,弹窗列出所有错误项,禁止写入。
|
||||
- **备份失败**:若备份步骤失败(如磁盘满),禁止覆盖原文件。
|
||||
- **多实例冲突**:启动时检测 `~/.kimi-code/.kimiswitch.lock`;若已存在且进程仍在运行,提示用户「KimiSwitch 已在运行,请勿多开」;若进程已消失则清理旧锁。保存时不强制全局锁,但建议单次会话内顺序执行写操作。
|
||||
|
||||
### 8.6 底部工具栏
|
||||
|
||||
- **读取配置**:从 `~/.kimi-code/config.toml` 重新加载。
|
||||
- **保存配置**:校验并写入当前 Profile 与 `config.toml`,失败时弹出错误提示。
|
||||
- **保存为新 Profile**:把当前编辑内容导出为新的 Profile 文件。
|
||||
- **打开配置目录**:调用系统文件管理器定位到数据目录。
|
||||
|
||||
## 9. 校验规则
|
||||
|
||||
保存前必须校验通过,否则禁止写入:
|
||||
|
||||
1. 每个 Provider 的 `name` 非空、不重复,且为合法 TOML 表键(必要时生成带引号的键)。
|
||||
2. 每个 Provider 的 `type` 必须是支持的类型之一。
|
||||
3. 每个 Provider 必须至少提供一种凭证来源,但 `vertexai` 例外(走 Google Cloud ADC,无需静态 API Key):
|
||||
- `api_key` 直接字段非空,或
|
||||
- `env` 中包含对应供应商的 API Key 键名。
|
||||
4. 凭证存储方式在直接字段与 `env` 子表之间互斥,保存时只写入选中的那一种。
|
||||
5. `vertexai` 类型的 Provider 必须在 `env` 中提供 `GOOGLE_CLOUD_PROJECT` 与 `GOOGLE_CLOUD_LOCATION`,不强制要求 API Key。
|
||||
6. 每个 Model 的 `alias` 非空、不重复,且为合法 TOML 表键(含 `.` 等字符时自动加引号)。
|
||||
7. 每个 Model 的 `provider` 必须引用一个已存在的 Provider。
|
||||
8. 每个 Model 的 `model` 非空,`max_context_size` 为正整数。
|
||||
|
||||
## 10. MSI 打包
|
||||
|
||||
### 10.1 打包流程
|
||||
|
||||
1. 安装依赖:`npm install` 与 `cargo fetch`
|
||||
2. 开发/构建前端:`npm run build`
|
||||
3. 构建并打包:`npm run tauri build`
|
||||
- Tauri 自动调用 `cargo build --release` 与 Windows bundler
|
||||
- 输出:`src-tauri/target/release/bundle/msi/KimiSwitch_<version>_x64_en-US.msi`
|
||||
|
||||
### 10.2 MSI 行为
|
||||
|
||||
- 默认安装目录:`C:\Program Files\KimiSwitch`( per-machine 安装,需管理员权限)。
|
||||
- 创建开始菜单快捷方式:`KimiSwitch`。
|
||||
- 安装程序不自动写入 `config.toml`,仅部署工具本身。
|
||||
- 支持标准卸载。
|
||||
- 可在 `tauri.conf.json` 中配置签名、版本、厂商信息、安装目录等。
|
||||
- 第一版仅构建 x64 架构 MSI,不构建 arm64 或 32 位版本。
|
||||
- 第一版**不启用 Tauri Updater**,后续版本通过重新安装 MSI 升级,降低第一版复杂度。
|
||||
|
||||
### 10.3 Windows 专项处理
|
||||
|
||||
- **路径解析**:使用 Rust `dirs::home_dir()` 获取用户主目录,拼接 `.kimi-code`,避免依赖 `$HOME` 环境变量(Windows 下可能不存在)。
|
||||
- **配置文件位置**:默认 `C:\Users\<用户名>\.kimi-code\config.toml`;若用户设置 `KIMI_CODE_HOME`,按该变量解析。
|
||||
- **权限**:工具运行时只读写用户目录下的文件,无需以管理员身份运行;仅 MSI 安装本身需要管理员权限。
|
||||
- **编码与换行**:TOML 文件统一使用 UTF-8;Windows 下换行保持为 `\r\n` 或 `\n` 均可,`toml_edit` 会保留原文件换行风格。
|
||||
- **代码签名(可选)**:如后续分发,建议为 MSI 配置代码签名证书,减少 Windows Defender SmartScreen 提示。
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
- **单元测试**(Rust):
|
||||
- `config_io.rs`:解析与生成 TOML 的 round-trip 测试。
|
||||
- `profile_manager.rs`:Profile 新建、复制、切换、激活状态判定测试。
|
||||
- `validators.rs`:校验规则的边界测试。
|
||||
- **手动测试**:
|
||||
- 启动 GUI,增删改供应商与模型。
|
||||
- 创建多个 Profile 并相互切换,验证 `config.toml` 内容正确变化。
|
||||
- 保存后使用 Kimi Code CLI 读取并验证可用性。
|
||||
- 在 Windows 上执行 MSI 安装与卸载。
|
||||
|
||||
## 12. 假设与约束
|
||||
|
||||
- **第一版目标平台仅为 Windows x64**,macOS / Linux 跨平台支持不在第一版范围内,待 Windows x64 版本完成后再规划。
|
||||
- API Key 按 Kimi Code CLI 官方方式明文存储在 `config.toml` 中,不引入额外加密。
|
||||
- 开发者已安装 Rust 工具链、Node.js 与 Tauri CLI(仅开发与打包时需要,最终用户不需要)。
|
||||
- 不覆盖 Kimi Code CLI 的自动更新机制或 OAuth 登录流程。
|
||||
- 最终用户运行 KimiSwitch 只需标准 Windows 用户权限,安装 MSI 时才需要管理员权限。
|
||||
|
||||
## 13. 参考文档
|
||||
|
||||
- [Kimi Code CLI 配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html)
|
||||
- [Kimi Code CLI 平台与模型](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)
|
||||
- [Kimi Code CLI 数据路径](https://www.kimi.com/code/docs/kimi-code-cli/configuration/data-locations.html)
|
||||
- [Kimi Code CLI 环境变量](https://www.kimi.com/code/docs/kimi-code-cli/configuration/env-vars.html)
|
||||
- [Kimi Code CLI 配置覆盖](https://www.kimi.com/code/docs/kimi-code-cli/configuration/overrides.html)
|
||||
@@ -1,47 +0,0 @@
|
||||
# Kimi Code 选项卡 /reload 提示设计
|
||||
|
||||
## 背景
|
||||
|
||||
用户在 Kimi Code 选项卡下切换供应商后,Kimi Code CLI 需要执行 `/reload` 才能加载新的提供方配置。当前界面没有给出该提示,用户容易遗漏这一步。
|
||||
|
||||
## 目标
|
||||
|
||||
在 Kimi Code 选项卡下,给每个供应商的"切换使用"按钮旁边增加一个信息提示,告知用户切换供应商后需要执行 `/reload`。
|
||||
|
||||
## 设计
|
||||
|
||||
### 位置与交互
|
||||
|
||||
- 在 `ProviderList` 组件中,每个供应商卡片右侧的"切换使用"按钮旁边添加一个信息图标 `ⓘ`。
|
||||
- 使用原生 `title` 属性展示 tooltip,鼠标悬停时显示提示文案。
|
||||
- 仅在当前选中 `agent === "kimi_code"` 时显示该图标;Pi 选项卡下不显示。
|
||||
|
||||
### 文案
|
||||
|
||||
- 中文:`切换供应商后,请在 Kimi Code 中执行 /reload 以生效。`
|
||||
- 英文:`After switching providers, run /reload in Kimi Code to apply the change.`
|
||||
|
||||
### 样式
|
||||
|
||||
- 图标大小约 14px,颜色为 `text-gray-500`,hover 时变为 `text-gray-300`。
|
||||
- 图标与"切换使用"按钮间距约 8px(`gap-2`)。
|
||||
- 不引入额外依赖或自定义 tooltip 组件。
|
||||
|
||||
### 组件与数据流
|
||||
|
||||
- `src/components/ProviderList.tsx`:
|
||||
- `ProviderListProps` 新增 `agent: Agent` 字段。
|
||||
- 根据 `agent` 决定是否渲染提示图标。
|
||||
- 图标 `title` 使用 `t("switchReloadHint")`。
|
||||
- `src/App.tsx`:
|
||||
- 渲染 `<ProviderList />` 时传入 `agent={agent}`。
|
||||
- `src/i18n/zh.ts` 与 `src/i18n/en.ts`:
|
||||
- 新增键 `switchReloadHint`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 切换到 Kimi Code 选项卡时,每个供应商的"切换使用"按钮旁出现 `ⓘ` 图标。
|
||||
- [ ] 鼠标悬停在图标上时,显示正确语言版本的 `/reload` 提示。
|
||||
- [ ] 切换到 Pi 选项卡时,该图标不显示。
|
||||
- [ ] 不引入新的运行时依赖。
|
||||
- [ ] TypeScript 编译与项目构建保持通过。
|
||||
@@ -1,253 +0,0 @@
|
||||
# Kimi Code 全局配置面板设计
|
||||
|
||||
## 背景
|
||||
|
||||
Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Kimi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在 Kimi Code 选项卡的供应商编辑页面(`ProviderEdit`)中,"模型映射"标签页下方新增一个"全局配置"面板,用图形化控件快速编辑上述 Kimi Code 全局设置。
|
||||
2. 根据模型 ID 自动为新添加/发现的模型设置默认 `max_context_size`,全局默认值为 256000,具体模型按可扩展的键值对表格匹配。
|
||||
|
||||
模型级的 `max_context_size` 已在模型映射表格中实现,用户仍可手动覆盖自动默认值。
|
||||
|
||||
## 设计
|
||||
|
||||
### 位置
|
||||
|
||||
- 组件:`src/components/ProviderEdit.tsx`
|
||||
- 仅在 `agent === "kimi_code"` 时显示。
|
||||
- 位于"模型映射"标签页的内容区域内,在模型映射表格和"+ 添加模型映射"按钮的下方。
|
||||
- 垂直排列,超出区域高度时由页面滚动条处理。
|
||||
|
||||
### 分组与控件
|
||||
|
||||
面板按功能分为 5 张卡片:
|
||||
|
||||
#### 卡片 1:思考模式 `[thinking]`
|
||||
|
||||
| 控件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 复选框 | `enabled` | 是否默认开启思考 |
|
||||
| 分段按钮 | `effort` | 思考强度:`low` / `medium` / `high` / `max` |
|
||||
| 复选框 | `keep` | 是否保留历史思考内容(`"all"` 或关值) |
|
||||
|
||||
- 思考等级和保留思考内容仅在"启用思考"勾选时可用,禁用时置灰。
|
||||
- "保留思考内容"未勾选时,写入 `[thinking].keep = false`(不能省略,否则 CLI 会回退到默认值 `"all"`)。
|
||||
- 卡片底部小字提示:"启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。"
|
||||
|
||||
#### 卡片 2:循环控制 `[loop_control]`
|
||||
|
||||
| 控件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 数字输入框 | `max_retries_per_step` | 单步失败后最大重试次数,默认 3 |
|
||||
| 数字输入框 | `reserved_context_size` | 预留给模型输出的 token 数,单位 token |
|
||||
|
||||
#### 卡片 3:后台任务 `[background]`
|
||||
|
||||
| 控件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 数字输入框 | `max_running_tasks` | 同时运行的最大后台任务数 |
|
||||
| 复选框 | `keep_alive_on_exit` | 会话关闭时是否保留后台任务 |
|
||||
|
||||
#### 卡片 4:权限规则 `[[permission.rules]]`
|
||||
|
||||
- 可添加/删除的规则列表,默认空列表。
|
||||
- 每行包含:
|
||||
- **处置** 下拉框:`allow` / `deny` / `ask`
|
||||
- **模式** 输入框:如 `Read`、`Bash(rm -rf*)`
|
||||
- **删除** 按钮
|
||||
- 提供"添加常用规则"快捷入口,可一键插入:
|
||||
- `allow` + `Read`
|
||||
- `deny` + `Bash(rm -rf*)`
|
||||
|
||||
#### 卡片 5:生命周期钩子 `[[hooks]]`
|
||||
|
||||
- 可添加/删除的钩子列表,默认空列表。
|
||||
- 每行包含:
|
||||
- **事件** 下拉框:常见事件如 `PreToolUse` / `PostToolUse`,也可手动输入其他事件
|
||||
- **匹配器** 输入框:如 `Bash`
|
||||
- **命令** 输入框:如 `node ~/.kimi-code/hooks/check-bash.mjs`
|
||||
- **超时** 数字输入框(秒)
|
||||
- **删除** 按钮
|
||||
|
||||
### 样式
|
||||
|
||||
- 卡片容器:`bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4`
|
||||
- 卡片间距:`gap-4`
|
||||
- 卡片标题:`text-gray-400 text-sm font-medium mb-3`
|
||||
- 表单项:`flex items-center gap-4 flex-wrap`
|
||||
- 数字输入框宽度:`w-32`
|
||||
- 禁用状态:文字 `text-gray-600`,按钮不可交互
|
||||
|
||||
### 字段默认值
|
||||
|
||||
当 `raw_other` 中不存在对应表时,控件按以下默认值展示:
|
||||
|
||||
| 配置块 | 字段 | UI 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `[thinking]` | `enabled` | `true` |
|
||||
| `[thinking]` | `effort` | `"medium"` |
|
||||
| `[thinking]` | `keep` | `"all"`(勾选) |
|
||||
| `[loop_control]` | `max_retries_per_step` | `3` |
|
||||
| `[loop_control]` | `reserved_context_size` | `50000` |
|
||||
| `[background]` | `max_running_tasks` | 空(不写入) |
|
||||
| `[background]` | `keep_alive_on_exit` | `false` |
|
||||
| `[[permission.rules]]` | — | 空列表 |
|
||||
| `[[hooks]]` | — | 空列表 |
|
||||
|
||||
数字输入框允许清空,表示不写入配置文件,由 Kimi Code CLI 使用其内置默认值。
|
||||
|
||||
### 数据流
|
||||
|
||||
- `App.tsx` 向 `ProviderEdit` 传入:
|
||||
- `agent`
|
||||
- `rawOther`(即 `config.raw_other`)
|
||||
- `updateRawOther: (updater: (rawOther: unknown) => unknown) => void`
|
||||
- `ProviderEdit` 中新增内部子组件 `AgentSettingsPanel`,封装所有全局配置 UI。
|
||||
- 新增 TypeScript 类型(`src/types/index.ts`):
|
||||
- `ThinkingConfig`
|
||||
- `LoopControlConfig`
|
||||
- `BackgroundConfig`
|
||||
- `PermissionRule`
|
||||
- `Hook`
|
||||
- `AgentSettings`
|
||||
- 新增 helper(`src/components/AgentSettingsPanel.tsx` 或 `src/lib/agent-settings.ts`):
|
||||
- `getAgentSettings(rawOther: unknown): AgentSettings`
|
||||
- `setAgentSettings(rawOther: unknown, patch: Partial<AgentSettings>): unknown`
|
||||
- 统一从 `raw_other` 中读取/写入 `[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]`。
|
||||
- 后端无需改动:这些顶层表原本就通过 `Config.raw_other` 在导入/导出时保留。
|
||||
|
||||
### 模型默认上下文长度
|
||||
|
||||
在新增模型(手动添加、一键设置、从 API 发现模型)时,根据模型 ID 自动推荐 `max_context_size`。
|
||||
|
||||
- 默认回退值:`256000`
|
||||
- 匹配方式:按模型 ID 字符串进行不区分大小写的正则前缀/全名匹配(代码中使用 `/pattern/i`),命中第一条规则即返回。
|
||||
- 规则表(按匹配优先级排列):
|
||||
|
||||
| 匹配规则(不区分大小写) | `max_context_size` | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **国内模型(优先)** | | |
|
||||
| `^kimi-for-coding$` | `262144` | Kimi Code 官方托管模型 |
|
||||
| `^kimi-k2\.5` | `256000` | Kimi K2.5 系列 |
|
||||
| `^kimi-k2` | `256000` | Kimi K2 系列 |
|
||||
| `^kimi-` | `256000` | 其他 Kimi 模型 |
|
||||
| `^glm-5\.2` | `1000000` | GLM-5.2 |
|
||||
| `^glm-5\.1` | `256000` | GLM-5.1 |
|
||||
| `^glm-5` | `256000` | GLM-5 系列 |
|
||||
| `^glm-4` | `128000` | GLM-4 / GLM-4-Plus / GLM-4-Flash |
|
||||
| `^glm-` | `128000` | 其他 GLM 模型 |
|
||||
| `^MiniMax-M3` | `1000000` | MiniMax-M3 |
|
||||
| `^MiniMax-Text-01` | `400000` | MiniMax-Text-01 |
|
||||
| `^MiniMax-` | `256000` | 其他 MiniMax 模型 |
|
||||
| `^qwen2\.5` | `128000` | 通义千问 Qwen2.5 系列 |
|
||||
| `^qwen-max` | `128000` | 通义千问 Max |
|
||||
| `^qwen-plus` | `128000` | 通义千问 Plus |
|
||||
| `^qwen-turbo` | `128000` | 通义千问 Turbo |
|
||||
| `^qwen-coder` | `128000` | 通义千问 Coder |
|
||||
| `^qwen-` | `128000` | 其他通义千问模型 |
|
||||
| `^deepseek-r1` | `64000` | DeepSeek-R1 |
|
||||
| `^deepseek-v3` | `64000` | DeepSeek-V3 |
|
||||
| `^deepseek-coder` | `64000` | DeepSeek-Coder |
|
||||
| `^deepseek-` | `64000` | 其他 DeepSeek 模型 |
|
||||
| `^hunyuan-pro` | `32000` | 腾讯 Hunyuan Pro |
|
||||
| `^hunyuan-standard` | `32000` | 腾讯 Hunyuan Standard |
|
||||
| `^hunyuan-lite` | `32000` | 腾讯 Hunyuan Lite |
|
||||
| `^hunyuan-` | `32000` | 其他 Hunyuan 模型 |
|
||||
| `^doubao-pro` | `128000` | 字节 Doubao Pro |
|
||||
| `^doubao-lite` | `128000` | 字节 Doubao Lite |
|
||||
| `^doubao-vision` | `128000` | 字节 Doubao Vision |
|
||||
| `^doubao-` | `128000` | 其他 Doubao 模型 |
|
||||
| `^ernie-4\.0` | `128000` | 百度文心 4.0 |
|
||||
| `^ernie-3\.5` | `128000` | 百度文心 3.5 |
|
||||
| `^ernie-speed` | `128000` | 百度文心 Speed |
|
||||
| `^ernie-lite` | `128000` | 百度文心 Lite |
|
||||
| `^ernie-` | `128000` | 其他文心模型 |
|
||||
| `^spark-v4` | `32000` | 讯飞星火 V4 |
|
||||
| `^spark-v3\.5` | `32000` | 讯飞星火 V3.5 |
|
||||
| `^spark-pro` | `32000` | 讯飞星火 Pro |
|
||||
| `^spark-max` | `32000` | 讯飞星火 Max |
|
||||
| `^spark-` | `32000` | 其他星火模型 |
|
||||
| `^sensechat-` | `128000` | 商汤 SenseChat |
|
||||
| `^baichuan-4` | `128000` | 百川 Baichuan 4 |
|
||||
| `^baichuan-3` | `128000` | 百川 Baichuan 3 |
|
||||
| `^baichuan-` | `128000` | 其他百川模型 |
|
||||
| `^yi-` | `128000` | 零一万物 Yi 系列 |
|
||||
| **国际模型** | | |
|
||||
| `^claude-opus` | `200000` | Claude Opus 系列(多数版本为 200K;Claude 4 部分版本可达 1M,这里取保守值) |
|
||||
| `^claude-sonnet` | `200000` | Claude Sonnet 系列 |
|
||||
| `^claude-haiku` | `200000` | Claude Haiku 系列 |
|
||||
| `^claude-` | `200000` | 其他 Claude 模型 |
|
||||
| `^gpt-4\.1` | `1047576` | GPT-4.1 系列(1M tokens) |
|
||||
| `^gpt-4o` | `128000` | GPT-4o 系列 |
|
||||
| `^gpt-4-turbo` | `128000` | GPT-4 Turbo |
|
||||
| `^gpt-4-` | `128000` | 其他 GPT-4 模型 |
|
||||
| `^gemini-2\.0-flash` | `1048576` | Gemini 2.0 Flash(1M tokens) |
|
||||
| `^gemini-1\.5-pro` | `2097152` | Gemini 1.5 Pro(2M tokens) |
|
||||
| `^gemini-1\.5-flash` | `1048576` | Gemini 1.5 Flash(1M tokens) |
|
||||
| `^gemini-` | `1048576` | 其他 Gemini 模型 |
|
||||
| (默认) | `256000` | 未命中任何规则 |
|
||||
|
||||
> 注:模型上下文长度取公开资料典型值,部分国内/国际模型存在多个版本导致数值差异,实际以供应商文档为准。规则表以代码常量形式存在,按 specificity 从高到低排列,命中第一条即返回,未命中时回退到 256000。用户仍可在模型映射表格中手动覆盖。
|
||||
|
||||
- 实现位置:`src/lib/model-defaults.ts`(或类似纯函数文件)。
|
||||
- 核心函数:
|
||||
```ts
|
||||
export function getDefaultMaxContextSize(modelId: string): number;
|
||||
```
|
||||
- 调用点:
|
||||
- `ProviderEdit` 中手动添加模型时。
|
||||
- `ProviderEdit` 中通过"获取模型列表"发现模型并添加时。
|
||||
- `handleAddProvider` / `handleApplyProviderJson` 等生成默认模型的地方,可一并使用此函数作为更智能的默认值。
|
||||
|
||||
### i18n 键
|
||||
|
||||
新增键:
|
||||
|
||||
- `agentSettings`: "全局配置"
|
||||
- `enableThinking`: "启用思考"
|
||||
- `thinkingLevel`: "思考等级"
|
||||
- `thinkingKeep`: "保留思考内容"
|
||||
- `thinkingLow`: "低"
|
||||
- `thinkingMedium`: "中"
|
||||
- `thinkingHigh`: "高"
|
||||
- `thinkingMax`: "最大"
|
||||
- `thinkingContextHint`: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。"
|
||||
- `loopControlSettings`: "循环控制"
|
||||
- `maxRetriesPerStep`: "单步重试次数"
|
||||
- `reservedContextSize`: "上下文预留大小"
|
||||
- `backgroundSettings`: "后台任务"
|
||||
- `maxRunningTasks`: "最大并发数"
|
||||
- `keepAliveOnExit`: "退出时保持运行"
|
||||
- `permissionRules`: "权限规则"
|
||||
- `permissionDecision`: "处置"
|
||||
- `permissionPattern`: "模式"
|
||||
- `permissionAllow`: "允许"
|
||||
- `permissionDeny`: "拒绝"
|
||||
- `permissionAsk`: "询问"
|
||||
- `addRule`: "+ 添加规则"
|
||||
- `addCommonRules`: "添加常用规则"
|
||||
- `hooks`: "生命周期钩子"
|
||||
- `hookEvent`: "事件"
|
||||
- `hookMatcher`: "匹配器"
|
||||
- `hookCommand`: "命令"
|
||||
- `hookTimeout`: "超时"
|
||||
- `addHook`: "+ 添加钩子"
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 在 Kimi Code 选项卡下进入供应商编辑页面,"模型映射"标签页下方出现"全局配置"区域。
|
||||
- [ ] Pi 选项卡下不显示该全局配置区域。
|
||||
- [ ] "启用思考"复选框控制 `[thinking].enabled`。
|
||||
- [ ] 启用思考后,"思考等级"和"保留思考内容"可用;未启用时禁用。
|
||||
- [ ] "思考等级"分段按钮写入 `[thinking].effort`。
|
||||
- [ ] "保留思考内容"写入 `[thinking].keep = "all"`,未勾选时写入关值。
|
||||
- [ ] `[loop_control]` 和 `[background]` 的数字/复选框正确读写。
|
||||
- [ ] 权限规则列表可增删改,导出为 `[[permission.rules]]`。
|
||||
- [ ] 钩子列表可增删改,导出为 `[[hooks]]`。
|
||||
- [ ] 保存配置后,Kimi Code 的 `config.toml` 中对应顶层表内容正确。
|
||||
- [ ] 不修改模型映射表格中已有的 `max_context_size` 手动编辑逻辑。
|
||||
- [ ] 添加新模型时,`max_context_size` 默认按规则表自动填充,匹配不区分大小写,未命中规则时为 256000。
|
||||
- [ ] 用户仍可在模型映射表格中手动覆盖自动填充的 `max_context_size`。
|
||||
- [ ] `npm run build` 通过。
|
||||
Generated
+12
-2
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kimiswitch",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -2173,6 +2174,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.2"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
+177
-63
@@ -211,6 +211,55 @@ pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredMo
|
||||
}
|
||||
}
|
||||
|
||||
/// Test reachability of a provider's base URL (cc-switch semantics):
|
||||
/// any HTTP response counts as reachable; only network-layer errors fail.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectivityResult {
|
||||
pub ok: bool,
|
||||
pub latency_ms: u64,
|
||||
pub status_code: Option<u16>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_connectivity(provider: Provider) -> Result<ConnectivityResult, String> {
|
||||
let base = resolve_base_url(&provider);
|
||||
if base.trim().is_empty() {
|
||||
return Ok(ConnectivityResult {
|
||||
ok: false,
|
||||
latency_ms: 0,
|
||||
status_code: None,
|
||||
error: Some("no base URL configured".to_string()),
|
||||
});
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
let start = std::time::Instant::now();
|
||||
match client.get(base.as_str()).send().await {
|
||||
Ok(resp) => Ok(ConnectivityResult {
|
||||
ok: true,
|
||||
latency_ms: start.elapsed().as_millis() as u64,
|
||||
status_code: Some(resp.status().as_u16()),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ConnectivityResult {
|
||||
ok: false,
|
||||
latency_ms: start.elapsed().as_millis() as u64,
|
||||
status_code: None,
|
||||
error: Some(if e.is_connect() {
|
||||
"connection refused / DNS failed".to_string()
|
||||
} else if e.is_timeout() {
|
||||
"request timed out".to_string()
|
||||
} else {
|
||||
e.to_string()
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_api_key(provider: &Provider) -> Option<String> {
|
||||
if provider.managed {
|
||||
return Some("managed".to_string());
|
||||
@@ -248,21 +297,9 @@ fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAI-compatible /models endpoint ──────────────────────────────
|
||||
// ── OpenAI-compatible /models endpoint (with pagination) ─────────────
|
||||
|
||||
async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!("{}/models", base.trim_end_matches('/'));
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OaiModel {
|
||||
id: String,
|
||||
@@ -270,16 +307,57 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OaiList {
|
||||
data: Vec<OaiModel>,
|
||||
#[serde(default)]
|
||||
has_more: Option<bool>,
|
||||
#[serde(default)]
|
||||
last_id: Option<String>,
|
||||
#[serde(default)]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
let body: OaiList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
let client = reqwest::Client::new();
|
||||
let root = base.trim_end_matches('/').to_string();
|
||||
let mut url = format!("{}/models", root);
|
||||
let mut all: Vec<OaiModel> = Vec::new();
|
||||
const MAX_PAGES: usize = 50;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
for _ in 0..MAX_PAGES {
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
let body: OaiList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
all.extend(body.data);
|
||||
|
||||
// OpenAI cursor pagination: has_more + last_id → ?after=<last_id>
|
||||
if body.has_more.unwrap_or(false) {
|
||||
if let Some(last_id) = body.last_id.clone() {
|
||||
url = format!("{}/models?after={}", root, last_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Token-based pagination: next_page_token → ?page_token=<token>
|
||||
if let Some(token) = body.next_page_token.clone() {
|
||||
if !token.is_empty() {
|
||||
url = format!("{}/models?page_token={}", root, token);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|m| seen.insert(m.id.clone()))
|
||||
.map(|m| DiscoveredModel {
|
||||
id: m.id,
|
||||
display_name: None,
|
||||
@@ -288,22 +366,9 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Anthropic /v1/models endpoint ───────────────────────────────────
|
||||
// ── Anthropic /v1/models endpoint (with pagination) ──────────────────
|
||||
|
||||
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!("{}/v1/models", base.trim_end_matches('/'));
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AntModel {
|
||||
id: String,
|
||||
@@ -312,16 +377,46 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AntList {
|
||||
data: Vec<AntModel>,
|
||||
#[serde(default)]
|
||||
has_more: Option<bool>,
|
||||
#[serde(default)]
|
||||
last_id: Option<String>,
|
||||
}
|
||||
|
||||
let body: AntList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
let client = reqwest::Client::new();
|
||||
let root = base.trim_end_matches('/').to_string();
|
||||
let mut url = format!("{}/v1/models?limit=1000", root);
|
||||
let mut all: Vec<AntModel> = Vec::new();
|
||||
const MAX_PAGES: usize = 20;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
for _ in 0..MAX_PAGES {
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
let body: AntList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
let more = body.has_more.unwrap_or(false);
|
||||
let cursor = body.last_id.clone();
|
||||
all.extend(body.data);
|
||||
if !(more && cursor.is_some()) {
|
||||
break;
|
||||
}
|
||||
url = format!("{}/v1/models?limit=1000&after_id={}", root, cursor.unwrap());
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|m| seen.insert(m.id.clone()))
|
||||
.map(|m| DiscoveredModel {
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
@@ -330,27 +425,12 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Google GenAI /v1beta/models endpoint ────────────────────────────
|
||||
// ── Google GenAI /v1beta/models endpoint (with pagination) ───────────
|
||||
|
||||
async fn fetch_google_genai_models(
|
||||
base: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!(
|
||||
"{}/v1beta/models?key={}",
|
||||
base.trim_end_matches('/'),
|
||||
api_key
|
||||
);
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GglModel {
|
||||
name: String,
|
||||
@@ -362,18 +442,52 @@ async fn fetch_google_genai_models(
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GglList {
|
||||
models: Vec<GglModel>,
|
||||
#[serde(rename = "nextPageToken", default)]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
let body: GglList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
let client = reqwest::Client::new();
|
||||
let root = base.trim_end_matches('/').to_string();
|
||||
let mut url = format!("{}/v1beta/models?key={}&pageSize=1000", root, api_key);
|
||||
let mut all: Vec<GglModel> = Vec::new();
|
||||
const MAX_PAGES: usize = 50;
|
||||
|
||||
Ok(body
|
||||
.models
|
||||
for _ in 0..MAX_PAGES {
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
let body: GglList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
let cursor = body.next_page_token.clone();
|
||||
all.extend(body.models);
|
||||
match cursor {
|
||||
Some(t) if !t.is_empty() => {
|
||||
url = format!(
|
||||
"{}/v1beta/models?key={}&pageSize=1000&pageToken={}",
|
||||
root, api_key, t
|
||||
);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|m| seen.insert(m.name.clone()))
|
||||
.map(|m| {
|
||||
let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_string();
|
||||
let id = m
|
||||
.name
|
||||
.strip_prefix("models/")
|
||||
.unwrap_or(&m.name)
|
||||
.to_string();
|
||||
DiscoveredModel {
|
||||
id,
|
||||
display_name: m.display_name,
|
||||
@@ -553,4 +667,4 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
||||
app.opener()
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,11 @@ pub struct DailyRow {
|
||||
pub cache_hit_rate: f64,
|
||||
/// Per-model token breakdown for stacked-bar rendering
|
||||
pub by_model: HashMap<String, u64>,
|
||||
/// Per-provider token breakdown for stacked-bar rendering
|
||||
pub by_provider: HashMap<String, u64>,
|
||||
/// Per-provider, per-model nested token breakdown (provider → model → tokens),
|
||||
/// used by the provider-tab drill-down detail.
|
||||
pub by_provider_model: HashMap<String, HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -353,6 +358,66 @@ fn is_kimi_home(dir: &Path) -> bool {
|
||||
dir.join("config.toml").exists() || dir.join("sessions").exists()
|
||||
}
|
||||
|
||||
/// Build a legacy alias→provider map by merging the current config.toml with every
|
||||
/// `config.toml.bak.*` snapshot (home root + backups/). Historical usage records may
|
||||
/// reference model aliases that no longer exist in the current config (old bare or
|
||||
/// `-N`-suffixed aliases); their provider is recovered from these snapshots.
|
||||
fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
|
||||
let mut map: HashMap<String, String> = HashMap::new();
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
let current = home.join("config.toml");
|
||||
if current.is_file() {
|
||||
candidates.push(current);
|
||||
}
|
||||
for dir in [home.to_path_buf(), home.join("backups")] {
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("config.toml.bak.") {
|
||||
candidates.push(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for path in candidates {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed: toml::Value = match toml::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(models) = parsed.get("models").and_then(|m| m.as_table()) {
|
||||
for (alias, m) in models {
|
||||
if let Some(p) = m.get("provider").and_then(|p| p.as_str()) {
|
||||
// Current config is parsed first and wins on conflict.
|
||||
map.entry(alias.clone()).or_insert_with(|| p.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Resolve the provider for a usage record's raw model key: prefer the `provider/`
|
||||
/// prefix, then fall back to the legacy alias→provider map (config + backups).
|
||||
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
|
||||
let p = model_raw
|
||||
.rsplit_once('/')
|
||||
.map(|(prov, _)| prov.to_string())
|
||||
.or_else(|| map.get(model_raw).cloned());
|
||||
p.map(|x| normalize_provider(&x))
|
||||
}
|
||||
|
||||
/// Normalize historical provider-name variants to the canonical name.
|
||||
fn normalize_provider(p: &str) -> String {
|
||||
match p {
|
||||
"CodingPlanSite" => "CodingPlan.site".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sessions_root(home: &Path) -> PathBuf {
|
||||
home.join("sessions")
|
||||
}
|
||||
@@ -444,6 +509,7 @@ fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create:
|
||||
|
||||
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
let root = sessions_root(home);
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let mut records = Vec::new();
|
||||
let mut files_scanned = 0;
|
||||
let mut lines_seen = 0;
|
||||
@@ -500,7 +566,7 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
model: model_raw.clone(),
|
||||
model_resolved: bare.to_string(),
|
||||
model_display: model_raw.clone(),
|
||||
provider: model_raw.rsplit_once('/').map(|x| x.0.to_string()),
|
||||
provider: resolve_provider(&model_raw, &alias2prov),
|
||||
from_env: model_raw == "__kimi_env_model__",
|
||||
input_other,
|
||||
output,
|
||||
@@ -544,16 +610,22 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
}
|
||||
|
||||
let mut totals = TotalsRow::default();
|
||||
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, u64>)> = HashMap::new();
|
||||
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, u64>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
|
||||
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||
|
||||
for r in &filtered {
|
||||
totals.add(r);
|
||||
let dk = day_key(r.time);
|
||||
let (day_totals, day_models) = by_day.entry(dk.clone()).or_default();
|
||||
let (day_totals, day_models, day_prov_models) = by_day.entry(dk.clone()).or_default();
|
||||
day_totals.add(r);
|
||||
*day_models.entry(r.model.clone()).or_insert(0) +=
|
||||
r.input_other + r.output + r.input_cache_read + r.input_cache_creation;
|
||||
let provider_key = r.provider.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
*day_prov_models
|
||||
.entry(provider_key)
|
||||
.or_default()
|
||||
.entry(r.model.clone())
|
||||
.or_insert(0) += r.input_other + r.output + r.input_cache_read + r.input_cache_creation;
|
||||
|
||||
let mk = r.model.clone();
|
||||
let entry = by_model.entry(mk.clone()).or_insert_with(|| {
|
||||
@@ -577,7 +649,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
totals.cache_hit_rate = if total_input > 0 { totals.input_cache_read as f64 / total_input as f64 } else { 0.0 };
|
||||
|
||||
let mut daily: Vec<DailyRow> = by_day.into_iter()
|
||||
.map(|(date, (t, by_model))| {
|
||||
.map(|(date, (t, by_model, by_provider_model))| {
|
||||
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
|
||||
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
|
||||
DailyRow {
|
||||
@@ -586,6 +658,11 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
input_cache_read: t.input_cache_read, input_cache_creation: t.input_cache_creation,
|
||||
cost_usd: t.cost_usd, total_tokens: t.total_tokens, cache_hit_rate: ch,
|
||||
by_model,
|
||||
by_provider: by_provider_model
|
||||
.iter()
|
||||
.map(|(p, m)| (p.clone(), m.values().sum::<u64>()))
|
||||
.collect(),
|
||||
by_provider_model,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
+10
-13
@@ -65,27 +65,23 @@ pub fn run() {
|
||||
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();
|
||||
}
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Minimize to tray: restore then hide so the taskbar button disappears
|
||||
// Close button -> hide to tray instead of quitting. Minimize keeps
|
||||
// the taskbar button (native behaviour); only an explicit close
|
||||
// (window X) retreats to the tray.
|
||||
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();
|
||||
}
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
let _ = window_clone.hide();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
@@ -99,6 +95,7 @@ pub fn run() {
|
||||
commands::open_agent_config_dir,
|
||||
commands::get_app_version,
|
||||
commands::list_provider_models,
|
||||
commands::test_connectivity,
|
||||
commands::debug_log,
|
||||
commands::get_app_setting,
|
||||
commands::set_app_setting,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.2",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+34
@@ -205,6 +205,39 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateProvider = async (name: string) => {
|
||||
updateConfig((cfg) => {
|
||||
const src = cfg.providers[name];
|
||||
if (!src) return cfg;
|
||||
let newName = `${name}-copy`;
|
||||
let n = 2;
|
||||
while (cfg.providers[newName]) {
|
||||
newName = `${name}-copy-${n}`;
|
||||
n++;
|
||||
}
|
||||
// Clone the provider (deactivated, clear its remembered default model
|
||||
// since the model aliases will be re-keyed to the new name below).
|
||||
const copied = setProviderDefaultModel(
|
||||
{ ...src, name: newName, active: false },
|
||||
null
|
||||
);
|
||||
const providers = { ...cfg.providers, [newName]: copied };
|
||||
// Re-key this provider's models to the new provider name so the copy
|
||||
// has its own independent model set.
|
||||
const models = { ...cfg.models };
|
||||
for (const [alias, m] of Object.entries(cfg.models)) {
|
||||
if (m.provider === name) {
|
||||
const newAlias = newName + alias.slice(name.length);
|
||||
models[newAlias] = { ...m, alias: newAlias, provider: newName };
|
||||
}
|
||||
}
|
||||
return { ...cfg, providers, models };
|
||||
});
|
||||
await save();
|
||||
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
|
||||
setTimeout(() => setSwitchMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleSetDefaultModel = (alias: string) => {
|
||||
updateConfig((cfg) => {
|
||||
const model = cfg.models[alias];
|
||||
@@ -458,6 +491,7 @@ export default function App() {
|
||||
setView("edit");
|
||||
}}
|
||||
onDelete={handleDeleteProvider}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onAdd={handleAddProvider}
|
||||
onSwitchProvider={handleSwitchProvider}
|
||||
agent={agent}
|
||||
|
||||
@@ -598,7 +598,7 @@ function ModelMapping({
|
||||
<th className="text-center px-4 py-3 font-medium w-20">{t("operation")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#2a2a2e]">
|
||||
<tbody className="divide-y divide-border">
|
||||
{models.map((m) => (
|
||||
<tr key={m.alias} className="hover:bg-hover">
|
||||
<td className="px-4 py-2">
|
||||
@@ -776,8 +776,6 @@ function CapabilitiesCell({
|
||||
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) {
|
||||
@@ -789,7 +787,13 @@ function CapabilitiesCell({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
{KNOWN_CAPABILITIES.map((cap) => (
|
||||
{/* Only `thinking` is user-editable. The other capabilities
|
||||
(always_thinking / image_in / video_in / tool_use) are still
|
||||
auto-derived from models.dev via `capabilitiesFromRef` and saved
|
||||
to config.toml — they just aren't exposed for manual editing.
|
||||
To restore the full editor, drop the `.filter(...)` below and
|
||||
re-add the custom-capabilities input (see git history). */}
|
||||
{KNOWN_CAPABILITIES.filter((cap) => cap === "thinking").map((cap) => (
|
||||
<label
|
||||
key={cap}
|
||||
className="flex items-center gap-1 text-xs text-content-muted cursor-pointer"
|
||||
@@ -803,19 +807,6 @@ function CapabilitiesCell({
|
||||
{t(CAPABILITY_LABELS[cap])}
|
||||
</label>
|
||||
))}
|
||||
<input
|
||||
className="w-28 bg-transparent border border-border 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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import type { Agent, Model, Provider } from "../types";
|
||||
|
||||
interface ConnectivityResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
statusCode?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
status: "testing" | "ok" | "fail";
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Provider[];
|
||||
defaultModel: string | null;
|
||||
models: Record<string, Model>;
|
||||
onEdit: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onDuplicate: (name: string) => void;
|
||||
onAdd: () => void;
|
||||
onSwitchProvider: (name: string) => void;
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
const iconBtn =
|
||||
"w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors";
|
||||
|
||||
export function ProviderList({
|
||||
providers,
|
||||
defaultModel,
|
||||
models,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onAdd,
|
||||
onSwitchProvider,
|
||||
agent,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [testState, setTestState] = useState<Record<string, TestState>>({});
|
||||
|
||||
const handleTest = async (provider: Provider) => {
|
||||
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
|
||||
try {
|
||||
const result = await invoke<ConnectivityResult>("test_connectivity", { provider });
|
||||
setTestState((s) => ({
|
||||
...s,
|
||||
[provider.name]: {
|
||||
status: result.ok ? "ok" : "fail",
|
||||
latency: result.latencyMs,
|
||||
error: result.error ?? undefined,
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
setTestState((s) => ({
|
||||
...s,
|
||||
[provider.name]: {
|
||||
status: "fail",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
}));
|
||||
}
|
||||
// auto-clear the inline result after a few seconds
|
||||
setTimeout(() => {
|
||||
setTestState((s) => {
|
||||
const next = { ...s };
|
||||
delete next[provider.name];
|
||||
return next;
|
||||
});
|
||||
}, 6000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
@@ -85,6 +139,7 @@ export function ProviderList({
|
||||
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
||||
: null;
|
||||
const isActive = provider.active === true;
|
||||
const ts = testState[provider.name];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -139,6 +194,27 @@ export function ProviderList({
|
||||
{t("defaultModel", { name: defaultModelName ?? "" })}
|
||||
</span>
|
||||
)}
|
||||
{/* inline connectivity test result */}
|
||||
{ts && ts.status !== "testing" && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
|
||||
ts.status === "ok"
|
||||
? ts.latency && ts.latency > 6000
|
||||
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
|
||||
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
|
||||
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
|
||||
}`}
|
||||
title={
|
||||
ts.status === "fail"
|
||||
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
|
||||
: ts.latency && ts.latency > 6000
|
||||
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
|
||||
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
|
||||
}
|
||||
>
|
||||
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -162,15 +238,48 @@ export function ProviderList({
|
||||
ⓘ
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* icon button group */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(provider.name);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
title={t("edit")}
|
||||
className={iconBtn}
|
||||
aria-label={t("edit")}
|
||||
>
|
||||
{t("edit")}
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate(provider.name);
|
||||
}}
|
||||
title={t("copyProvider")}
|
||||
className={iconBtn}
|
||||
aria-label={t("copyProvider")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTest(provider);
|
||||
}}
|
||||
disabled={ts?.status === "testing"}
|
||||
title={t("testConnectivity")}
|
||||
className={`${iconBtn} disabled:opacity-50`}
|
||||
aria-label={t("testConnectivity")}
|
||||
>
|
||||
{ts?.status === "testing" ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Activity className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,9 +287,11 @@ export function ProviderList({
|
||||
e.stopPropagation();
|
||||
onDelete(provider.name);
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
|
||||
title={t("delete")}
|
||||
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
{t("delete")}
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +303,7 @@ export function SettingsModal({
|
||||
)}
|
||||
{updateInfo?.updateAvailable && (
|
||||
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
|
||||
✦ {t("updateAvailable")}: v{updateInfo.latest}
|
||||
✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)}
|
||||
</div>
|
||||
)}
|
||||
{updateInfo && !updateInfo.updateAvailable && (
|
||||
|
||||
@@ -5,13 +5,19 @@ import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { DailyDetailModal } from "./DailyDetailModal";
|
||||
|
||||
type TrendDimension = "model" | "provider";
|
||||
|
||||
interface DailyBarsProps {
|
||||
daily: DailyRow[];
|
||||
/** Ordered model names for the colour palette & legend */
|
||||
modelNames: string[];
|
||||
/** Which per-day breakdown to render */
|
||||
dimension: TrendDimension;
|
||||
/** Ordered names for the active dimension (colour palette & legend) */
|
||||
names: string[];
|
||||
/** Label for the unresolved-provider bucket */
|
||||
unknownProviderLabel: string;
|
||||
}
|
||||
|
||||
/** Deterministic colour palette — one hue per model index */
|
||||
/** Deterministic colour palette — one hue per index */
|
||||
const PALETTE = [
|
||||
"#3b82f6", // blue
|
||||
"#22c55e", // green
|
||||
@@ -35,17 +41,24 @@ function shortModel(model: string): string {
|
||||
return bare;
|
||||
}
|
||||
|
||||
export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<DailyRow | null>(null);
|
||||
|
||||
const colorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
modelNames.forEach((m, i) => {
|
||||
names.forEach((m, i) => {
|
||||
map[m] = modelColor(i);
|
||||
});
|
||||
return map;
|
||||
}, [modelNames]);
|
||||
}, [names]);
|
||||
|
||||
const displayName = (key: string): string => {
|
||||
if (dimension === "provider") {
|
||||
return key === "unknown" ? unknownProviderLabel : key;
|
||||
}
|
||||
return shortModel(key);
|
||||
};
|
||||
|
||||
if (!daily?.length) {
|
||||
return (
|
||||
@@ -61,13 +74,13 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{modelNames.map((m) => (
|
||||
{names.map((m) => (
|
||||
<span key={m} className="flex items-center gap-1.5 text-[11px] text-content-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: colorMap[m] }}
|
||||
/>
|
||||
{shortModel(m)}
|
||||
{displayName(m)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -78,22 +91,20 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
|
||||
const total = d.totalTokens || 1;
|
||||
|
||||
// Build stacked segments sorted by token count (largest at bottom)
|
||||
const segments = Object.entries(d.byModel || {})
|
||||
.map(([model, tokens]) => ({
|
||||
model,
|
||||
const breakdown = dimension === "provider" ? d.byProvider : d.byModel;
|
||||
const segments = Object.entries(breakdown || {})
|
||||
.map(([key, tokens]) => ({
|
||||
key,
|
||||
tokens,
|
||||
color: colorMap[model] || "#6b7280",
|
||||
color: colorMap[key] || "#6b7280",
|
||||
height: Math.max(1, Math.round((tokens / total) * h)),
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
const tooltipLines = [
|
||||
d.date,
|
||||
`总 Token: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · hit ${fmtPct(d.cacheHitRate)}`,
|
||||
...segments.map(
|
||||
(s) => `${shortModel(s.model)}: ${fmtTokens(s.tokens)}`
|
||||
),
|
||||
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
|
||||
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
|
||||
"",
|
||||
t("doubleClickHint"),
|
||||
].join("\n");
|
||||
@@ -112,7 +123,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.model}
|
||||
key={s.key}
|
||||
className="w-full transition-opacity group-hover:opacity-80"
|
||||
style={{
|
||||
height: s.height,
|
||||
@@ -133,11 +144,13 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
createPortal(
|
||||
<DailyDetailModal
|
||||
day={selected}
|
||||
dimension={dimension}
|
||||
colorMap={colorMap}
|
||||
unknownProviderLabel={unknownProviderLabel}
|
||||
onClose={() => setSelected(null)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { modelColor } from "./DailyBars";
|
||||
|
||||
type TrendDimension = "model" | "provider";
|
||||
|
||||
interface DailyDetailModalProps {
|
||||
day: DailyRow;
|
||||
/** model name → colour hex */
|
||||
dimension: TrendDimension;
|
||||
/** active-dimension name → colour hex (models in model tab, providers in provider tab) */
|
||||
colorMap: Record<string, string>;
|
||||
unknownProviderLabel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -16,10 +20,9 @@ function shortModel(model: string): string {
|
||||
return bare;
|
||||
}
|
||||
|
||||
export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalProps) {
|
||||
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -28,7 +31,6 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Lock background scroll while modal is open
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -38,15 +40,35 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
|
||||
}, []);
|
||||
|
||||
const total = day.totalTokens || 1;
|
||||
const segments = Object.entries(day.byModel || {})
|
||||
const isProvider = dimension === "provider";
|
||||
|
||||
// Flat per-model breakdown (model tab)
|
||||
const modelSegments = Object.entries(day.byModel || {})
|
||||
.map(([model, tokens]) => ({
|
||||
model,
|
||||
tokens,
|
||||
pct: (tokens / total) * 100,
|
||||
color: colorMap[model] || "#6b7280",
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
// Two-level provider → model breakdown (provider tab)
|
||||
const providerGroups = Object.entries(day.byProviderModel || {})
|
||||
.map(([prov, models]) => {
|
||||
const provTotal = Object.values(models).reduce((a, b) => a + b, 0);
|
||||
const modelList = Object.entries(models)
|
||||
.map(([model, tokens]) => ({ model, tokens }))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
return {
|
||||
provider: prov,
|
||||
displayProvider: prov === "unknown" ? unknownProviderLabel : prov,
|
||||
total: provTotal,
|
||||
pct: (provTotal / total) * 100,
|
||||
color: colorMap[prov] || "#6b7280",
|
||||
models: modelList,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
@@ -75,17 +97,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
|
||||
aria-label={t("close")}
|
||||
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -93,73 +105,80 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
|
||||
|
||||
{/* Day summary chips */}
|
||||
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
||||
<SummaryChip
|
||||
label={t("totalTokens")}
|
||||
value={fmtTokens(day.totalTokens)}
|
||||
tone="default"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("cost")}
|
||||
value={fmtUsd(day.costUsd)}
|
||||
tone="orange"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("requests")}
|
||||
value={Math.round(day.requests).toLocaleString()}
|
||||
tone="blue"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("cacheHitRate")}
|
||||
value={fmtPct(day.cacheHitRate)}
|
||||
tone="green"
|
||||
/>
|
||||
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
|
||||
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
|
||||
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
|
||||
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
|
||||
</div>
|
||||
|
||||
{/* Per-model breakdown */}
|
||||
{/* Breakdown — flat by model, or two-level by provider → model */}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
|
||||
{t("modelDistribution")} · {segments.length}
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-content-muted">
|
||||
{t("noData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
|
||||
{segments.map((s, i) => (
|
||||
<div key={s.model} className="flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: modelColor(i) }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className="truncate text-content-primary"
|
||||
title={s.model}
|
||||
>
|
||||
{shortModel(s.model)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-content-muted tabular-nums">
|
||||
{fmtTokens(s.tokens)}{" "}
|
||||
<span className="text-content-muted">
|
||||
· {s.pct.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
{isProvider ? (
|
||||
<>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
|
||||
{t("providerDistribution")} · {providerGroups.length}
|
||||
</div>
|
||||
{providerGroups.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto pr-1">
|
||||
{providerGroups.map((p) => (
|
||||
<div key={p.provider}>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: p.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-content-primary" title={p.provider}>{p.displayProvider}</span>
|
||||
<span className="shrink-0 text-xs text-content-muted tabular-nums">
|
||||
{fmtTokens(p.total)} <span className="text-content-muted">· {p.pct.toFixed(1)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.max(2, p.pct)}%`, backgroundColor: p.color }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 ml-5 space-y-0.5">
|
||||
{p.models.map((m) => (
|
||||
<div key={m.model} className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="truncate text-content-muted" title={m.model}>{shortModel(m.model)}</span>
|
||||
<span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.max(2, s.pct)}%`,
|
||||
backgroundColor: modelColor(i),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
|
||||
{t("modelDistribution")} · {modelSegments.length}
|
||||
</div>
|
||||
{modelSegments.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
|
||||
{modelSegments.map((s, i) => (
|
||||
<div key={s.model} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-content-primary" title={s.model}>{shortModel(s.model)}</span>
|
||||
<span className="shrink-0 text-xs text-content-muted tabular-nums">
|
||||
{fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${Math.max(2, s.pct)}%`, backgroundColor: modelColor(i) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -172,31 +191,16 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryChip({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "default" | "orange" | "blue" | "green";
|
||||
}) {
|
||||
function SummaryChip({ label, value, tone }: { label: string; value: string; tone: "default" | "orange" | "blue" | "green" }) {
|
||||
const valueClass =
|
||||
tone === "orange"
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: tone === "blue"
|
||||
? "text-blue-400"
|
||||
: tone === "green"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-content-primary";
|
||||
tone === "orange" ? "text-orange-600 dark:text-orange-400"
|
||||
: tone === "blue" ? "text-blue-400"
|
||||
: tone === "green" ? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-content-primary";
|
||||
return (
|
||||
<div className="rounded-md bg-hover px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-content-muted">
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-content-muted">{label}</div>
|
||||
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
|
||||
const RANGES: { value: DashboardRange; label: string }[] = [
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "7d", label: "7 天" },
|
||||
{ value: "30d", label: "30 天" },
|
||||
{ value: "all", label: "全部" },
|
||||
];
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
|
||||
function Card({
|
||||
title,
|
||||
@@ -22,12 +18,12 @@ function Card({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-panel">
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
<div className="flex-1 p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,9 +41,18 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
"7d": t("range7d"),
|
||||
"30d": t("range30d"),
|
||||
all: t("rangeAll"),
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
@@ -61,13 +66,13 @@ export function DashboardPage() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 mb-2">加载失败</div>
|
||||
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
|
||||
<div className="text-sm text-content-muted">{error}</div>
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
|
||||
>
|
||||
重试
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,16 +99,26 @@ export function DashboardPage() {
|
||||
|
||||
// Ordered model names for chart colour mapping
|
||||
const modelNames = models.map((m) => m.model);
|
||||
const trendDaily = daily.length > 30 ? daily.slice(-30) : daily;
|
||||
const providerTotals: Record<string, number> = {};
|
||||
for (const d of trendDaily) {
|
||||
for (const [p, tok] of Object.entries(d.byProvider || {})) {
|
||||
providerTotals[p] = (providerTotals[p] || 0) + tok;
|
||||
}
|
||||
}
|
||||
const providerNames = Object.entries(providerTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([p]) => p);
|
||||
|
||||
const kpiItems = [
|
||||
{ label: "请求数", value: fmtInt(totals.requests) },
|
||||
{ label: "非缓存输入", value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
|
||||
{ label: "输出", value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
|
||||
{ label: "缓存读", value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
|
||||
{ label: "缓存创建", value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||
{ label: "缓存命中", value: fmtPct(totals.cacheHitRate) },
|
||||
{ label: "总 Token", value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||
{ label: "预估费用", value: fmtUsd(totals.costUsd) },
|
||||
{ label: t("kpiRequests"), value: fmtInt(totals.requests) },
|
||||
{ label: t("kpiInputOther"), value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
|
||||
{ label: t("kpiOutput"), value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
|
||||
{ label: t("kpiCacheRead"), value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
|
||||
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
|
||||
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||
{ label: t("kpiCost"), value: fmtUsd(totals.costUsd) },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -113,16 +128,16 @@ export function DashboardPage() {
|
||||
<div className="flex items-center bg-input border border-border rounded p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => changeRange(r.value)}
|
||||
onClick={() => changeRange(r)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
range === r.value
|
||||
range === r
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,33 +147,33 @@ export function DashboardPage() {
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
|
||||
>
|
||||
{loading ? "刷新中…" : "↻ 刷新"}
|
||||
{loading ? t("refreshing") : `↻ ${t("refresh")}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Range overview cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{RANGES.map((r) => {
|
||||
const tot = rangeTotals[r.value];
|
||||
const tot = rangeTotals[r];
|
||||
if (!tot) return null;
|
||||
const active = range === r.value;
|
||||
const active = range === r;
|
||||
return (
|
||||
<button
|
||||
key={r.value}
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => changeRange(r.value)}
|
||||
onClick={() => changeRange(r)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
active
|
||||
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
|
||||
: "border-border bg-panel hover:border-strong hover:bg-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-content-muted">{r.label}</div>
|
||||
<div className="text-xs text-content-muted">{RANGE_LABELS[r]}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-content-primary tracking-tight">
|
||||
{fmtTokens(tot.totalTokens)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-content-muted">
|
||||
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -173,41 +188,72 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||
<Card title="全年热力图" subtitle="按 Token 量着色">
|
||||
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
||||
<Heatmap heatmap={heatmap} />
|
||||
</Card>
|
||||
|
||||
{/* Charts + Model side table */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<Card
|
||||
title="每日用量趋势"
|
||||
subtitle={
|
||||
daily.length > 30
|
||||
? `近 30 天 · 共 ${daily.length} 天`
|
||||
: `按模型着色 · ${daily.length} 天`
|
||||
}
|
||||
>
|
||||
<div className="h-full min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={daily.length > 30 ? daily.slice(-30) : daily}
|
||||
modelNames={modelNames}
|
||||
/>
|
||||
{/* Usage-trend tab container */}
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
{/* Tab bar header */}
|
||||
<div className="flex items-end justify-between border-b border-border px-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("model")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "model"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabModelTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("provider")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "provider"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabProviderTrend")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
{/* Active tab content */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
|
||||
<Card title={t("cardModelUsage")} subtitle={t("cardModelUsageSub", { n: models.length })}>
|
||||
{models.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-3 font-normal">模型</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">请求</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">Token</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">缓存命中</th>
|
||||
<th className="pb-2 font-normal text-right">费用</th>
|
||||
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">{t("requests")}</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">{t("colTokens")}</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">{t("colCacheHit")}</th>
|
||||
<th className="pb-2 font-normal text-right">{t("cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -228,9 +274,9 @@ export function DashboardPage() {
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-content-primary truncate">{m.modelDisplay || m.model}</div>
|
||||
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
|
||||
{m.costEstimated && (
|
||||
<span className="text-[10px] text-yellow-600">估算</span>
|
||||
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +295,7 @@ export function DashboardPage() {
|
||||
onClick={() => setShowAllModels(!showAllModels)}
|
||||
className="mt-3 text-sm text-blue-500 hover:text-blue-400"
|
||||
>
|
||||
{showAllModels ? "收起" : `展开全部 (${models.length})`}
|
||||
{showAllModels ? t("collapse") : t("expandAll", { n: models.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,27 +305,27 @@ export function DashboardPage() {
|
||||
|
||||
{/* Recent requests */}
|
||||
<Card
|
||||
title="最近请求"
|
||||
title={t("cardRecent")}
|
||||
subtitle={
|
||||
recentTotal > recent.length
|
||||
? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条`
|
||||
: `${recent.length} 条`
|
||||
? t("cardRecentCapped", { n: recent.length, m: recentTotal })
|
||||
: t("cardRecentCount", { n: recent.length })
|
||||
}
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-4 font-normal">时间</th>
|
||||
<th className="pb-2 pr-4 font-normal">模型</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">输入</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">输出</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">缓存读</th>
|
||||
<th className="pb-2 font-normal text-right">费用</th>
|
||||
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
||||
<th className="pb-2 pr-4 font-normal">{t("colModel")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
|
||||
<th className="pb-2 font-normal text-right">{t("cost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -302,9 +348,12 @@ export function DashboardPage() {
|
||||
{/* Pagination footer */}
|
||||
<div className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2 text-xs text-content-muted">
|
||||
<span>
|
||||
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
|
||||
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
|
||||
每页 {RECENT_PAGE_SIZE} 条
|
||||
{t("paginationPage", { x: recentCurrentPage, y: recentTotalPages })} ·{" "}
|
||||
{t("paginationRange", {
|
||||
x: recentStart + 1,
|
||||
y: Math.min(recentStart + RECENT_PAGE_SIZE, recent.length),
|
||||
})}{" "}
|
||||
· {t("paginationPerPage", { n: RECENT_PAGE_SIZE })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -312,7 +361,7 @@ export function DashboardPage() {
|
||||
disabled={recentCurrentPage <= 1}
|
||||
onClick={() => setRecentPage(1)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="首页"
|
||||
aria-label={t("pageFirst")}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
@@ -321,7 +370,7 @@ export function DashboardPage() {
|
||||
disabled={recentCurrentPage <= 1}
|
||||
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="上一页"
|
||||
aria-label={t("pagePrev")}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
@@ -330,7 +379,7 @@ export function DashboardPage() {
|
||||
disabled={recentCurrentPage >= recentTotalPages}
|
||||
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="下一页"
|
||||
aria-label={t("pageNext")}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
@@ -339,7 +388,7 @@ export function DashboardPage() {
|
||||
disabled={recentCurrentPage >= recentTotalPages}
|
||||
onClick={() => setRecentPage(recentTotalPages)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="末页"
|
||||
aria-label={t("pageLast")}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
@@ -351,10 +400,10 @@ export function DashboardPage() {
|
||||
|
||||
{/* Attribution footer */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
<br />
|
||||
仪表盘功能基于{" "}
|
||||
{t("dashboardAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
@@ -362,7 +411,7 @@ export function DashboardPage() {
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
(MIT,© JochenYang)移植
|
||||
{t("attributionSuffix")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -548,7 +548,7 @@ export function SessionsPage() {
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{data?.home && <>{data.home}</>}
|
||||
<br />
|
||||
会话管理功能基于{" "}
|
||||
{t("sessionsAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
@@ -556,7 +556,7 @@ export function SessionsPage() {
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
(MIT,© JochenYang)移植
|
||||
{t("attributionSuffix")}
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
@@ -721,6 +721,7 @@ function PreviewMessageCard({
|
||||
text: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > PREVIEW_TEXT_LIMIT;
|
||||
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
|
||||
@@ -754,8 +755,8 @@ function PreviewMessageCard({
|
||||
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{expanded
|
||||
? `收起 · ${text.length.toLocaleString()} 字符`
|
||||
: `展开全文 · ${text.length.toLocaleString()} 字符`}
|
||||
? t("previewCollapseChars", { n: text.length.toLocaleString() })
|
||||
: t("previewExpandChars", { n: text.length.toLocaleString() })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -10,7 +10,10 @@ export interface UpdateInfo {
|
||||
}
|
||||
|
||||
const THROTTLE_KEY = "kimi-switch-last-update-check";
|
||||
const THROTTLE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
// Both the throttle window and the periodic re-check run on an 8h cadence:
|
||||
// long-running sessions still pick up new releases without a restart, and the
|
||||
// actual backend call is still gated so short windows don't double-fire.
|
||||
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
export function useUpdateCheck() {
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
@@ -26,7 +29,7 @@ export function useUpdateCheck() {
|
||||
|
||||
const doCheck = useCallback(async (force: boolean) => {
|
||||
if (!force) {
|
||||
if (lastChecked && Date.now() - lastChecked < THROTTLE_MS) return;
|
||||
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
|
||||
}
|
||||
setChecking(true);
|
||||
try {
|
||||
@@ -40,16 +43,26 @@ export function useUpdateCheck() {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// silently fail — user can retry from Settings
|
||||
// silently fail — network errors, parse errors, etc. should not nag
|
||||
// the user. They can still trigger a manual check from Settings.
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [lastChecked]);
|
||||
|
||||
// On mount: throttled auto-check
|
||||
// Keep a ref to the latest doCheck so the periodic interval doesn't need
|
||||
// to be reset on every lastChecked change.
|
||||
const doCheckRef = useRef(doCheck);
|
||||
useEffect(() => {
|
||||
doCheck(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
doCheckRef.current = doCheck;
|
||||
}, [doCheck]);
|
||||
|
||||
// On mount: throttled auto-check + periodic re-check while the window
|
||||
// stays open. Errors are silent (handled inside doCheck).
|
||||
useEffect(() => {
|
||||
doCheckRef.current(false);
|
||||
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(() => doCheck(true), [doCheck]);
|
||||
|
||||
@@ -31,6 +31,13 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
defaultModel: "Default: {name}",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
copyProvider: "Duplicate provider",
|
||||
testConnectivity: "Test connectivity",
|
||||
testing: "Testing...",
|
||||
connectivityOk: "{name} reachable ({latency}ms)",
|
||||
connectivitySlow: "{name} reachable but slow ({latency}ms)",
|
||||
connectivityFail: "{name} unreachable: {error}",
|
||||
copiedProvider: "Duplicated as {name}",
|
||||
|
||||
// Provider edit
|
||||
editProvider: "Edit Provider",
|
||||
@@ -202,6 +209,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
totalTokens: "Total tokens",
|
||||
cost: "Cost",
|
||||
modelDistribution: "Model distribution",
|
||||
providerDistribution: "Provider distribution",
|
||||
modalEscHint: "Press Esc or click outside to close",
|
||||
doubleClickHint: "Double-click for details",
|
||||
|
||||
@@ -245,4 +253,67 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
unknownModelProvider: "Model '{0}' references unknown provider '{1}'",
|
||||
emptyModelId: "Model '{0}' has an empty model ID",
|
||||
invalidMaxContextSize: "Model '{0}' has an invalid max_context_size",
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "Today",
|
||||
range7d: "7d",
|
||||
range30d: "30d",
|
||||
rangeAll: "All",
|
||||
dashLoadFailed: "Failed to load",
|
||||
|
||||
// Dashboard — KPI labels
|
||||
kpiRequests: "Requests",
|
||||
kpiInputOther: "Non-cached input",
|
||||
kpiOutput: "Output",
|
||||
kpiCacheRead: "Cache read",
|
||||
kpiCacheCreation: "Cache creation",
|
||||
kpiCacheHit: "Cache hit",
|
||||
kpiCost: "Est. cost",
|
||||
|
||||
// Dashboard — cards
|
||||
cardHeatmap: "Full-year heatmap",
|
||||
cardHeatmapSub: "Coloured by token volume",
|
||||
cardDailyTrend: "Usage trend",
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
cardModelUsage: "Model usage",
|
||||
cardModelUsageSub: "{n} models",
|
||||
cardRecent: "Recent requests",
|
||||
cardRecentCapped: "Showing first {n} / {m}",
|
||||
cardRecentCount: "{n} entries",
|
||||
|
||||
// Dashboard — table columns / units
|
||||
colModel: "Model",
|
||||
colTokens: "Tokens",
|
||||
colCacheHit: "Cache hit",
|
||||
colTime: "Time",
|
||||
colInput: "Input",
|
||||
colOutput: "Output",
|
||||
colCacheRead: "Cache read",
|
||||
unitTimes: "requests",
|
||||
estimate: "est.",
|
||||
collapse: "Collapse",
|
||||
expandAll: "Expand all ({n})",
|
||||
|
||||
// Dashboard — pagination
|
||||
paginationPage: "Page {x} / {y}",
|
||||
paginationRange: "{x}–{y}",
|
||||
paginationPerPage: "{n} per page",
|
||||
pageFirst: "First",
|
||||
pagePrev: "Previous",
|
||||
pageNext: "Next",
|
||||
pageLast: "Last",
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "Scanned {n} files · {m} records",
|
||||
dashboardAttribution: "Dashboard ported from",
|
||||
sessionsAttribution: "Session manager ported from",
|
||||
attributionSuffix: "(MIT, © JochenYang)",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "Collapse · {n} chars",
|
||||
previewExpandChars: "Expand · {n} chars",
|
||||
};
|
||||
@@ -29,6 +29,13 @@ export const zhTranslations = {
|
||||
defaultModel: "默认: {name}",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
copyProvider: "复制供应商",
|
||||
testConnectivity: "测试连通性",
|
||||
testing: "测试中...",
|
||||
connectivityOk: "{name} 连通正常({latency}ms)",
|
||||
connectivitySlow: "{name} 连通较慢({latency}ms)",
|
||||
connectivityFail: "{name} 无法连通:{error}",
|
||||
copiedProvider: "已复制为 {name}",
|
||||
|
||||
// Provider edit
|
||||
editProvider: "编辑供应商",
|
||||
@@ -197,6 +204,7 @@ export const zhTranslations = {
|
||||
totalTokens: "总 Token",
|
||||
cost: "费用",
|
||||
modelDistribution: "模型用量分布",
|
||||
providerDistribution: "供应商用量分布",
|
||||
modalEscHint: "按 Esc 或点击外部关闭",
|
||||
doubleClickHint: "双击查看详情",
|
||||
|
||||
@@ -240,6 +248,69 @@ export const zhTranslations = {
|
||||
unknownModelProvider: "模型「{0}」引用了未知供应商「{1}」",
|
||||
emptyModelId: "模型「{0}」的模型 ID 为空",
|
||||
invalidMaxContextSize: "模型「{0}」的 max_context_size 无效",
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "今天",
|
||||
range7d: "7 天",
|
||||
range30d: "30 天",
|
||||
rangeAll: "全部",
|
||||
dashLoadFailed: "加载失败",
|
||||
|
||||
// Dashboard — KPI labels
|
||||
kpiRequests: "请求数",
|
||||
kpiInputOther: "非缓存输入",
|
||||
kpiOutput: "输出",
|
||||
kpiCacheRead: "缓存读",
|
||||
kpiCacheCreation: "缓存创建",
|
||||
kpiCacheHit: "缓存命中",
|
||||
kpiCost: "预估费用",
|
||||
|
||||
// Dashboard — cards
|
||||
cardHeatmap: "全年热力图",
|
||||
cardHeatmapSub: "按 Token 量着色",
|
||||
cardDailyTrend: "用量趋势",
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
cardModelUsage: "模型用量",
|
||||
cardModelUsageSub: "{n} 个模型",
|
||||
cardRecent: "最近请求",
|
||||
cardRecentCapped: "显示前 {n} / {m} 条",
|
||||
cardRecentCount: "{n} 条",
|
||||
|
||||
// Dashboard — table columns / units
|
||||
colModel: "模型",
|
||||
colTokens: "Token",
|
||||
colCacheHit: "缓存命中",
|
||||
colTime: "时间",
|
||||
colInput: "输入",
|
||||
colOutput: "输出",
|
||||
colCacheRead: "缓存读",
|
||||
unitTimes: "次",
|
||||
estimate: "估算",
|
||||
collapse: "收起",
|
||||
expandAll: "展开全部 ({n})",
|
||||
|
||||
// Dashboard — pagination
|
||||
paginationPage: "第 {x} / {y} 页",
|
||||
paginationRange: "{x}–{y} 条",
|
||||
paginationPerPage: "每页 {n} 条",
|
||||
pageFirst: "首页",
|
||||
pagePrev: "上一页",
|
||||
pageNext: "下一页",
|
||||
pageLast: "末页",
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "扫描 {n} 个文件 · {m} 条记录",
|
||||
dashboardAttribution: "仪表盘功能基于",
|
||||
sessionsAttribution: "会话管理功能基于",
|
||||
attributionSuffix: "(MIT,© JochenYang)移植",
|
||||
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "收起 · {n} 字符",
|
||||
previewExpandChars: "展开全文 · {n} 字符",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof zhTranslations;
|
||||
@@ -14,6 +14,8 @@ export interface TotalsRow {
|
||||
export interface DailyRow extends TotalsRow {
|
||||
date: string;
|
||||
byModel: Record<string, number>;
|
||||
byProvider: Record<string, number>;
|
||||
byProviderModel: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface ModelRow extends TotalsRow {
|
||||
|
||||
Reference in new issue
Block a user