Compare commits

...
5 Commits
Author SHA1 Message Date
KimiSwitch Dev e00f6b6591 feat(usage): NewAPI 订阅额度行 + 修复配置面板错误百分比(v0.8.4)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- NewAPI 用量查询新增 /api/subscription/self:订阅额度占比作为独立一行展示,
  与钱包余额(/api/user/self)分开——两者是互相独立的账本
- 多订阅全部展示;订阅行 unit=% 自动参与预警阈值
- 老版本站点(无订阅接口 404)或无订阅时静默降级,行为与 v0.8.3 一致
- 修复配置面板「测试查询」对金额行错算百分比(曾把钱包余额显示为「已用 29%」);
  抽出 isMoneyRow/isPlanRow/percentOf 到 usage-display.ts 与卡片共用
- models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商)
- 测试:Rust 205(+5)、前端 150(+4),tsc 零错误
2026-10-15 21:17:35 +08:00
KimiSwitch Dev f643802f82 chore(release): Windows 安装包以 NSIS 为主,MSI 不再主动发布
- CI Windows 构建由 --bundles msi 改为 --bundles nsis
- tauri.conf.json 默认打包目标改为仅 nsis(需要 MSI 可本地 --bundles msi 构建)
- 应用内「检查更新」优先下载 NSIS setup exe,无 exe 的历史版本自动回落 .msi
- 发布说明模板 / README 双语下载说明同步更新,CHANGELOG 记录该分发策略变更
- 测试:Rust 200(资产选择新增 NSIS 优先与 MSI 回落两例)、前端 146
2026-10-15 11:37:05 +08:00
KimiSwitch Dev da468aff13 chore(release): bump to v0.8.3 — kimi-code 2.2.0 适配核验 + 配置一致性修复正式版
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- kimi-code 2.2.0 新配置键([mcp] default_deferred、[models] upgrade 迁移元数据)往返回归测试钉住
- 适配基线注释更新至 2.2.0;实验开关清单本就同步,无需改动
- 可选后续项记录于 docs/BACKLOG-kimi-code-2.2.0.md
- 版本号三处统一 0.8.3;测试 Rust 199 / 前端 146 全绿
2026-10-15 09:31:01 +08:00
KimiSwitch Dev 35788c2783 fix(config): 供应商改名同步重键模型别名 + 删除时清理关联配置(v0.8.3-rc1)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
修复「改名后模型别名保留旧前缀,僵尸条目删了又出现」这一类配置不一致。

- 改名时同步重键模型别名、全局默认模型、供应商记住的默认模型、子代理池条目
- 改名冲突(撞已存在供应商名 / 遗留裸别名)加 -2/-3 后缀,不再静默吞模型
- 修复复制供应商复用移动语义 rekey 导致源供应商模型被清空
- 加载时不再合并「供应商已不存在」的 SQLite 僵尸模型
- 删除供应商/模型时清理子代理池条目、池默认回落、default_model 置空、
  SQLite 用量查询配置(不误删另一 agent,读取失败即跳过)
- 防呆:复制供应商改为 dirty-only,放弃修改不再落盘
- 测试基建:统一 crate 级 test_state 锁,消除环境变量并发竞争

测试:前端 146(新增 16)、Rust 197(新增 4)
2026-10-10 19:50:03 +08:00
KimiSwitch Dev 317562781c feat(website): 官网更新至 v0.8.2(下载版本号 v0.8.2、模型库 8,467·225、更新日志补 v0.8.2 性能专项条目、JSON-LD softwareVersion/dateModified 同步) 2026-10-09 20:00:13 +08:00
38 changed files with 25358 additions and 6914 deletions

No files matched your search

