diff --git a/README.md b/README.md index 21eb266..f572fb3 100644 --- a/README.md +++ b/README.md @@ -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` 类型会自动校验缺失条目(编译时报错) -- 运行时通过右上角下拉切换语言,记忆在组件内 state(未持久化到 SQLite) +- 翻译源:`src/i18n/zh.ts`(源) + `src/i18n/en.ts`(目标) +- 新增 key **先在 `zh.ts` 里加**,`en.ts` 的 `Record` 类型会自动校验缺失条目(编译时报错) +- `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__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 \ No newline at end of file +许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site diff --git a/README_EN.md b/README_EN.md index 1ca7d12..19f8128 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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` 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` 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__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 \ No newline at end of file +License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site diff --git a/package.json b/package.json index 99df770..bfd0713 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0482a31..3d5dd7a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b712cad..52ef738 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 90c20cd..930f67a 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -134,6 +134,11 @@ pub struct DailyRow { pub cache_hit_rate: f64, /// Per-model token breakdown for stacked-bar rendering pub by_model: HashMap, + /// Per-provider token breakdown for stacked-bar rendering + pub by_provider: HashMap, + /// Per-provider, per-model nested token breakdown (provider → model → tokens), + /// used by the provider-tab drill-down detail. + pub by_provider_model: HashMap>, } #[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 { + let mut map: HashMap = HashMap::new(); + let mut candidates: Vec = 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) -> Option { + 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, 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, 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)> = HashMap::new(); + let mut by_day: HashMap, HashMap>)> = HashMap::new(); let mut by_model: HashMap = 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 = 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::())) + .collect(), + by_provider_model, } }) .collect(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cdca091..caf40c8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.5.1", + "version": "0.5.2", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 673f891..2f8b13e 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -303,7 +303,7 @@ export function SettingsModal({ )} {updateInfo?.updateAvailable && (
- ✦ {t("updateAvailable")}: v{updateInfo.latest} + ✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)}
)} {updateInfo && !updateInfo.updateAvailable && ( diff --git a/src/components/dashboard/DailyBars.tsx b/src/components/dashboard/DailyBars.tsx index 9dd1deb..bded9e5 100644 --- a/src/components/dashboard/DailyBars.tsx +++ b/src/components/dashboard/DailyBars.tsx @@ -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(null); const colorMap = useMemo(() => { const map: Record = {}; - 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) {
{/* Legend */}
- {modelNames.map((m) => ( + {names.map((m) => ( - {shortModel(m)} + {displayName(m)} ))}
@@ -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) => (
setSelected(null)} />, document.body )}
); -} \ No newline at end of file +} diff --git a/src/components/dashboard/DailyDetailModal.tsx b/src/components/dashboard/DailyDetailModal.tsx index 265fbd9..c90ef1e 100644 --- a/src/components/dashboard/DailyDetailModal.tsx +++ b/src/components/dashboard/DailyDetailModal.tsx @@ -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; + 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 (
- + @@ -93,73 +105,80 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro {/* Day summary chips */}
- - - - + + + +
- {/* Per-model breakdown */} + {/* Breakdown — flat by model, or two-level by provider → model */}
-
- {t("modelDistribution")} · {segments.length} -
- {segments.length === 0 ? ( -
- {t("noData")} -
- ) : ( -
- {segments.map((s, i) => ( -
- -
-
- - {shortModel(s.model)} - - - {fmtTokens(s.tokens)}{" "} - - · {s.pct.toFixed(1)}% - - + {isProvider ? ( + <> +
+ {t("providerDistribution")} · {providerGroups.length} +
+ {providerGroups.length === 0 ? ( +
{t("noData")}
+ ) : ( +
+ {providerGroups.map((p) => ( +
+
+ +
+
+ {p.displayProvider} + + {fmtTokens(p.total)} · {p.pct.toFixed(1)}% + +
+
+
+
+
+
+
+ {p.models.map((m) => ( +
+ {shortModel(m.model)} + {fmtTokens(m.tokens)} +
+ ))} +
-
-
-
-
+ ))}
- ))} -
+ )} + + ) : ( + <> +
+ {t("modelDistribution")} · {modelSegments.length} +
+ {modelSegments.length === 0 ? ( +
{t("noData")}
+ ) : ( +
+ {modelSegments.map((s, i) => ( +
+ +
+
+ {shortModel(s.model)} + + {fmtTokens(s.tokens)} · {s.pct.toFixed(1)}% + +
+
+
+
+
+
+ ))} +
+ )} + )}
@@ -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 (
-
- {label} -
-
- {value} -
+
{label}
+
{value}
); -} \ No newline at end of file +} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 55edb02..e2cb9f8 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -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 = { + today: t("rangeToday"), + "7d": t("range7d"), + "30d": t("range30d"), + all: t("rangeAll"), + }; if (loading && !data) { return ( @@ -61,13 +66,13 @@ export function DashboardPage() { return (
-
加载失败
+
{t("dashLoadFailed")}
{error}
@@ -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 = {}; + 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() {
{RANGES.map((r) => ( ))}
@@ -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")}`}
{/* Range overview cards */}
{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 ( ); @@ -173,41 +188,72 @@ export function DashboardPage() {
{/* Heatmap — full-year strip, fixed-size cells, centered */} - + {/* Charts + Model side table */}
- 30 - ? `近 30 天 · 共 ${daily.length} 天` - : `按模型着色 · ${daily.length} 天` - } - > -
- 30 ? daily.slice(-30) : daily} - modelNames={modelNames} - /> + {/* Usage-trend tab container */} +
+ {/* Tab bar header */} +
+
+ + +
+ + {daily.length > 30 + ? t("cardDailyTrendSubCapped", { n: daily.length }) + : t("cardDailyTrendSub", { n: daily.length })} +
- + {/* Active tab content */} +
+
+ +
+
+
- + {models.length === 0 ? ( -
暂无数据
+
{t("noData")}
) : (
- - - - - + + + + + @@ -230,7 +276,7 @@ export function DashboardPage() {
{m.modelDisplay || m.model}
{m.costEstimated && ( - 估算 + {t("estimate")} )}
@@ -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 })} )} @@ -259,27 +305,27 @@ export function DashboardPage() { {/* Recent requests */} recent.length - ? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条` - : `${recent.length} 条` + ? t("cardRecentCapped", { n: recent.length, m: recentTotal }) + : t("cardRecentCount", { n: recent.length }) } > {recent.length === 0 ? ( -
暂无数据
+
{t("noData")}
) : ( <>
模型请求Token缓存命中费用{t("colModel")}{t("requests")}{t("colTokens")}{t("colCacheHit")}{t("cost")}
- - - - - - + + + + + + @@ -302,9 +348,12 @@ export function DashboardPage() { {/* Pagination footer */}
- 第 {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 })}
@@ -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")} > ‹ @@ -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")} > › @@ -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")} > » @@ -351,10 +400,10 @@ export function DashboardPage() { {/* Attribution footer */}
- 扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "} + {t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "} {data.meta.home}
- 仪表盘功能基于{" "} + {t("dashboardAttribution")}{" "} {" "} - (MIT,© JochenYang)移植 + {t("attributionSuffix")}
); diff --git a/src/components/sessions/SessionsPage.tsx b/src/components/sessions/SessionsPage.tsx index e2bfce8..218b250 100644 --- a/src/components/sessions/SessionsPage.tsx +++ b/src/components/sessions/SessionsPage.tsx @@ -548,7 +548,7 @@ export function SessionsPage() {
{data?.home && <>{data.home}}
- 会话管理功能基于{" "} + {t("sessionsAttribution")}{" "} {" "} - (MIT,© JochenYang)移植 + {t("attributionSuffix")}
{/* 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() })} )}
diff --git a/src/hooks/useUpdateCheck.ts b/src/hooks/useUpdateCheck.ts index d056b00..7b7b7b9 100644 --- a/src/hooks/useUpdateCheck.ts +++ b/src/hooks/useUpdateCheck.ts @@ -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(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]); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c508e89..1354106 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -209,6 +209,7 @@ export const enTranslations: Record = { 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 = { 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", }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5014a42..23b3830 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -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; diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index 72f9cf5..93581d5 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -14,6 +14,8 @@ export interface TotalsRow { export interface DailyRow extends TotalsRow { date: string; byModel: Record; + byProvider: Record; + byProviderModel: Record>; } export interface ModelRow extends TotalsRow {
时间模型输入输出缓存读费用{t("colTime")}{t("colModel")}{t("colInput")}{t("colOutput")}{t("colCacheRead")}{t("cost")}