feat: v0.5.2 - daily trend refactor + provider/model two-level drill-down

- Dashboard: 用量趋势卡片改为外层 tab 容器(下划线 tab),移除卡片标题与内部 pill 切换器
- 用量趋势: 新增 供应商模型用量趋势 tab,与 模型用量趋势 并列
- 供应商 tab 弹窗: 每日用量详情改为两级(供应商 → 模型)分组
- 后端: DailyRow 新增 by_provider_model 嵌套聚合
- 前端: DailyBars 通用化(dimension/colorMap),DailyDetailModal 按 tab 维度渲染
- i18n: 新增 providerDistribution(中/英)
- 版本: 0.5.1 → 0.5.2(tauri.conf / Cargo.toml / package.json / README)
This commit is contained in:
KimiSwitch Dev committed 2026-07-29 13:41:04 +08:00
1 parent 3dd1fd93fe
commit a1fdc6c93d
16 files changed
+1190 -506

No files matched your search

+353 -153
View File
@@ -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 @@
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![Version](https://img.shields.io/badge/release-v0.3.0-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch)
[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
---
@@ -19,6 +19,7 @@
- [核心特性](#核心特性)
- [界面预览](#界面预览)
- [架构总览](#架构总览)
- [功能详情](#功能详情)
- [支持的供应商类型](#支持的供应商类型)
- [数据存储位置](#数据存储位置)
- [快速上手](#快速上手)
@@ -27,43 +28,52 @@
- [国际化](#国际化)
- [测试](#测试)
- [打包发布](#打包发布)
- [发布历史](#发布历史)
- [常见问题](#常见问题)
- [安全提示](#安全提示)
- [致谢](#致谢)
---
## 这是什么
**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 隐藏到托盘;托盘左键始终显示并聚焦 |
## 界面预览
@@ -71,71 +81,163 @@ Kimi Switch 提供统一的图形界面:
![供应商列表](docs/screenshots/providers.png)
清晰展示当前激活的供应商、默认模型、延迟和可用模型数量,支持快速切换、复制、跳转官网、编辑和删除操作。
展示当前激活的供应商、默认模型、延迟气泡、可用模型数量与品牌图标;支持快速切换、复制、测试连通、跳转官网、编辑、删除。
**编辑供应商 - 基本信息**
**编辑供应商 — 基本信息**
![编辑供应商-基本信息](docs/screenshots/provider-basic-info.png)
包含供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key 与请求地址等基础字段。
包括供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key、请求地址。
**编辑供应商 - 模型映射**
**编辑供应商 — 模型映射**
![编辑供应商-模型映射](docs/screenshots/provider-model-mapping.png)
一张表管理全部模型映射:显示名称、实际请求模型、上下文长度、1M 上下文声明、能力标签(思考/图像/视频/工具调用/其他),支持「一键设置」「获取模型列表」批量填充。
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、1M 上下文声明、能力(仅"思考")、设为默认、删除。
**用量仪表盘**
![用量仪表盘](docs/screenshots/dashboard.png)
8 项核心 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图)+ 模型用量明细(请求、Token、缓存命中、费用)。
8 项 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图,贴底布局)+ 模型用量明细 + 最近请求(分页)。
**会话管理**
![会话管理](docs/screenshots/sessions.png)
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选,流式逐行预览会话内容(20MB 字节上限,500 字符折叠),可归档或批量删除。
按工作区隔离浏览 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` 插件接住,回前台 + 聚焦
## 支持的供应商类型
@@ -154,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`,**暂无环境变量覆盖**
## 快速上手
@@ -175,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 打包(首次构建自动下载) |
### 安装
@@ -189,7 +293,7 @@ npm install
npm run tauri-dev
```
会同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口,修改前端代码会自动刷新,修改 Rust 代码会重新编译。
同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口;前端热重载,Rust 自动重编译。
### 仅前端开发(无 Tauri 窗口)
@@ -205,89 +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/
│ ├── screenshots/ # README 引用的界面截图
│ └── 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`)
## 测试
@@ -301,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` 立即生效
## 打包发布
@@ -321,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
### 首次打包
首次运行会下载:
@@ -342,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
+338 -141
View File
@@ -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 @@
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![Version](https://img.shields.io/badge/release-v0.3.0-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch)
[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
---
@@ -19,6 +19,7 @@
- [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)
@@ -27,42 +28,52 @@
- [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
@@ -70,71 +81,159 @@ Kimi Switch provides a unified GUI:
![Provider list](docs/screenshots/providers.png)
Shows the active provider, default model, latency and model count at a glance, with quick-switch, copy, open-website, edit and delete actions.
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**
![Edit provider — basic info](docs/screenshots/provider-basic-info.png)
Provider name, notes, official URL, managed-provider toggle, API format, API key and base URL — the essentials in one panel.
Provider name, notes, official URL, managed-provider toggle, API format, API key, and base URL.
**Edit provider — model mapping**
![Edit provider — model mapping](docs/screenshots/provider-model-mapping.png)
A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability tags (thinking / image / video / tools / other). One-click setup and "fetch model list" actions are available.
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**
![Usage dashboard](docs/screenshots/dashboard.png)
Eight core KPIs (requests, non-cached input, output, cache read / write / hit, total tokens, estimated cost), a full-year heatmap, a per-model stacked daily usage bar chart, and a per-model usage breakdown (requests, tokens, cache hit, cost).
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 management**
**Session manager**
![Session management](docs/screenshots/sessions.png)
![Session manager](docs/screenshots/sessions.png)
Browse Kimi Code sessions per workspace, filter by active / archived / all, stream-preview contents (20MB byte cap, 500-char collapse), archive or bulk-delete.
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
@@ -147,22 +246,24 @@ Browse Kimi Code sessions per workspace, filter by active / archived / all, stre
| 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
@@ -204,58 +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/
│ ├── screenshots/ # UI screenshots referenced by the README
│ └── 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
@@ -263,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
@@ -300,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
@@ -320,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:
@@ -341,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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.5.1"
version = "0.5.2"
description = "Kimi Switch - model config manager"
authors = ["you"]
edition = "2021"
+81 -4
View File
@@ -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();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.5.1",
"version": "0.5.2",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -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 && (
+32 -19
View File
@@ -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>
);
}
}
+106 -102
View File
@@ -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>
);
}
}
+120 -71
View File
@@ -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,
@@ -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>
@@ -230,7 +276,7 @@ export function DashboardPage() {
<div className="min-w-0">
<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>
);
+5 -4
View File
@@ -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>
+20 -7
View File
@@ -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]);
+64
View File
@@ -209,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",
@@ -252,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",
};
+64
View File
@@ -204,6 +204,7 @@ export const zhTranslations = {
totalTokens: "总 Token",
cost: "费用",
modelDistribution: "模型用量分布",
providerDistribution: "供应商用量分布",
modalEscHint: "按 Esc 或点击外部关闭",
doubleClickHint: "双击查看详情",
@@ -247,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;
+2
View File
@@ -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 {