+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
- platform: macos-latest
args: --bundles app,dmg
- platform: windows-latest
args: --bundles msi
args: --bundles nsis
runs-on: ${{ matrix.platform }}
steps:
@@ -89,7 +89,7 @@ jobs:
cat <<'TAIL_EOF'
### Downloads by platform
- **Windows**: `.msi` installer
- **Windows**: `.exe`(NSIS 安装包)——自 v0.8.3 起 Windows 以 NSIS 为主,**MSI 不再主动发布**(历史版本的 `.msi` 仍可下载)
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
+34
View File
@@ -6,6 +6,40 @@
---
## v0.8.4 (2026-10-15)
用量查询增强:NewAPI 中转站现在同时展示**订阅额度占比**与**钱包余额**两套数据,并修掉配置面板把钱包金额误算成百分比的问题。
- **NewAPI 订阅额度行(新增)**:此前只查询 `/api/user/self`(钱包),订阅套餐的额度完全看不到;现在同时查询 `/api/subscription/self`,把订阅作为独立一行展示(如 `⚡ 订阅 · 41% 已使用 · 16d 后重置`),与钱包行(`💰 余额 500`)分开——两者是互相独立的账本
- **多个活跃订阅全部展示**:站点返回多个订阅时逐行列出
- **订阅行参与预警阈值**:订阅是配额占比行,达到阈值时同样高亮提醒
- **修掉配置面板的错误百分比**:此前对「金额行」也无条件拼 `used/total%`,把钱包余额算成「已用 29%」这类无意义的累计消耗率;现在只有真正的配额行才显示百分比(与卡片口径统一)
- **老版本 NewAPI 兼容**:站点无 `/api/subscription/self` 接口(404)或无订阅时静默降级,只显示钱包行,行为与之前完全一致
- models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商)
- **Windows 安装包改为以 NSIS(`.exe`)为主,MSI 不再主动发布**:CI 的 Windows 构建由 `--bundles msi` 改为 `--bundles nsis`,`tauri.conf.json` 默认打包目标同步改为仅 `nsis`;应用内「检查更新」优先下载 NSIS 安装包(无 exe 的历史版本自动回落 `.msi`)。历史版本的 `.msi` 仍可下载,需要 MSI 可本地 `npx tauri build --bundles msi` 自行构建
- 测试:前端 150 项(新增 4 项)、Rust 205 项(新增 5 项)
## v0.8.3 (2026-10-15)
配置一致性修复版(含 kimi-code CLI 2.2.0 适配核验)。
- **供应商改名时模型别名同步重键**:此前只改模型的 `provider` 字段,别名键保留旧前缀(如 `opencode-go-copy/...`)形成僵尸条目;现在模型别名、全局默认模型、供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:加载时 SQLite 镜像会把 config.toml 中缺失的模型合并回来,导致已删模型保存后重现;现在供应商已不存在的模型不再合并
- **改名冲突不再静默吞模型**:改名撞已存在的供应商名或遗留裸别名时,目标别名自动加 `-2`/`-3` 后缀
- **复制供应商不再清空源供应商**:修复复制复用改名逻辑(移动语义)导致源供应商模型被搬走;副本独立、源完整
- **删除供应商/模型的关联清理**:子代理模型池中引用被删模型的条目一并移除;池默认模型自动回落到剩余条目(池清空则移除整节);`default_model` 置空;SQLite 中该供应商的用量查询配置随之清理,且不误删另一 agent(Pi)的设置
- **防呆:放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致,放弃后配置保持原样
- **kimi-code 2.2.0 适配核验**:新增的 `[mcp] default_deferred` 与 `[models] upgrade` 迁移元数据经导入/导出往返无损保留(回归测试钉住);实验开关与版本基线无需改动;可选后续项记录于 `docs/BACKLOG-kimi-code-2.2.0.md`
- 修复测试基建中 `KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH` 的并发竞争(统一 crate 级共享锁)
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
- 测试:前端 146 项(新增 16 项)、Rust 199 项(新增 6 项)
## v0.8.3-rc1 (2026-10-10)
配置一致性修复预发布版(RC),内容全部并入 v0.8.3,无独有变更。
---
## v0.8.2 (2026-10-09)
性能专项,功能与数据口径不变(用量数字、字段、IPC 接口、导出格式均与 v0.8.1 一致)。
+4 -4
View File
@@ -54,7 +54,7 @@
## 下载
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.exe`(NSIS 安装包;**自 v0.8.3 起 Windows 以 NSIS 为主,MSI 不再主动发布**,历史版本 `.msi` 仍可下载)
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名**,仅 Apple Silicon / M 系列芯片)。安装请下载 Release 附带的 `install-macos.sh` 一键处理:
```bash
@@ -496,10 +496,10 @@ npm run tauri-build
产物位置:
```
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
src-tauri/target/release/bundle/nsis/Kimi.Switch_<version>_x64-setup.exe
```
Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。
Windows 安装包(NSIS setup exe),含 WebView2 bootstrapper 自动下载。自 v0.8.3 起 Windows **只主动发布 NSIS 安装包,MSI 不再主动发布**(如需 MSI 可本地 `npx tauri build --bundles msi` 自行构建)。
### 首次打包
@@ -565,7 +565,7 @@ A: 对 Kimi Code,`config.toml` 本身就是全量权威,直接备份即可
A: Vertex 需要 GCP project / location 凭证,当前实现留 TODO,等 GCP SDK 集成后补。
**Q: 支持 macOS / Linux 吗?**
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `msi`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `nsis`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
**Q: 怎么改主题 / 语言?**
A: 顶部齿轮 → 设置弹窗 → 主题 / 语言。保存到前端 `localStorage`(WebView2),重启后保持。
+4 -4
View File
@@ -54,7 +54,7 @@
## Downloads
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.exe` (NSIS installer; **since v0.8.3 Windows ships NSIS as the primary format and MSI is no longer actively published** — `.msi` files of past releases remain downloadable)
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg` (**unsigned** — first launch needs right-click → Open to bypass Gatekeeper)
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
@@ -486,10 +486,10 @@ npm run tauri-build
Output:
```
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
src-tauri/target/release/bundle/nsis/Kimi.Switch_<version>_x64-setup.exe
```
A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced.
A Windows installer (NSIS setup exe) with the WebView2 bootstrapper embedded for auto-download. Since v0.8.3 Windows **publishes only the NSIS installer; MSI is no longer actively released** (build it locally with `npx tauri build --bundles msi` if needed).
### First build
@@ -552,7 +552,7 @@ A: For Kimi Code, `config.toml` itself is the authoritative full config — back
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.
A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targets `nsis` 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.
+34
View File
@@ -0,0 +1,34 @@
# Kimi Code 2.2.0 适配 — 可选后续项
> 2026-10-15 适配核验时确认:必改项为零(`[mcp] default_deferred` 与 `[models] upgrade`
> 元数据经 raw_other 透传无损往返,回归测试见 `src-tauri/src/kimi_code_io.rs`
> 的 `kimi_code_roundtrip_*` 两条测试)。以下为**评估后暂缓**的可选增强,按需再排期。
## 1. 模型退役角标(UI 增强)
- **内容**:模型列表读取模型 `raw_other.upgrade.retirement_at`,显示「将于 X 退役,
建议迁移到 Y」角标
- **暂缓原因**:上游 2.2.0 才引入迁移元数据,实际写入该字段的 registry 还很少,
等 CLI 侧普及后再做收益更高
## 2. JSON 编辑器透传字段提示(防误删)
- **内容**:JSON 预览 / 应用处提示「删除 `raw_other` 会丢失 CLI 透传字段
(如 `upgrade` 迁移元数据)」
- **位置**:`handleApplyProviderJson`(`src/App.tsx`)是整表重建语义——用户手改
JSON 删掉 `raw_other` 块时透传字段随之丢失;属用户显式编辑行为,非 bug
- **暂缓原因**:触发条件苛刻(须手动删除 raw_other 块),文案提示即可,优先级低
## 3. TOML datetime 类型降级兜底(上游依赖)
- **内容**:若上游未来把 `upgrade.retirement_at` 写成 TOML datetime(当前是字符串),
`toml_value_to_json` → `json_to_toml` 往返会把它降级为字符串
- **暂缓原因**:当前上游(kimi-code 2.2.0)写入的就是字符串,无实际影响;
升级 CLI 时留意 `git diff` config.toml 中该字段类型即可
## 背景参考
- 适配结论:实验开关(5 flag)、版本基线(≥2.0.2 → current)、前端模型编辑
merge 语义均无需改动
- `executor = "kaos"` 检测评估过、放弃:该字段在 mcp.json(KimiSwitch 不管理),
不在 config.toml
+81
View File
@@ -0,0 +1,81 @@
# 本地验证清单 — 配置一致性修复(待发 v0.8.3)
构建版本:0.8.2(含未提交改动)。自动化测试已全绿(前端 146 / Rust 197),
以下是**必须人工验证**的场景,重点是引擎侧对配置结构的实际反应。
验证前备份:`~/.kimi-code/config.toml`、`~/.kimi-switch/kimi-switch.db`
---
## 0. 前置:确认基线干净
- [ ] 打开 `~/.kimi-code/config.toml`,搜索 `opencode-go-copy` / `opencode-go-1` → 应为 0 命中
- [ ] KimiSwitch 供应商列表中 `opencode-go` 正常显示,模型 3 条(longcat / mimo / step-5)
- [ ] 高级设置 → 子代理模型池中 3 条 opencode-go 条目都在
- [ ] kimi-code 会话能正常启动(`kimi` 命令),确认 `default_model` 生效
---
## 1. 复制供应商(本次修复的严重回归点)
- [ ] 供应商列表 → `opencode-go` 点「复制」
- [ ] **源供应商 `opencode-go` 的 3 个模型仍在**(这是关键:旧实现会清空)
- [ ] 副本 `opencode-go-copy` 也有 3 个模型,别名带 `-copy` 前缀
- [ ] 副本默认状态为「未启用 / 未使用中」
- [ ] 点返回 → 弹出未保存确认 → 选「放弃」→ **列表里副本消失**(验证防呆:放弃不落盘)
- [ ] 重新复制 → Ctrl+S 保存 → 重启应用 → 副本仍在(验证保存生效)
- [ ] 删除副本 → Ctrl+S → 重启 → 副本消失,`opencode-go` 完好
## 2. 供应商改名(僵尸别名根源)
- [ ] 编辑 `opencode-go-copy` → 名称改为 `opencode-renamed` → 保存 → 返回
- [ ] `config.toml` 中该供应商的模型别名变为 `opencode-renamed/*`,**无 `opencode-go-copy/*` 残留**
- [ ] 全局默认模型、该供应商记住的默认模型都指向新别名
- [ ] 子代理池中若有副本的条目,键同步变为新别名(描述保留)
- [ ] kimi-code 会话能启动,默认模型正确
## 3. 删除供应商(关联清理)
- [ ] 先把某个模型加入子代理池,并设为池默认
- [ ] 删除该供应商 → Ctrl+S → 打开 `config.toml` 检查:
- [ ] 该供应商的 `[models.*]` 段落全部消失
- [ ] `[secondary_model.models]` 中引用它的条目已消失
- [ ] 池 `default_model` / `model` 已回落到剩余条目(不是悬空)
- [ ] 池被清空时整个 `[secondary_model]` 节消失
- [ ] `default_model` 若指向被删模型则为 `""` 或不再指向它
- [ ] **kimi-code 会话能正常启动**(重点:验证引擎对 `default_model = ""` 的容忍度)
- [ ] SQLite 检查:`~/.kimi-switch/kimi-switch.db` 的 `settings` 表中
`usage_kinds:<该供应商名>` / `usage_config:<该供应商名>` 行已删除
- [ ] 无关的 settings 行(如 `app.language`)未受影响
## 4. 删除单个模型
- [ ] 把某模型加入子代理池 → 编辑该供应商 → 删除该模型 → 保存
- [ ] `config.toml` 中 `[secondary_model.models]` 对应条目消失
- [ ] 池默认为被删模型时已回落到其他条目
## 5. 防呆:放弃修改不落盘
- [ ] 编辑供应商改名 → 点返回 → 选「放弃」→ 重新打开:名称为旧名,**config.toml 未变**
- [ ] 删除模型 → 点返回 → 放弃 → 重新打开:模型仍在
- [ ] 复制供应商 → 放弃 → 副本消失
- [ ] 任何改动后不点保存就关闭应用 → 重启后配置不变
## 6. Pi agent 不受牵连
- [ ] 切到 Pi agent 面板,确认其供应商与用量查询配置正常
- [ ] 切回 KimiCode 保存一次 → 再看 Pi 的用量查询设置,应仍在
- [ ] 检查 SQLite `settings` 表:Pi 供应商的 `usage_*` 行未被误删
## 7. 防呆:改名冲突
- [ ] 存在 `opencode-go` 和 `opencode-go-copy` 时,把后者改名为 `opencode-go` 并保存
- [ ] 结果:两个供应商条目与各自模型都保留,冲突方别名带 `-2` 后缀,**无静默丢模型**
---
## 已知遗留(不属于本次范围)
- config.toml 中若存在 provider 已删除的僵尸 `[models.*]` 段落,应用不会自动删除
(用户权威文件,只防复发),需手动清理一次
- `default_model = ""` 时 kimi-code CLI 的确切回退行为,建议实测确认
@@ -0,0 +1,43 @@
# KimiSwitch v0.8.3-rc1
配置一致性修复版:解决「供应商改名后模型别名没跟着改,产生的僵尸条目删了又出现」这一类问题,并补齐删除供应商时的关联清理与防呆。
## 修复
- **供应商改名时模型别名同步重键**:此前改名只更新模型的 `provider` 字段,模型别名键仍保留旧前缀(如 `opencode-go-copy/...`),形成永远指向新供应商的僵尸条目;现在别名、全局默认模型、该供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:应用加载时会把 SQLite 镜像中缺失于 config.toml 的模型合并回来(迁移保护),导致已删除的模型在保存后重新出现;现在供应商已不存在的模型不再被合并
- **改名冲突不再静默吞模型**:改名为已存在的供应商名、或遗留裸别名与新名冲突时,目标别名自动加 `-2`/`-3` 后缀,与「应用 JSON」路径的既有规则一致
- **复制供应商不再清空源供应商**:修复复制时复用改名逻辑导致源供应商模型被整体搬走的问题;副本为独立副本,源供应商模型完整保留
## 删除供应商 / 模型时的关联清理
- 子代理模型池(`[secondary_model]`)中引用被删模型的条目一并移除
- 池默认模型指向被删模型时自动回落到池中剩余条目;池被清空时整个 `[secondary_model]` 节移除
- 全局 `default_model` 指向被删模型时置空
- SQLite 中该供应商的用量查询配置(`usage_kinds:` / `usage_config:`)随之清理;保留另一 agent(Pi)的设置,读取失败时跳过清理而非误删
- 模型占位符自动改名为真实模型 id 时,池条目同步跟随
## 防呆
- **放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致——点「返回」并放弃后配置保持原样
- 改名/删除/JSON 应用等所有编辑路径统一遵守「必须保存才写盘」
## 其他
- 修复测试基建中进程级环境变量(`KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH`)的并发竞争:dashboard 与 oauth 测试此前各自持锁,改为统一的 crate 级共享锁
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
## 测试
- 前端 146 项(新增 16 项:改名重键、复制保留源、冲突去重、子代理池清理与空表防护)
- Rust 197 项(新增 4 项:删除后双存储持久化与不复活、孤儿配置修剪、跨 agent 修剪保护)
## 已知限制
- config.toml 中若已存在「供应商已删除」的僵尸 `[models.*]` 段落,升级后不会自动删除(config.toml 是用户权威文件,本版只防复发),需在界面中手动删除一次
- 删除供应商后 `default_model` 置空时的 kimi-code CLI 回退行为,建议实测确认
## 下载
- **Windows**:`.exe`(NSIS 安装包,本版仅提供)
- **macOS / Linux**:见 [Releases](https://github.com/billowliu2/KimiSwitch/releases)(由 CI 构建)
@@ -0,0 +1,49 @@
# KimiSwitch v0.8.3
配置一致性修复版:解决「供应商改名后模型别名没跟着改,产生的僵尸条目删了又出现」这一类问题,补齐删除供应商时的关联清理与防呆,并完成 kimi-code CLI 2.2.0 的适配核验。
## 修复
- **供应商改名时模型别名同步重键**:此前改名只更新模型的 `provider` 字段,模型别名键仍保留旧前缀(如 `opencode-go-copy/...`),形成永远指向新供应商的僵尸条目;现在别名、全局默认模型、该供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:应用加载时会把 SQLite 镜像中缺失于 config.toml 的模型合并回来(迁移保护),导致已删除的模型在保存后重新出现;现在供应商已不存在的模型不再被合并
- **改名冲突不再静默吞模型**:改名为已存在的供应商名、或遗留裸别名与新名冲突时,目标别名自动加 `-2`/`-3` 后缀,与「应用 JSON」路径的既有规则一致
- **复制供应商不再清空源供应商**:修复复制时复用改名逻辑导致源供应商模型被整体搬走的问题;副本为独立副本,源供应商模型完整保留
## 删除供应商 / 模型时的关联清理
- 子代理模型池(`[secondary_model]`)中引用被删模型的条目一并移除
- 池默认模型指向被删模型时自动回落到池中剩余条目;池被清空时整个 `[secondary_model]` 节移除
- 全局 `default_model` 指向被删模型时置空
- SQLite 中该供应商的用量查询配置(`usage_kinds:` / `usage_config:`)随之清理;保留另一 agent(Pi)的设置,读取失败时跳过清理而非误删
- 模型占位符自动改名为真实模型 id 时,池条目同步跟随
## 防呆
- **放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致——点「返回」并放弃后配置保持原样
- 改名/删除/JSON 应用等所有编辑路径统一遵守「必须保存才写盘」
## Kimi Code CLI 2.2.0 适配
- **兼容性核验通过**:2.2.0 新增的 `[mcp] default_deferred`(MCP 工具延迟披露全局默认)与 `[models] upgrade` 模型退役/迁移元数据,经 KimiSwitch 导入/导出往返无损保留(含「CLI 后写入」与「编辑已知模型字段」两条路径,回归测试钉住)
- 实验开关清单(`notify_user`、`tower`、`wait_for`、`tool-select`、`subagent_fork`)与上游 2.2.0 注册表一致,无需改动;版本适配基线(≥ 2.0.2 判为「完全支持」)覆盖 2.2.0
- 可选后续项(退役角标、JSON 编辑器提示等)记录于 `docs/BACKLOG-kimi-code-2.2.0.md`
## 其他
- 修复测试基建中进程级环境变量(`KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH`)的并发竞争:dashboard 与 oauth 测试此前各自持锁,改为统一的 crate 级共享锁
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
## 测试
- 前端 146 项(新增 16 项:改名重键、复制保留源、冲突去重、子代理池清理与空表防护)
- Rust 199 项(新增 6 项:删除后双存储持久化与不复活、孤儿配置修剪、跨 agent 修剪保护、kimi-code 2.2.0 新配置键往返兼容)
## 已知限制
- config.toml 中若已存在「供应商已删除」的僵尸 `[models.*]` 段落,升级后不会自动删除(config.toml 是用户权威文件,本版只防复发),需在界面中手动删除一次
- 删除供应商后 `default_model` 置空时的 kimi-code CLI 回退行为,建议实测确认
## 下载
- **Windows**:`.exe`(NSIS 安装包)/ `.msi`
- **macOS / Linux**:见 [Releases](https://github.com/billowliu2/KimiSwitch/releases)(由 CI 构建)
@@ -0,0 +1,41 @@
# KimiSwitch v0.8.4
用量查询增强:NewAPI 中转站现在同时展示**订阅额度占比**与**钱包余额**两套数据,并修掉配置面板把钱包金额误算成百分比的问题。
## NewAPI 订阅额度(新增)
- **订阅与钱包分开展示**:NewAPI 站点有两套互相独立的账本——**钱包余额**(`/api/user/self`)与**订阅套餐额度**(`/api/subscription/self`)。此前只查询前者,订阅额度完全看不到;现在两者各占一行:
```
⚡ 订阅 · 41% 已使用 16d 后重置 ← 订阅额度占比(本周期)
💰 余额 500 ← 钱包余额
```
- **多个活跃订阅全部展示**:站点返回多个订阅时逐行列出,不隐藏
- **订阅行参与预警阈值**:订阅是配额占比行,达到配置面板设定的阈值时会高亮提醒
- **重置时间**:显示订阅的下一次重置倒计时(如「16d 后重置」)
## 修复
- **配置面板的错误百分比**:此前「测试查询」结果对各行**无条件**拼接 `used/total%`,会把钱包余额算成「已用 29%」这类无意义的累计消耗率(`used_quota / (quota + used_quota)`,没有业务含义)。现在只有真正的配额行才显示百分比,与供应商卡片口径完全一致
## 兼容性
- **老版本 NewAPI 站点无影响**:站点没有 `/api/subscription/self` 接口(404)或账号无订阅时静默降级,只显示钱包行,行为与 v0.8.3 完全一致
- **不改配置格式**:仅调整用量查询实现与界面渲染,`config.toml`、IPC 契约、`UsageData` 结构均无变化
## 其他
- models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商)
- **Windows 安装包改为以 NSIS(`.exe`)为主,MSI 不再主动发布**:应用内「检查更新」优先下载 NSIS 安装包(无 exe 的历史版本自动回落 `.msi`);历史版本的 `.msi` 仍可下载
## 测试
- 前端 150 项(新增 4 项:行判定 / 百分比计算 / 订阅标签)
- Rust 205 项(新增 5 项:订阅解析、多订阅、空订阅、零额度不除零、超额钳制)
- 已用真实 NewAPI 站点实测:订阅占比与站点网页控制台、API 原始数据三方一致
## 下载
- **Windows**:`.exe`(NSIS 安装包)
- **macOS / Linux**:见 [Releases](https://github.com/billowliu2/KimiSwitch/releases)(由 CI 构建)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.8.2",
"version": "0.8.4",
"type": "module",
"scripts": {
"dev": "vite",
+7668 -2215
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.8.2"
version = "0.8.4"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.8.2"
version = "0.8.4"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+346 -16
View File
@@ -175,11 +175,18 @@ fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
}
// Migration safety: include providers/models that exist in
// SQLite but not in config.toml (e.g. after upgrading from the
// old single-provider-write behaviour).
// old single-provider-write behaviour). Models whose provider
// no longer exists anywhere are skipped — they can never be
// shown or selected in the UI, and merging them back would
// resurrect zombie `[models."old-name/..."]` entries into
// config.toml on the next save.
for (name, p) in &db.providers {
config.providers.entry(name.clone()).or_insert_with(|| p.clone());
}
for (alias, m) in &db.models {
if !config.providers.contains_key(&m.provider) {
continue;
}
config.models.entry(alias.clone()).or_insert_with(|| m.clone());
}
}
@@ -230,6 +237,25 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
None => db::delete_setting_pub(&cfg_key).map_err(fmt_anyhow)?,
}
}
// Prune usage settings of providers that no longer exist (deleted in the
// UI). The keys are agent-agnostic, so the live set includes the other
// agent's providers too. If the other agent's config cannot be read we
// skip this round entirely — pruning against an incomplete live set
// could delete the other agent's still-live settings.
{
let other_agent = match agent {
Agent::KimiCode => Agent::Pi,
Agent::Pi => Agent::KimiCode,
};
let other_cfg = match db::load_config(&other_agent) {
Ok(cfg) => cfg,
Err(_) => return Ok(()),
};
let mut live: std::collections::HashSet<String> =
config.providers.keys().cloned().collect();
live.extend(other_cfg.providers.keys().cloned());
db::prune_orphan_usage_settings(&live).map_err(fmt_anyhow)?;
}
Ok(())
}
@@ -1573,7 +1599,8 @@ pub async fn download_update(
Ok(path_str)
}
/// Open the downloaded MSI installer using the system default handler.
/// Open the downloaded installer (NSIS setup exe / dmg / AppImage) using the
/// system default handler.
#[tauri::command]
pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
@@ -1813,27 +1840,38 @@ fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(),
});
Ok(())
}
/// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format.
/// Preference for Linux is AppImage (portable, no install). Windows prefers
/// the NSIS `-setup.exe` (MSI is no longer actively published since v0.8.3)
/// and falls back to `.msi` for releases that predate the switch. If the
/// preferred extension is not present, returns None so the UI can fall back
/// to "open release page" instead of guessing a less-preferred format.
fn pick_asset_for_os(assets: &[serde_json::Value], os: &str) -> Option<String> {
let target_ext = match os {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
let (preferred, fallback) = match os {
"macos" => ("dmg", None),
"linux" => ("AppImage", None),
"windows" => ("setup.exe", Some("msi")),
_ => return None,
};
asset_url_ending_with(assets, preferred)
.or_else(|| fallback.and_then(|ext| asset_url_ending_with(assets, ext)))
.map(str::to_string)
}
/// Download URL of the first release asset whose name ends with `ext`.
fn asset_url_ending_with<'a>(
assets: &'a [serde_json::Value],
ext: &str,
) -> Option<&'a str> {
assets
.iter()
.find(|a| {
a.get("name")
.and_then(|n| n.as_str())
.map(|n| n.ends_with(target_ext))
.map(|n| n.ends_with(ext))
.unwrap_or(false)
})
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Pick the GitHub release asset for the current OS (runtime target).
@@ -1846,7 +1884,10 @@ fn update_temp_filename() -> String {
let ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
// The preferred Windows installer is the NSIS setup exe; the fallback
// (.msi, pre-v0.8.3-releases) keeps its own extension via the same
// suffix match, so "exe" covers the actively published format.
"windows" => "exe",
_ => "bin",
};
format!("KimiSwitch_update.{ext}")
@@ -1908,7 +1949,9 @@ fn semver_triple(version: &str) -> Option<(u32, u32, u32)> {
}
/// Adaptation baseline against the kimi-code CLI:
/// >= 2.0.2 fully supported ("current")
/// >= 2.0.2 fully supported ("current"; verified through 2.2.0 — the new
/// [mcp] default_deferred and [models] upgrade keys round-trip,
/// see the kimi_code_io roundtrip tests)
/// >= 0.43.1 quota parsing moved upstream — upgrade KimiSwitch ("outdated")
/// >= 0.43.0 the auto_session_title flag is gone ("partial")
/// < 0.43.0 untested ("unknown")
@@ -2132,10 +2175,28 @@ mod asset_picker_tests {
}
#[test]
fn windows_picks_msi() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
fn windows_prefers_nsis_setup_exe_over_msi() {
let assets = assets(&[
"Kimi.Switch_0.8.3_x64_en-US.msi",
"Kimi.Switch_0.8.3_x64-setup.exe",
"Kimi.Switch_0.8.3_aarch64.dmg",
]);
let url = pick_asset_for_os(&assets, "windows");
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_x64_en-US.msi"));
assert_eq!(
url.as_deref(),
Some("https://example.com/Kimi.Switch_0.8.3_x64-setup.exe")
);
}
#[test]
fn windows_falls_back_to_msi_when_no_setup_exe() {
// Pre-v0.8.3 releases (and older Gitea releases) ship only the .msi.
let assets = assets(&["Kimi.Switch_0.8.2_x64_en-US.msi"]);
let url = pick_asset_for_os(&assets, "windows");
assert_eq!(
url.as_deref(),
Some("https://example.com/Kimi.Switch_0.8.2_x64_en-US.msi")
);
}
#[test]
@@ -2367,3 +2428,272 @@ mod usage_key_tests {
assert_eq!(resolve_usage_api_key(&p).unwrap(), None);
}
}
/// End-to-end persistence tests for the Kimi Code agent: a UI edit followed
/// by save_agent_config_command must land in BOTH stores (config.toml and
/// SQLite) and must not resurrect deleted models on the next load.
#[cfg(test)]
mod config_persistence_tests {
use super::*;
/// Serialize env mutation with every other env-mutating test in the
/// binary (shared crate-level lock; see lib.rs `test_state`).
fn with_env_homes<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
let _guard = crate::test_state::lock();
let home = tempfile::tempdir().unwrap();
let db_dir = tempfile::tempdir().unwrap();
std::env::set_var("KIMI_CODE_HOME", home.path());
std::env::set_var("KIMI_SWITCH_DB_PATH", db_dir.path().join("test.db"));
db::close_cached_conn_for_tests();
let out = f(home.path());
db::close_cached_conn_for_tests();
std::env::remove_var("KIMI_CODE_HOME");
std::env::remove_var("KIMI_SWITCH_DB_PATH");
out
}
const TOML_WITH_STALE_MODELS: &str = r#"
default_model = "opencode-go/longcat-2.5-preview-free"
[providers.opencode-go]
type = "openai"
base_url = "https://opencode.ai/zen/go/v1"
api_key = "sk-test"
[models."opencode-go-copy/deepseek-v4-flash"]
provider = "opencode-go"
model = "deepseek-v4-flash"
max_context_size = 512000
[models."opencode-go-1/space-bunny-free"]
provider = "opencode-go"
model = "space-bunny-free"
max_context_size = 512000
[models."opencode-go/longcat-2.5-preview-free"]
provider = "opencode-go"
model = "longcat-2.5-preview-free"
max_context_size = 1000000
"#;
#[test]
fn ui_delete_then_save_persists_to_both_stores_and_never_resurrects() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
// Load from the seeded config.toml, then mirror it into SQLite to
// reproduce the user's real state: stale aliases in BOTH stores.
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(config.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(config.models.contains_key("opencode-go-1/space-bunny-free"));
db::save_config(&Agent::KimiCode, &config).unwrap();
// Simulate the UI's model delete (onModelDelete): drop the stale
// aliases from the in-memory config, then save.
config.models.retain(|alias, _| {
!alias.starts_with("opencode-go-copy/") && !alias.starts_with("opencode-go-1/")
});
save_agent_config_command(Agent::KimiCode, config).unwrap();
// config.toml: stale sections gone, live model kept.
let toml_text = std::fs::read_to_string(home.join("config.toml")).unwrap();
assert!(!toml_text.contains("opencode-go-copy"));
assert!(!toml_text.contains("opencode-go-1"));
assert!(toml_text.contains("opencode-go/longcat-2.5-preview-free"));
// SQLite mirror: stale rows gone.
let db_config = db::load_config(&Agent::KimiCode).unwrap();
assert!(!db_config.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(!db_config.models.contains_key("opencode-go-1/space-bunny-free"));
// Reload through the real load path: no resurrection from SQLite.
let reloaded = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(!reloaded.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(!reloaded.models.contains_key("opencode-go-1/space-bunny-free"));
assert!(reloaded.models.contains_key("opencode-go/longcat-2.5-preview-free"));
});
}
#[test]
fn sqlite_models_whose_provider_was_deleted_do_not_resurrect() {
with_env_homes(|home| {
// config.toml already clean: only the live provider + model.
let toml = r#"
default_model = "opencode-go/longcat-2.5-preview-free"
[providers.opencode-go]
type = "openai"
base_url = "https://opencode.ai/zen/go/v1"
api_key = "sk-test"
[models."opencode-go/longcat-2.5-preview-free"]
provider = "opencode-go"
model = "longcat-2.5-preview-free"
max_context_size = 1000000
"#;
std::fs::write(home.join("config.toml"), toml).unwrap();
// Seed SQLite with a zombie model whose provider no longer exists
// (left behind by an old rename-then-delete).
let mut providers = IndexMap::new();
providers.insert(
"opencode-go".to_string(),
Provider {
name: "opencode-go".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://opencode.ai/zen/go/v1".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let mut models = IndexMap::new();
models.insert(
"opencode-go-copy/deepseek-v4-flash".to_string(),
Model {
alias: "opencode-go-copy/deepseek-v4-flash".to_string(),
provider: "opencode-go-copy".to_string(),
model: "deepseek-v4-flash".to_string(),
max_context_size: 512_000,
display_name: None,
supports_1m: false,
capabilities: vec![],
raw_other: serde_json::Value::Null,
},
);
let seeded = Config {
default_model: Some("opencode-go/longcat-2.5-preview-free".to_string()),
providers,
models,
raw_other: serde_json::Value::Null,
imported_section_keys: vec![],
};
db::save_config(&Agent::KimiCode, &seeded).unwrap();
// Loading must NOT merge the zombie back: its provider is gone,
// so it can never be shown or selected, and merging it would
// rewrite it into config.toml on the next save.
let loaded = load_agent_config_uncached(Agent::KimiCode).unwrap();
assert!(!loaded.models.contains_key("opencode-go-copy/deepseek-v4-flash"));
assert!(loaded.models.contains_key("opencode-go/longcat-2.5-preview-free"));
// And a save after that load must keep config.toml clean.
save_agent_config_command(Agent::KimiCode, loaded).unwrap();
let toml_text = std::fs::read_to_string(home.join("config.toml")).unwrap();
assert!(!toml_text.contains("opencode-go-copy"));
});
}
fn plain_provider(name: &str) -> Provider {
Provider {
name: name.to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://opencode.ai/zen/go/v1".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::Value::Null,
usage_kinds: None,
usage_config: None,
}
}
#[test]
fn deleting_a_provider_prunes_its_orphan_usage_settings() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
// Provider present: its usage settings survive the save.
config.providers.get_mut("opencode-go").unwrap().usage_kinds =
Some(vec!["plan:opencode_go".to_string()]);
save_agent_config_command(Agent::KimiCode, config.clone()).unwrap();
assert_eq!(
db::get_setting_pub("usage_kinds:opencode-go").unwrap().as_deref(),
Some(r#"["plan:opencode_go"]"#)
);
// Seed orphans the way history did (renamed/deleted providers).
db::set_setting_pub("usage_kinds:opencode-go-copy", r#"["plan:opencode_go"]"#)
.unwrap();
db::set_setting_pub("usage_config:opencode-go-1", "{}").unwrap();
// Non-usage settings must never be touched by the prune.
db::set_setting_pub("app.language", "zh").unwrap();
// Delete the provider (UI semantics) and save.
config.providers.shift_remove("opencode-go");
config.models.retain(|_, m| m.provider != "opencode-go");
config.default_model = None;
save_agent_config_command(Agent::KimiCode, config).unwrap();
// The provider's own settings and the historical orphans are gone.
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go").unwrap(), None);
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go-copy").unwrap(), None);
assert_eq!(db::get_setting_pub("usage_config:opencode-go-1").unwrap(), None);
assert_eq!(db::get_setting_pub("app.language").unwrap().as_deref(), Some("zh"));
// Re-adding a provider with the same name starts clean (no
// resurrection of the pruned rows).
let mut config = load_agent_config_uncached(Agent::KimiCode).unwrap();
config
.providers
.insert("opencode-go".to_string(), plain_provider("opencode-go"));
save_agent_config_command(Agent::KimiCode, config).unwrap();
assert_eq!(db::get_setting_pub("usage_kinds:opencode-go").unwrap(), None);
assert_eq!(db::get_setting_pub("app.language").unwrap().as_deref(), Some("zh"));
});
}
#[test]
fn pruning_never_touches_the_other_agents_usage_settings() {
with_env_homes(|home| {
std::fs::write(home.join("config.toml"), TOML_WITH_STALE_MODELS).unwrap();
// Pi (the other agent) has its own provider with usage settings.
let mut pi_providers = IndexMap::new();
pi_providers.insert("pi-provider".to_string(), plain_provider("pi-provider"));
db::save_config(
&Agent::Pi,
&Config {
default_model: None,
providers: pi_providers,
models: IndexMap::new(),
raw_other: serde_json::Value::Null,
imported_section_keys: vec![],
},
)
.unwrap();
db::set_setting_pub("usage_kinds:pi-provider", r#"["balance:deepseek"]"#)
.unwrap();
db::set_setting_pub("usage_config:pi-provider", "{}").unwrap();
// Save the Kimi Code config (which has no `pi-provider`).
let config = load_agent_config_uncached(Agent::KimiCode).unwrap();
save_agent_config_command(Agent::KimiCode, config).unwrap();
// The other agent's settings survive the prune.
assert_eq!(
db::get_setting_pub("usage_kinds:pi-provider").unwrap().as_deref(),
Some(r#"["balance:deepseek"]"#)
);
assert_eq!(db::get_setting_pub("usage_config:pi-provider").unwrap().as_deref(), Some("{}"));
});
}
}
+1 -2
View File
@@ -1670,8 +1670,7 @@ fn scan_usage_cached2(home: &Path, refresh: bool) -> (Arc<Vec<UsageRecord>>, Sca
/// home, redirected database or purged cache.
#[cfg(test)]
fn test_state_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
crate::test_state::lock()
}
/// The record table is shared behind an `Arc` so the unchanged path hands out
+27
View File
@@ -428,6 +428,33 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
Ok(())
}
/// Delete `usage_kinds:<name>` / `usage_config:<name>` settings rows whose
/// provider no longer exists in any agent's config, so deleting a provider in
/// the UI also clears its usage-query settings instead of leaving orphan rows
/// that outlive every rename of that provider.
pub fn prune_orphan_usage_settings(
live_providers: &std::collections::HashSet<String>,
) -> DbResult<()> {
with_conn(|conn| {
let keys: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT key FROM settings WHERE key LIKE 'usage_kinds:%' OR key LIKE 'usage_config:%'",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
};
for key in keys {
let name = key.split_once(':').map(|(_, n)| n).unwrap_or("");
if !live_providers.contains(name) {
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
}
}
Ok(())
})?;
bump_generation();
Ok(())
}
// ---------------------------------------------------------------------------
// Archived-session usage snapshots
// ---------------------------------------------------------------------------
+180
View File
@@ -1407,4 +1407,184 @@ max_context_size = 128000
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
assert!(!pt.contains_key("api_key_env"));
}
#[test]
fn kimi_code_roundtrip_preserves_mcp_default_deferred() {
// kimi-code 2.2.0 adds `default_deferred` (the global default for
// deferred MCP tool disclosure) to the top-level [mcp] section. The
// whole section travels through raw_other, so both orderings must
// round-trip: present at import time, and written by the CLI only
// after we imported.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
api_key = "sk-test"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
[mcp]
startup_timeout_ms = 30000
tool_timeout_ms = 60000
default_deferred = true
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
assert!(
config.raw_other.get("mcp").is_some(),
"[mcp] must be carried in raw_other"
);
assert!(
config.imported_section_keys.contains(&"mcp".to_string()),
"baseline must record the imported [mcp] section"
);
// (a) [mcp] existed at import time — it must be written back verbatim.
let exported = config_to_kimi_code(&config, Some(&value));
let mcp = exported
.as_table().unwrap()
.get("mcp").unwrap()
.as_table().unwrap();
assert_eq!(
mcp.get("default_deferred").and_then(|v| v.as_bool()),
Some(true),
"[mcp] default_deferred must survive export"
);
assert_eq!(
mcp.get("startup_timeout_ms").and_then(|v| v.as_integer()),
Some(30000)
);
assert_eq!(
mcp.get("tool_timeout_ms").and_then(|v| v.as_integer()),
Some(60000)
);
// (b) [mcp] absent at import, added by the CLI afterwards — not in the
// baseline, so the stale-key cleanup must leave it untouched.
let without_mcp = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
api_key = "sk-test"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
"#;
let imported: TomlValue = without_mcp.parse().unwrap();
let config = kimi_code_to_config(&imported);
assert!(!config.imported_section_keys.contains(&"mcp".to_string()));
let existing: TomlValue = format!(
"{}\n[mcp]\nstartup_timeout_ms = 30000\ntool_timeout_ms = 60000\ndefault_deferred = true\n",
without_mcp.trim_end()
)
.parse()
.unwrap();
let exported = config_to_kimi_code(&config, Some(&existing));
let mcp = exported
.as_table().unwrap()
.get("mcp").unwrap()
.as_table().unwrap();
assert_eq!(
mcp.get("default_deferred").and_then(|v| v.as_bool()),
Some(true),
"CLI-added [mcp] default_deferred must be preserved"
);
assert_eq!(
mcp.get("startup_timeout_ms").and_then(|v| v.as_integer()),
Some(30000)
);
}
#[test]
fn kimi_code_roundtrip_preserves_model_upgrade_metadata() {
// kimi-code 2.2.0 adds an `upgrade` sub-table per model
// ({ model, migration_markdown, retirement_at }) describing the
// retirement/migration target. It is not one of the five known model
// keys, so it lives in the model's raw_other and must survive both a
// plain round-trip and an edit of a known field.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
api_key = "sk-test"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
[models."p1/m1".upgrade]
model = "kimi-k3"
migration_markdown = "Move to kimi-k3 before retirement."
retirement_at = "2027-01-15T00:00:00Z"
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let model = config.models.get("p1/m1").unwrap();
assert!(
model.raw_other.get("upgrade").is_some(),
"upgrade must be carried in the model raw_other"
);
// The known keys are still extracted normally.
assert_eq!(model.max_context_size, 128000);
let exported = config_to_kimi_code(&config, Some(&value));
let mt = exported
.as_table().unwrap()
.get("models").unwrap()
.as_table().unwrap()
.get("p1/m1").unwrap()
.as_table().unwrap();
let upgrade = mt.get("upgrade").expect("upgrade sub-table").as_table().unwrap();
assert_eq!(
upgrade.get("model").and_then(|v| v.as_str()),
Some("kimi-k3")
);
assert_eq!(
upgrade.get("retirement_at").and_then(|v| v.as_str()),
Some("2027-01-15T00:00:00Z")
);
assert_eq!(
upgrade.get("migration_markdown").and_then(|v| v.as_str()),
Some("Move to kimi-k3 before retirement.")
);
// Editing a known field (the UI path) must not drop the passthrough
// `upgrade` metadata.
let mut edited = config;
edited.models.get_mut("p1/m1").unwrap().max_context_size = 262_144;
let exported = config_to_kimi_code(&edited, Some(&value));
let mt = exported
.as_table().unwrap()
.get("models").unwrap()
.as_table().unwrap()
.get("p1/m1").unwrap()
.as_table().unwrap();
assert_eq!(
mt.get("max_context_size").and_then(|v| v.as_integer()),
Some(262_144)
);
let upgrade = mt.get("upgrade").expect("upgrade sub-table").as_table().unwrap();
assert_eq!(
upgrade.get("model").and_then(|v| v.as_str()),
Some("kimi-k3"),
"upgrade metadata must survive an edit of a known field"
);
assert_eq!(
upgrade.get("retirement_at").and_then(|v| v.as_str()),
Some("2027-01-15T00:00:00Z")
);
}
}
+14
View File
@@ -10,6 +10,20 @@ pub mod pi_io;
pub mod plugins;
pub mod services;
/// Process-global state guard for tests that redirect shared environment
/// (`KIMI_CODE_HOME`, `KIMI_SWITCH_DB_PATH`) or in-memory caches (scan cache,
/// cached DB connection). Every env-mutating test must hold this lock so a
/// parallel test can never observe another one's temp home or redirected
/// database.
#[cfg(test)]
pub(crate) mod test_state {
static STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) fn lock() -> std::sync::MutexGuard<'static, ()> {
STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
}
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder};
use tauri::Manager;
+1 -2
View File
@@ -925,8 +925,7 @@ mod tests {
/// dir). A process-wide mutex serializes env mutation so parallel tests in
/// this binary can't observe a stale override.
fn with_kimi_code_home<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
let _guard = LOCK.get_or_init(|| std::sync::Mutex::new(())).lock().unwrap();
let _guard = crate::test_state::lock();
let home = tempfile::tempdir().unwrap();
std::env::set_var("KIMI_CODE_HOME", home.path());
let out = f(home.path());
+171 -1
View File
@@ -14,6 +14,7 @@
//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。
use super::usage_types::{UsageData, UsageResult};
use super::coding_plan;
use std::time::Duration;
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
@@ -467,11 +468,95 @@ pub async fn query_newapi(
let (quota_per_unit, symbol) = fetch_newapi_status(base_url, timeout).await;
Ok(match parse_newapi(&body, quota_per_unit, &symbol) {
Ok(data) => UsageResult::ok(data),
Ok(wallet) => {
// 订阅额度是决策主信息(钱包余额独立于它),前置到钱包行之前;
// compact 卡片只取 data[0],所以顺序决定了卡片主摘要显示什么。
let subs = fetch_newapi_subscriptions(&client, base, access_token, user_id, quota_per_unit).await;
let mut data = subs;
data.extend(wallet);
UsageResult::ok(data)
}
Err(err) => err,
})
}
/// 拉取用户的订阅套餐额度(NewAPI `GET /api/subscription/self`)。这是 NewAPI
/// 较新的特性:站点无此接口(404)、无订阅、网络失败或解析失败一律返回空
/// Vec——订阅是钱包余额之外的附加信息,任何失败都不能拖垮整个用量查询。
///
/// 与 `/api/user/self` 同源同凭据(Bearer access token + New-Api-User)。
async fn fetch_newapi_subscriptions(
client: &reqwest::Client,
base: &str,
access_token: &str,
user_id: &str,
quota_per_unit: f64,
) -> Vec<UsageData> {
let url = format!("{base}/api/subscription/self");
let Ok(resp) = client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {access_token}"))
.header("New-Api-User", user_id)
.send()
.await
else {
return Vec::new();
};
if !resp.status().is_success() {
return Vec::new();
}
let Ok(raw) = resp.bytes().await else {
return Vec::new();
};
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&raw) else {
return Vec::new();
};
parse_newapi_subscriptions(&body, quota_per_unit)
}
/// 解析订阅自查响应。每项形如 `{ subscription: { amount_total, amount_used,
/// next_reset_time, ... } }`;金额是 int64 原始配额单位,需除以站点
/// `quota_per_unit` 才是钱数。产出百分比行(`unit="%"`,与 plan tier 同形态,
/// 因此自动参与预警阈值判定)。
///
/// `amount_total` 为 0(站点未设额度)时百分比记为 0,不做除法。
fn parse_newapi_subscriptions(body: &serde_json::Value, quota_per_unit: f64) -> Vec<UsageData> {
let Some(subs) = body
.get("data")
.and_then(|d| d.get("subscriptions"))
.and_then(|s| s.as_array())
else {
return Vec::new();
};
let mut out = Vec::new();
for entry in subs {
// SubscriptionSummary 把实际字段包在 `subscription` 里;兼容站点直接
// 平铺字段的情况(取 entry 本身作回退)。
let sub = entry.get("subscription").unwrap_or(entry);
let total_raw = parse_f64_field(sub, "amount_total").unwrap_or(0.0);
let used_raw = parse_f64_field(sub, "amount_used").unwrap_or(0.0);
let total = total_raw / quota_per_unit;
let used = used_raw / quota_per_unit;
let percent = if total > 0.0 {
(used / total * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
out.push(UsageData {
plan_name: Some("subscription".to_string()),
remaining: Some((100.0 - percent).max(0.0)),
total: Some(100.0),
used: Some(percent),
unit: Some("%".to_string()),
is_valid: Some(percent < 100.0),
resets_at: sub.get("next_reset_time").and_then(coding_plan::extract_reset_time),
});
}
out
}
fn parse_newapi(
body: &serde_json::Value,
quota_per_unit: f64,
@@ -842,6 +927,91 @@ mod tests {
assert_eq!(data[0].used, Some(0.2));
}
#[test]
fn newapi_subscription_maps_to_percent_row() {
// 用户实测站点的数值:总额度 $500、已用 $206.17 → 41.234% 已用
// (amount_* 是 int64 原始配额,quota_per_unit=500000 换算成钱)。
let body = json!({
"success": true,
"data": {
"subscriptions": [{
"subscription": {
"id": 9,
"amount_total": 250_000_000,
"amount_used": 103_085_000,
"next_reset_time": 1_793_491_200,
"end_time": 2_106_105_600,
"status": "active"
}
}],
"all_subscriptions": []
}
});
let data = parse_newapi_subscriptions(&body, 500_000.0);
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("subscription"));
assert_eq!(data[0].unit.as_deref(), Some("%"));
assert_eq!(data[0].total, Some(100.0));
// 41.234% 已用 → 剩余 58.766
assert!((data[0].used.unwrap() - 41.234).abs() < 0.01);
assert!((data[0].remaining.unwrap() - 58.766).abs() < 0.01);
assert_eq!(data[0].is_valid, Some(true));
assert!(data[0].resets_at.is_some(), "next_reset_time must map to ISO");
}
#[test]
fn newapi_subscription_multiple_all_returned() {
let body = json!({
"success": true,
"data": {
"subscriptions": [
{ "subscription": { "amount_total": 100_000_000, "amount_used": 90_000_000 } },
{ "subscription": { "amount_total": 200_000_000, "amount_used": 20_000_000 } }
]
}
});
let data = parse_newapi_subscriptions(&body, 500_000.0);
assert_eq!(data.len(), 2);
assert!((data[0].used.unwrap() - 90.0).abs() < 0.01);
assert!((data[1].used.unwrap() - 10.0).abs() < 0.01);
}
#[test]
fn newapi_subscription_empty_or_missing_yields_nothing() {
// 站点无订阅 → 空数组
let empty = json!({ "success": true, "data": { "subscriptions": [] } });
assert!(parse_newapi_subscriptions(&empty, 500_000.0).is_empty());
// 老版本无此接口/字段缺失 → 也必须是空,不 panic
let no_field = json!({ "success": true, "data": {} });
assert!(parse_newapi_subscriptions(&no_field, 500_000.0).is_empty());
}
#[test]
fn newapi_subscription_zero_total_does_not_divide_by_zero() {
let body = json!({
"success": true,
"data": { "subscriptions": [{ "subscription": { "amount_total": 0, "amount_used": 0 } }] }
});
let data = parse_newapi_subscriptions(&body, 500_000.0);
assert_eq!(data.len(), 1);
assert_eq!(data[0].used, Some(0.0));
assert_eq!(data[0].remaining, Some(100.0));
assert_eq!(data[0].is_valid, Some(true));
}
#[test]
fn newapi_subscription_used_over_total_is_clamped() {
// 防御:站点数据异常(已用 > 总额)时百分比钳到 100,不出现负数剩余。
let body = json!({
"success": true,
"data": { "subscriptions": [{ "subscription": { "amount_total": 100, "amount_used": 300 } }] }
});
let data = parse_newapi_subscriptions(&body, 1.0);
assert_eq!(data[0].used, Some(100.0));
assert_eq!(data[0].remaining, Some(0.0));
assert_eq!(data[0].is_valid, Some(false));
}
#[test]
fn sub2api_site_root_strips_v1_and_slash() {
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
+1 -1
View File
@@ -53,7 +53,7 @@ fn millis_to_iso8601(ms: i64) -> Option<String> {
/// 从 JSON 值提取重置时间,兼容字符串和数字格式:
/// - 字符串:直接返回(视为 ISO 8601)
/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
pub(crate) fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.8.2",
"version": "0.8.4",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -27,7 +27,6 @@
"bundle": {
"active": true,
"targets": [
"msi",
"nsis"
],
"icon": [
+66 -34
View File
@@ -20,6 +20,14 @@ import {
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import {
copyModelsOnProviderDuplicate,
rekeyModelsOnProviderRename,
} from "./lib/providerRename";
import {
purgeSubagentPoolAliases,
rekeySubagentPoolAliases,
} from "./lib/referenceCleanup";
import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -153,9 +161,6 @@ export default function App() {
// provider being switched to — only that one needs to be
// complete; sibling providers being merely listed in the config
// must not block the switch.
// 3. Programmatic auto-saves (duplicate) call `save` directly with
// no validation — duplicates inherit completeness from their
// source.
// Returns `true` when the config was actually persisted, `false` when
// the user cancelled the validation confirm (callers can react to this
// to roll back in-memory mutations made before the save).
@@ -287,15 +292,30 @@ export default function App() {
);
if (oldEntry && oldEntry[0] !== provider.name) {
delete providers[oldEntry[0]];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === editingProvider) {
models[key] = { ...models[key], provider: provider.name };
}
// Re-key this provider's model aliases so the `<provider>/<model>`
// prefix tracks the new name (see rekeyModelsOnProviderRename).
const { models, aliasMap } = rekeyModelsOnProviderRename(
cfg.models,
editingProvider,
provider.name
);
// The provider's remembered default model and the global default
// model are stored as aliases and must follow the re-key.
const remembered = getProviderDefaultModel(provider);
providers[provider.name] =
remembered && aliasMap[remembered]
? setProviderDefaultModel(provider, aliasMap[remembered])
: provider;
let default_model = cfg.default_model;
if (default_model && aliasMap[default_model]) {
default_model = aliasMap[default_model];
}
providers[provider.name] = provider;
// The subagent pool references model aliases as its keys and
// defaults; they must follow the re-key or they turn into zombie
// pool entries.
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, aliasMap);
setEditingProvider(provider.name);
return { ...cfg, providers, models };
return { ...cfg, providers, models, default_model, raw_other };
}
providers[provider.name] = provider;
return { ...cfg, providers };
@@ -308,12 +328,21 @@ export default function App() {
const providers = { ...cfg.providers };
delete providers[name];
const models = { ...cfg.models };
const removedAliases: string[] = [];
for (const key of Object.keys(models)) {
if (models[key].provider === name) {
removedAliases.push(key);
delete models[key];
}
}
return { ...cfg, providers, models };
// Everything keyed on the deleted aliases must go with them: the
// global default model and the subagent pool entries / pool default.
const default_model =
cfg.default_model && removedAliases.includes(cfg.default_model)
? null
: cfg.default_model;
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
return { ...cfg, providers, models, default_model, raw_other };
});
if (editingProvider === name) {
setEditingProvider("");
@@ -356,29 +385,14 @@ export default function App() {
null
);
const providers = { ...cfg.providers, [newName]: copied };
// Re-key this provider's models to the new provider name so the copy
// has its own independent model set.
const models = { ...cfg.models };
for (const [alias, m] of Object.entries(cfg.models)) {
if (m.provider === name) {
// The model belongs to this provider by `m.provider === name`. To
// re-key it under the new provider, we want a `newName/...`
// alias. The original code assumed `alias` is exactly
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
// pre-v0.6 data) which would produce `newName-k3` — missing the
// `/` separator and pointing at the wrong model. Use an explicit
// prefix check and fall back to a full re-prefix.
const prefix = `${name}/`;
const newAlias = alias.startsWith(prefix)
? newName + alias.slice(name.length)
: `${newName}/${alias}`;
models[newAlias] = { ...m, alias: newAlias, provider: newName };
}
}
// Clone the provider's models under the duplicate's name — COPY
// semantics: the source keeps its models (a rekey would move them).
const models = copyModelsOnProviderDuplicate(cfg.models, name, newName);
return { ...cfg, providers, models };
});
await save();
// No immediate save: the copy lives in memory only, like every other
// edit. Abandoning (back → discard) must leave the config unchanged;
// the copy persists when the user saves (Ctrl+S / footer / switch).
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
setTimeout(() => setSwitchMessage(null), 3000);
};
@@ -529,6 +543,14 @@ export default function App() {
default_model = null;
}
// Pool entries pointing at aliases that disappeared in this apply
// (provider renamed, models dropped) must not survive as zombies.
const removedAliases = Object.keys(cfg.models).filter(
(key) =>
cfg.models[key].provider === oldName && !(key in updatedModels)
);
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
// Remember this provider's default model preference so it survives
// provider switches.
const updatedProvider = providers[provider.name];
@@ -536,7 +558,7 @@ export default function App() {
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
}
return { ...cfg, providers, models: updatedModels, default_model };
return { ...cfg, providers, models: updatedModels, default_model, raw_other };
});
if (provider.name !== editingProvider) {
setEditingProvider(provider.name);
@@ -744,16 +766,26 @@ export default function App() {
if (default_model === oldKey) default_model = effective.alias;
else if (effective !== model && default_model === model.alias)
default_model = effective.alias;
return { ...cfg, models, default_model };
// Pool entries keyed on the discarded alias (placeholder or
// replaced old key) must follow the rename or they dangle.
const poolAliasMap: Record<string, string> = {};
if (effective !== model) poolAliasMap[model.alias] = effective.alias;
if (oldKey && oldKey !== effective.alias)
poolAliasMap[oldKey] = effective.alias;
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, poolAliasMap);
return { ...cfg, models, default_model, raw_other };
});
}}
onModelDelete={(alias) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
delete models[alias];
// Pool entries keyed on the deleted alias must not survive.
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, [alias]);
return {
...cfg,
models,
raw_other,
default_model:
cfg.default_model === alias ? null : cfg.default_model,
};
+8 -3
View File
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import { formatAmount, isPlanRow, localizeUsageError, percentOf, planLabel } from "../lib/usage-display";
import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult {
@@ -131,13 +131,18 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
});
if (result.success) {
setTestOk(true);
// 与卡片 UsageFooter 同一套行判定:金额行(NewAPI 钱包 / 加力钱包)只显示
// 金额——其 used/total 是钱数,算成「百分比」是误导(曾把钱包余额误显示为
// 「已用 29%」)。只有真正的配额行才追加百分比。
const summary = (result.data ?? [])
.map((d) => {
const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t));
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`);
if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
if (isPlanRow(d)) {
const pct = percentOf(d);
if (pct != null) parts.push(`${Math.round(pct)}%`);
}
return parts.join(" ");
})
.filter(Boolean)
+9 -19
View File
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import {
BOOSTER_WALLET_PLAN,
formatAmount,
isPlanRow,
localizeUsageError,
percentOf,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
import type { UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps {
/** Query state lifted to the provider card via useUsageQuery. Both the
@@ -21,18 +22,15 @@ interface UsageFooterProps {
}
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*
* NewAPI 站点可同时返回钱包行(planName "NewAPI",金额)与订阅行
* (planName "subscription",`unit="%"` 的配额占比);后者不属于金额行,走
* 百分比渲染路径。判定逻辑与 usage-display.ts 共用,避免与配置面板再次分叉。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation();
@@ -48,14 +46,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
if (!supported || status === "idle") return null;
const percentOf = (d: UsageData): number | null => {
if (d.used != null && d.total != null && d.total > 0)
return (d.used / d.total) * 100;
if (d.remaining != null && d.total != null && d.total > 0)
return (1 - d.remaining / d.total) * 100;
return null;
};
const formatReset = (resetsAt?: string | null): string | null => {
if (!resetsAt) return null;
const ms = new Date(resetsAt).getTime() - now;
+1
View File
@@ -514,6 +514,7 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTierMonthly: "Monthly",
usageTierMonthCode: "Monthly (code)",
usageTierBoosterWallet: "Booster",
usageTierSubscription: "Subscription",
usageThreshold: "Alert threshold",
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
usageThresholdAlert: "Threshold reached",
+1
View File
@@ -503,6 +503,7 @@ export const zhTranslations = {
usageTierMonthly: "30天",
usageTierMonthCode: "30天(代码)",
usageTierBoosterWallet: "加力钱包",
usageTierSubscription: "订阅",
usageThreshold: "预警阈值",
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
usageThresholdAlert: "已达预警阈值",
+8332 -2371
View File
File diff suppressed because it is too large. Load diff
+7668 -2215
View File
File diff suppressed because it is too large. Load diff
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
copyModelsOnProviderDuplicate,
rekeyModelsOnProviderRename,
} from "./providerRename";
import type { Model } from "../types";
function model(alias: string, provider: string): Model {
return {
alias,
provider,
model: alias.slice(provider.length + 1),
max_context_size: 512000,
display_name: null,
};
}
describe("rekeyModelsOnProviderRename", () => {
it("re-keys aliases and provider when a provider is renamed (opencode-go-copy scenario)", () => {
const models = {
"opencode-go-copy/deepseek-v4-flash": model("opencode-go-copy/deepseek-v4-flash", "opencode-go-copy"),
"opencode-go-copy/deepseek-v4.1-flash": model("opencode-go-copy/deepseek-v4.1-flash", "opencode-go-copy"),
"opencode-go/longcat-2.5-preview-free": model("opencode-go/longcat-2.5-preview-free", "opencode-go"),
"other/m1": model("other/m1", "other"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "opencode-go-copy", "opencode-go");
expect(Object.keys(next).sort()).toEqual([
"opencode-go/deepseek-v4-flash",
"opencode-go/deepseek-v4.1-flash",
"opencode-go/longcat-2.5-preview-free",
"other/m1",
]);
expect(next["opencode-go/deepseek-v4-flash"].provider).toBe("opencode-go");
expect(next["opencode-go/deepseek-v4-flash"].model).toBe("deepseek-v4-flash");
expect(aliasMap).toEqual({
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
"opencode-go-copy/deepseek-v4.1-flash": "opencode-go/deepseek-v4.1-flash",
});
// Untouched providers stay identical.
expect(next["opencode-go/longcat-2.5-preview-free"]).toBe(models["opencode-go/longcat-2.5-preview-free"]);
expect(next["other/m1"]).toBe(models["other/m1"]);
});
it("fully re-prefixes non-standard aliases missing the old `<name>/` prefix", () => {
const models = { "legacy-k3": model("legacy-k3", "kimi-code") };
const { models: next } = rekeyModelsOnProviderRename(models, "kimi-code", "renamed");
expect(Object.keys(next)).toEqual(["renamed/legacy-k3"]);
expect(next["renamed/legacy-k3"].provider).toBe("renamed");
});
it("leaves other providers' models alone when renaming", () => {
const models = { "a/m1": model("a/m1", "a"), "b/m1": model("b/m1", "b") };
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "c");
expect(Object.keys(next).sort()).toEqual(["b/m1", "c/m1"]);
expect(aliasMap).toEqual({ "a/m1": "c/m1" });
});
it("suffixes instead of overwriting when the renamed alias collides (rename into an occupied name)", () => {
// Renaming provider `a` to `b` while `b` already has b/m1 — the re-keyed
// entry must not silently swallow b's model.
const models = {
"a/m1": model("a/m1", "a"),
"b/m1": model("b/m1", "b"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "b");
expect(next["b/m1"].model).toBe("m1"); // b's original survives
expect(next["b/m1-2"].provider).toBe("b"); // a's model re-keyed with suffix
expect(aliasMap).toEqual({ "a/m1": "b/m1-2" });
expect(Object.keys(next).sort()).toEqual(["b/m1", "b/m1-2"]);
});
it("suffixes when a legacy bare alias collides with the standard alias after rename", () => {
// Pre-v0.6 data: bare alias `k3` (no provider prefix) and `kimi/k3` both
// belong to `kimi`; both re-key to `x/k3`.
const models = {
k3: { ...model("k3", "kimi"), model: "k3" },
"kimi/k3": model("kimi/k3", "kimi"),
};
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "kimi", "x");
expect(Object.keys(next).sort()).toEqual(["x/k3", "x/k3-2"]);
expect(next["x/k3"]).toBeDefined();
expect(next["x/k3-2"]).toBeDefined();
// Both old keys are mapped; the pool re-key consumes the map, so every
// entry lands on a distinct target.
expect(Object.values(aliasMap).sort()).toEqual(["x/k3", "x/k3-2"]);
});
});
describe("copyModelsOnProviderDuplicate", () => {
it("clones the provider's models and KEEPS the source (copy, not move)", () => {
const models = {
"opencode-go/longcat": model("opencode-go/longcat", "opencode-go"),
"other/m1": model("other/m1", "other"),
};
const next = copyModelsOnProviderDuplicate(models, "opencode-go", "opencode-go-copy");
expect(Object.keys(next).sort()).toEqual([
"opencode-go-copy/longcat",
"opencode-go/longcat",
"other/m1",
]);
expect(next["opencode-go/longcat"].provider).toBe("opencode-go");
expect(next["opencode-go-copy/longcat"].provider).toBe("opencode-go-copy");
});
it("fully re-prefixes non-standard aliases and suffixes collisions", () => {
const models = {
"legacy-k3": { ...model("legacy-k3", "kimi"), model: "k3" },
"dupe/legacy-k3": model("dupe/legacy-k3", "other"),
};
const next = copyModelsOnProviderDuplicate(models, "kimi", "dupe");
// `dupe/legacy-k3` is taken → the clone gets a suffix.
expect(Object.keys(next).sort()).toEqual([
"dupe/legacy-k3",
"dupe/legacy-k3-2",
"legacy-k3",
]);
});
});
+79
View File
@@ -0,0 +1,79 @@
import type { Model } from "../types";
export interface ProviderRenameResult {
models: Record<string, Model>;
/** old alias → new alias (collision-deduped), for callers fixing alias references. */
aliasMap: Record<string, string>;
}
function rekeyedAlias(alias: string, oldName: string, newName: string): string {
const prefix = `${oldName}/`;
return alias.startsWith(prefix)
? newName + alias.slice(oldName.length)
: `${newName}/${alias}`;
}
/**
* Re-key a provider's model aliases after the provider is renamed so the
* `<provider>/<model>` prefix tracks the new name. Only patching
* `m.provider` (the old behaviour) left stale prefixes like
* `opencode-go-copy/...` pointing at the renamed provider — zombie aliases
* that the UI then re-saved to config.toml and SQLite forever.
*
* Collision-safe: a target alias that is already taken (renaming into an
* existing provider's name, or a legacy bare alias colliding with a standard
* `<name>/<model>` one — both map to the same new key) gets a `-2`/`-3`
* suffix instead of silently overwriting the other entry. Non-standard
* aliases without the `<oldName>/` prefix fall back to a full re-prefix.
*/
export function rekeyModelsOnProviderRename(
models: Record<string, Model>,
oldName: string,
newName: string
): ProviderRenameResult {
const next: Record<string, Model> = {};
const toRename: Array<[string, Model]> = [];
for (const [key, m] of Object.entries(models)) {
if (m.provider === oldName) toRename.push([key, m]);
else next[key] = m;
}
const aliasMap: Record<string, string> = {};
for (const [key, m] of toRename) {
const base = rekeyedAlias(key, oldName, newName);
let target = base;
let n = 2;
while (target in next) {
target = `${base}-${n}`;
n++;
}
next[target] = { ...m, alias: target, provider: newName };
aliasMap[key] = target;
}
return { models: next, aliasMap };
}
/**
* Clone a provider's models under the duplicate's name — COPY semantics:
* the source provider keeps its models (unlike rekeyModelsOnProviderRename,
* which moves them). Collisions with any existing alias get a `-2`/`-3`
* suffix.
*/
export function copyModelsOnProviderDuplicate(
models: Record<string, Model>,
oldName: string,
newName: string
): Record<string, Model> {
const next = { ...models };
for (const [key, m] of Object.entries(models)) {
if (m.provider !== oldName) continue;
const base = rekeyedAlias(key, oldName, newName);
let target = base;
let n = 2;
while (target in next) {
target = `${base}-${n}`;
n++;
}
next[target] = { ...m, alias: target, provider: newName };
}
return next;
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { purgeSubagentPoolAliases, rekeySubagentPoolAliases } from "./referenceCleanup";
const rawWithPool = {
thinking: { enabled: true },
secondary_model: {
default_model: "opencode-go/longcat-2.5-preview-free",
model: "opencode-go/longcat-2.5-preview-free",
models: {
"TokenHub/deepseek-v4.1-flash": "",
"opencode-go/longcat-2.5-preview-free": "",
"opencode-go/mimo-v2.6-flash": "",
"opencode-go-copy/deepseek-v4-flash": "route to deepseek",
},
force: false,
},
services: { moonshot_fetch: { api_key: "" } },
};
describe("rekeySubagentPoolAliases", () => {
it("re-keys pool keys and defaults when the provider is renamed", () => {
const next = rekeySubagentPoolAliases(rawWithPool, {
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
}) as typeof rawWithPool;
const section = next.secondary_model as Record<string, any>;
expect(Object.keys(section.models).sort()).toEqual([
"TokenHub/deepseek-v4.1-flash",
"opencode-go/deepseek-v4-flash",
"opencode-go/longcat-2.5-preview-free",
"opencode-go/mimo-v2.6-flash",
]);
// Descriptions survive the re-key.
expect(section.models["opencode-go/deepseek-v4-flash"]).toBe("route to deepseek");
// Untouched defaults stay put.
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
// Unrelated sections are preserved.
expect(next.thinking).toEqual({ enabled: true });
});
it("follows the default when it referenced a renamed alias", () => {
const raw = {
secondary_model: {
default_model: "old/m1",
model: "old/m1",
models: { "old/m1": "" },
},
};
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model.default_model).toBe("new/m1");
expect(next.secondary_model.model).toBe("new/m1");
expect(Object.keys(next.secondary_model.models)).toEqual(["new/m1"]);
});
it("returns the same reference when nothing matches", () => {
expect(rekeySubagentPoolAliases(rawWithPool, { "a/b": "c/d" })).toBe(rawWithPool);
});
it("never injects an empty models table into the implicit single-model form", () => {
// `{ model: "old/m1" }` with no pool table: re-keying the default must
// not add `[secondary_model.models]` — an empty table is invalid for the
// engine (its default has to be a key of the table).
const raw = { secondary_model: { model: "old/m1" } };
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model).toEqual({ model: "new/m1" });
expect(next.secondary_model.models).toBeUndefined();
});
it("never injects a models table into the force form", () => {
const raw = { secondary_model: { force: true, default_model: "old/m1", model: "old/m1" } };
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
expect(next.secondary_model).toEqual({
force: true,
default_model: "new/m1",
model: "new/m1",
});
expect(next.secondary_model.models).toBeUndefined();
});
});
describe("purgeSubagentPoolAliases", () => {
it("removes deleted aliases and keeps the rest of the section intact", () => {
const next = purgeSubagentPoolAliases(rawWithPool, [
"opencode-go-copy/deepseek-v4-flash",
]) as typeof rawWithPool;
const section = next.secondary_model as Record<string, any>;
expect(Object.keys(section.models).sort()).toEqual([
"TokenHub/deepseek-v4.1-flash",
"opencode-go/longcat-2.5-preview-free",
"opencode-go/mimo-v2.6-flash",
]);
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
expect(section.force).toBe(false);
expect(next.thinking).toEqual({ enabled: true });
});
it("falls back to the first remaining key when the default was purged", () => {
const next = purgeSubagentPoolAliases(rawWithPool, [
"opencode-go/longcat-2.5-preview-free",
]) as any;
const section = next.secondary_model;
expect(section.models).not.toHaveProperty("opencode-go/longcat-2.5-preview-free");
expect(section.default_model).toBe("TokenHub/deepseek-v4.1-flash");
expect(section.model).toBe("TokenHub/deepseek-v4.1-flash");
});
it("drops the whole section when the pool becomes empty", () => {
const raw = {
thinking: { enabled: true },
secondary_model: {
default_model: "p/m",
model: "p/m",
models: { "p/m": "" },
},
};
const next = purgeSubagentPoolAliases(raw, ["p/m"]) as any;
expect(next).not.toHaveProperty("secondary_model");
expect(next.thinking).toEqual({ enabled: true });
});
it("returns the same reference when no pool entry is affected", () => {
expect(purgeSubagentPoolAliases(rawWithPool, ["other/m9"])).toBe(rawWithPool);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Keep `[secondary_model]` (the subagent model pool in `raw_other`) free of
* dangling model-alias references.
*
* Model aliases are deleted or re-keyed in several places (provider delete,
* single-model delete, provider rename). The pool stores aliases as its
* `models` table keys and as the `default_model` / `model` values — none of
* those may outlive the alias they point at, or the engine resolves the pool
* entry to nothing and the zombie key lingers in config.toml forever.
*/
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Re-key pool aliases after a provider rename: `aliasMap` maps old alias →
* new alias. Pool keys keep their descriptions; `default_model` / `model`
* follow when they referenced a renamed alias. Returns the input reference
* when nothing changed.
*/
export function rekeySubagentPoolAliases(
rawOther: unknown,
aliasMap: Record<string, string>
): unknown {
if (Object.keys(aliasMap).length === 0) return rawOther;
const root = asRecord(rawOther);
const section = asRecord(root.secondary_model);
if (Object.keys(section).length === 0) return rawOther;
const models = asRecord(section.models);
let changed = false;
const nextModels: Record<string, unknown> = {};
for (const [k, v] of Object.entries(models)) {
const mapped = aliasMap[k];
if (mapped && mapped !== k) {
nextModels[mapped] = v;
changed = true;
} else {
nextModels[k] = v;
}
}
let nextDefault = section.default_model;
let nextModel = section.model;
if (typeof nextDefault === "string" && aliasMap[nextDefault]) {
nextDefault = aliasMap[nextDefault];
changed = true;
}
if (typeof nextModel === "string" && aliasMap[nextModel]) {
nextModel = aliasMap[nextModel];
changed = true;
}
if (!changed) return rawOther;
// Only write a `models` table when the section had one — the implicit
// single-model form (`{ model: "..." }`) and the force form must not gain
// an empty `[secondary_model.models]` table, which the engine can never
// validate (its default must be a key of the table).
const hadModelsTable = section.models !== undefined;
const nextSection: Record<string, unknown> = { ...section };
if (hadModelsTable || Object.keys(nextModels).length > 0) {
nextSection.models = nextModels;
}
if (nextDefault !== section.default_model) nextSection.default_model = nextDefault;
if (nextModel !== section.model) nextSection.model = nextModel;
return { ...root, secondary_model: nextSection };
}
/**
* Remove pool entries pointing at deleted aliases. When the pool's default
* referenced a deleted alias it falls back to the first remaining key; when
* the pool becomes empty the whole `[secondary_model]` section is dropped
* (an empty pool is invalid for the engine — subagents then inherit the
* primary model). Returns the input reference when nothing changed.
*/
export function purgeSubagentPoolAliases(
rawOther: unknown,
removedAliases: Iterable<string>
): unknown {
const removed = new Set(removedAliases);
if (removed.size === 0) return rawOther;
const root = asRecord(rawOther);
const section = asRecord(root.secondary_model);
if (Object.keys(section).length === 0) return rawOther;
const models = asRecord(section.models);
const kept: Record<string, unknown> = {};
let removedAny = false;
for (const [k, v] of Object.entries(models)) {
if (removed.has(k)) {
removedAny = true;
} else {
kept[k] = v;
}
}
const defaultGone =
(typeof section.default_model === "string" && removed.has(section.default_model)) ||
(typeof section.model === "string" && removed.has(section.model));
if (!removedAny && !defaultGone) return rawOther;
if (Object.keys(kept).length === 0) {
const nextRoot: Record<string, unknown> = { ...root };
delete nextRoot.secondary_model;
return nextRoot;
}
const nextSection: Record<string, unknown> = { ...section, models: kept };
if (defaultGone) {
const fallback = Object.keys(kept)[0];
nextSection.default_model = fallback;
nextSection.model = fallback;
}
return { ...root, secondary_model: nextSection };
}
+48 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
import {
formatAmount,
isMoneyRow,
isPlanRow,
localizeUsageError,
percentOf,
planLabel,
} from "./usage-display";
// Minimal TranslateFn: return the key itself so assertions read the key.
const t = (key: string) => key;
@@ -60,11 +67,51 @@ describe("planLabel", () => {
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
expect(planLabel("subscription", t)).toBe("usageTierSubscription");
expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥");
});
});
describe("isMoneyRow / isPlanRow", () => {
it("treats the NewAPI wallet and booster wallet as money rows", () => {
// NewAPI 钱包行只报金额,其 used/total 是钱数,不能当百分比渲染。
expect(isMoneyRow({ planName: "NewAPI" })).toBe(true);
expect(isMoneyRow({ planName: "booster_wallet" })).toBe(true);
expect(isMoneyRow({ planName: "subscription" })).toBe(false);
expect(isMoneyRow({ planName: "weekly_limit" })).toBe(false);
});
it("classifies quota rows by planName, not unit", () => {
// NewAPI 订阅行:unit="%" → 配额行
expect(
isPlanRow({ planName: "subscription", total: 100, used: 41 })
).toBe(true);
// sub2api 订阅窗口:unit 是 USD,但语义仍是配额占比 → 仍算配额行
expect(
isPlanRow({ planName: "weekly_limit", total: 50, used: 10 })
).toBe(true);
// 钱包行永远不是配额行,即使带了 total/used
expect(isPlanRow({ planName: "NewAPI", total: 704, used: 204 })).toBe(false);
// 无 planName 或无数值 → 非配额行
expect(isPlanRow({ planName: null, total: 100, used: 1 })).toBe(false);
expect(isPlanRow({ planName: "x" })).toBe(false);
});
});
describe("percentOf", () => {
it("prefers used/total and falls back to remaining/total", () => {
expect(percentOf({ used: 41, total: 100 })).toBe(41);
expect(percentOf({ remaining: 59, total: 100 })).toBe(41);
});
it("returns null when total is missing or non-positive", () => {
expect(percentOf({ used: 41 })).toBeNull();
expect(percentOf({ used: 41, total: 0 })).toBeNull();
expect(percentOf({})).toBeNull();
});
});
describe("formatAmount", () => {
it("maps CNY and USD to ¥/$ with two decimals", () => {
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
+44 -3
View File
@@ -12,6 +12,44 @@ type TranslateFn = (
*/
export const BOOSTER_WALLET_PLAN = "booster_wallet";
/**
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。
*
* 注意 NewAPI 站点同时有钱包与订阅两套账:钱包行的 planName 是 "NewAPI"(金额),
* 订阅行是 "subscription"(`unit="%"` 的配额占比),后者不在此列表内,正常走
* 百分比渲染。判定只能看 planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义
* 仍是「额度用了百分之多少」,按 unit 判定会把它误渲染成余额。
*/
export function isMoneyRow(d: { planName?: string | null }): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
/** 额度/配额行:有 planName、非金额行,且报了 total 或 used。 */
export function isPlanRow(d: {
planName?: string | null;
total?: number | null;
used?: number | null;
}): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
/**
* 配额行的「已用百分比」。优先 used/total,其次由 remaining/total 反推;
* total 缺失或 ≤0(无法算比例)返回 null。金额行不应调用此函数。
*/
export function percentOf(d: {
used?: number | null;
total?: number | null;
remaining?: number | null;
}): number | null {
if (d.used != null && d.total != null && d.total > 0)
return (d.used / d.total) * 100;
if (d.remaining != null && d.total != null && d.total > 0)
return (1 - d.remaining / d.total) * 100;
return null;
}
/**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
@@ -19,9 +57,10 @@ export const BOOSTER_WALLET_PLAN = "booster_wallet";
* Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
* TIER_MONTH_CODE — so this mapping covers every current and future plan
* provider automatically. If a new tier id is ever added there, add a case
* here too.
* TIER_MONTH_CODE — so the fixed tiers are covered here. The other cases come
* from src-tauri/src/services/balance.rs: `booster_wallet` (Kimi 加力钱包) and
* `subscription` (NewAPI 订阅套餐额度行). If a new tier id is added anywhere,
* add a case here too.
*/
export function planLabel(name: string, t: TranslateFn): string {
switch (name) {
@@ -37,6 +76,8 @@ export function planLabel(name: string, t: TranslateFn): string {
return t("usageTierMonthCode");
case BOOSTER_WALLET_PLAN:
return t("usageTierBoosterWallet");
case "subscription":
return t("usageTierSubscription");
default:
return name;
}
+2 -2
View File
@@ -50,8 +50,8 @@
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.8.1",
"dateModified": "2026-09-03",
"softwareVersion": "0.8.2",
"dateModified": "2026-10-09",
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
+32 -12
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.8.0",
downloadBtn: "下载 v0.8.2",
},
hero: {
badge: "v0.8.0 · 开源 MIT · Windows / macOS / Linux",
badge: "v0.8.2 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
@@ -29,7 +29,7 @@ const zh = {
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "8385", label: "模型价格库" },
{ value: "8467", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
@@ -76,7 +76,7 @@ const zh = {
{
id: "model-discovery",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,385 个模型、226 家供应商)。",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,467 个模型、225 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -89,7 +89,7 @@ const zh = {
},
showcase: {
title: "界面演示",
subtitle: "以下截图取自 v0.8.0 实机运行。",
subtitle: "以下截图取自 v0.8.2 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -140,6 +140,16 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"仪表盘重启后首次打开从约 25 秒降到 1 秒内:用量解析快照持久化到本地,启动直读并在后台校验,有变化自动无感更新(6.4 万条记录实测)",
"切换时间范围几乎瞬时:热力图 / 模型总览 / 各范围汇总按记录集缓存;去除原「全时段」聚合的整表无用遍历",
"界面不再闪加载态:切范围与重新进入页面先呈现上次数据再后台静默刷新;供应商卡片墙由 N 次配置全量加载降为 1 次",
"models.dev 快照同步至 2026-10-09(8,467 模型 / 225 供应商)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -348,7 +358,7 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.8.0 · Windows / macOS / Linux 三平台已发布",
subtitle: "当前版本 v0.8.2 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
@@ -417,10 +427,10 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.8.0",
downloadBtn: "Download v0.8.2",
},
hero: {
badge: "v0.8.0 · Open source MIT · Windows / macOS / Linux",
badge: "v0.8.2 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
@@ -430,7 +440,7 @@ const en: Dict = {
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "8385", label: "Model price DB" },
{ value: "8467", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
@@ -477,7 +487,7 @@ const en: Dict = {
{
id: "model-discovery",
title: "Model discovery",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,385 models, 226 providers).",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,467 models, 225 providers).",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -490,7 +500,7 @@ const en: Dict = {
},
showcase: {
title: "Screenshots",
subtitle: "All screenshots below are taken from the running v0.8.0 build.",
subtitle: "All screenshots below are taken from the running v0.8.2 build.",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -541,6 +551,16 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"First dashboard open after a restart drops from ~25s to under 1s: the usage snapshot is persisted locally and read straight at startup, then verified in the background with silent updates when things changed (measured at 64k records)",
"Switching the time range is near-instant: heatmap / model overview / per-range totals are cached per record set, and the wasteful full-table pass behind the 'all time' aggregate is gone",
"No more loading flicker: switching ranges or reopening the page shows the previous data first and revalidates silently; the provider card wall loads config once instead of once per card",
"models.dev snapshot synced to 2026-10-09 (8,467 models / 225 providers)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -748,7 +768,7 @@ const en: Dict = {
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.8.0 · Windows, macOS and Linux now released",
subtitle: "Current version v0.8.2 · Windows, macOS and Linux now released",
autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
ready: "Available",
wip: "In development",
+2 -2
View File
@@ -8,13 +8,13 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo,
};
const VERSION = "0.8.1";
const VERSION = "0.8.2";
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI
* release workflow exactly — verified against the published v0.7.12 assets:
* release workflow exactly — verified against the published v0.8.2 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform,
* except the rpm which uses `Kimi.Switch-<version>-1` (hyphens).
* Filenames interpolate VERSION, so a release bump only changes the