Compare commits

...
13 Commits
Author SHA1 Message Date
KimiSwitch Dev 35788c2783 fix(config): 供应商改名同步重键模型别名 + 删除时清理关联配置(v0.8.3-rc1)
Release / Version consistency (push) Waiting to run
修复「改名后模型别名保留旧前缀,僵尸条目删了又出现」这一类配置不一致。

- 改名时同步重键模型别名、全局默认模型、供应商记住的默认模型、子代理池条目
- 改名冲突(撞已存在供应商名 / 遗留裸别名)加 -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
KimiSwitch Dev 6a8331d28b chore(release): bump to v0.8.2 — 用量统计性能专项 + models.dev 快照同步
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
2026-10-09 16:44:10 +08:00
KimiSwitch Dev c08de0ebb0 chore(data): 同步 models-dev 2026-10-09(8,467 模型 / 225 供应商) 2026-10-09 16:44:09 +08:00
KimiSwitch Dev ded166c4c8 perf(dashboard): 用量统计从秒级降到感知不到 — SQLite 连接单例、配置读取缓存、聚合结果缓存、快照持久化 + 后台校验
功能与数据口径零变化(用量数字、字段、IPC 签名、导出格式均与 v0.8.1 一致)。

- db.rs:新增进程级连接单例(含迁移只跑一次),卡片墙场景由 200+ 次「开连接 + 7 条 DDL」降为 1 次;
  新增写路径代际计数器供各处读缓存判失效
- commands.rs:load_agent_config_command 结果缓存(db 代际 + config.toml 文件戳双重校验),
  N 张供应商卡片由 N 次全量加载(config.toml 解析 + SQLite 全量读 + 逐供应商 settings 读)降为 1 次
- dashboard.rs:
  * build_all_models 单遍轻量聚合,替换原先为取一张模型表而白算日/明细/供应商三份数据的整表遍历
  * SUMMARY_CACHE 按记录集 Arc 身份 + 日期 + 分钟桶缓存热力图/模型总览/各范围汇总,切范围来回近乎零成本
  * 归档会话列表与合成记录按代际 + Arc 身份缓存(合并后的 Arc 保持稳定,否则上层缓存永不命中)
  * 解析结果持久化到 ~/.kimi-switch/scan-cache-<hash>.json:重启后首次 get_summary 直读快照
    (实测 6.4 万条记录 / 1.03 GB:22~27s → 0.85s),随后后台线程全量校验,
    有变化时经 dashboard://records-updated 事件推送,前端无感刷新(先快后准);
    快照损坏/指纹不符静默降级为全量扫描,refresh=true 语义不变
- useDashboard.ts:stale-while-revalidate,切范围/重进页面先呈现旧数据再静默刷新,不再闪加载态;
  订阅上述事件做无感更新,保留原世代号防竞态

测试:Rust 193 项(+16)、前端 130 项全绿
2026-10-09 16:44:03 +08:00
KimiSwitch Dev 803c346854 fix(dashboard): useDashboard 加请求世代校验 — 快速连续切换时间范围时并发 get_summary 响应可能乱序返回导致标签与数据错位;参照 useUsageQuery 的 genRef 模式丢弃过期响应(data/loadStats/error/loading 全部受保护),卸载时使世代失效 2026-10-08 00:28:39 +08:00
KimiSwitch Dev f9f9af0fc9 perf(dashboard): 扫描输入缓存 + 归档合并零浪费 + 锁加固 — resolve_scan_inputs 结果按 config.toml/备份文件 (mtime,len) stat 指纹缓存(命中跳过 15 个配置文件 TOML 解析与 SQLite 读写,set_setting_pub 仅在值变化时写);归档合并先合成后克隆,合成结果为空时原 Arc 直接返回(跳过 6.3 万条 clone+sort,已排序输入的稳定排序为恒等变换);SCAN_CACHE/SCAN_INPUTS_CACHE 改 into_inner 防 Mutex 中毒;新增 4 个测试(Arc::ptr_eq 正反例、配置失效链、跨 home 隔离)并修复测试间 KIMI_SWITCH_DB_PATH 全局 env 并行污染;聚合口径零变化 2026-10-08 00:28:22 +08:00
KimiSwitch Dev 9f51ea5e84 perf(dashboard): 加载统计去掉对整个返回值的 JSON.stringify(数 MB 大对象在 webview 主线程序列化造成卡顿),loadStats 仅保留耗时 ms,中英文案同步 2026-10-07 23:35:30 +08:00
KimiSwitch Dev d190a55bf2 perf(dashboard): 用量统计性能优化 — get_summary/get_day_detail 改异步执行不再阻塞 UI;range_totals 单遍统计(每次调用 7 次全量聚合 → 2 次);扫描缓存改 Arc 共享消除整表深拷贝;wire.jsonl 按 (mtime,len) 逐文件增量解析替换 8s TTL 整表缓存(920MB/6.3万条实测:冷扫描 ~17.7s → ~1.0s,任意间隔切换范围 ~1.5s 且 UI 全程可交互);新增单遍等价性与增量行为测试(含混合增删改 vs 强制全量重扫的全字段指纹比对),聚合口径零变化 2026-10-07 23:35:17 +08:00
KimiSwitch Dev ac82135dee feat(settings): 适配 kimi-code 新增 auto_session_title / repeat_breaker 顶层开关;最大输出未匹配回填 131072;watch 默认值修正 — bump to v0.8.1
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
包含未发布的 ef6749b / 24cb04b / 977f433 三个提交,官网同步更新至 v0.8.1
2026-10-04 00:59:59 +08:00
KimiSwitch Dev ef6749b208 feat(models): 最大输出未匹配 models.dev 时回填默认值 131072(打开编辑页一次性回填 + 获取模型列表预填同口径) 2026-10-04 00:51:50 +08:00
KimiSwitch Dev 24cb04b98f style(i18n): 「最大输出 Token」列名精简为「最大输出」 2026-10-04 00:46:52 +08:00
KimiSwitch Dev 977f433bb2 fix(settings): 适配 kimi-code 近期变更 — [watch] 默认值修正为开启(上游 #4015 翻回默认开);新增顶层开关 auto_session_title(#3962)与 repeat_breaker(#3995,默认开、false 显式写入/true 删键);最大输出 Token 文案修正为「留空则不发送输出上限」(上游 #4091 未配置即省略 max_tokens) 2026-10-04 00:26:05 +08:00
34 changed files with 13049 additions and 6824 deletions

No files matched your search

+32
View File
@@ -6,6 +6,38 @@
---
## v0.8.3-rc1 (2026-10-10)
配置一致性修复预发布版(RC,请先本地验证)。
- **供应商改名时模型别名同步重键**:此前只改模型的 `provider` 字段,别名键保留旧前缀(如 `opencode-go-copy/...`)形成僵尸条目;现在模型别名、全局默认模型、供应商记住的默认模型、子代理池条目全部同步更新
- **僵尸模型不再复活**:加载时 SQLite 镜像会把 config.toml 中缺失的模型合并回来,导致已删模型保存后重现;现在供应商已不存在的模型不再合并
- **改名冲突不再静默吞模型**:改名撞已存在的供应商名或遗留裸别名时,目标别名自动加 `-2`/`-3` 后缀
- **复制供应商不再清空源供应商**:修复复制复用改名逻辑(移动语义)导致源供应商模型被搬走;副本独立、源完整
- **删除供应商/模型的关联清理**:子代理模型池中引用被删模型的条目一并移除;池默认模型自动回落到剩余条目(池清空则移除整节);`default_model` 置空;SQLite 中该供应商的用量查询配置随之清理,且不误删另一 agent(Pi)的设置
- **防呆:放弃修改不落盘**:复制供应商不再立即静默保存,与其他编辑一致,放弃后配置保持原样
- 修复测试基建中 `KIMI_CODE_HOME` / `KIMI_SWITCH_DB_PATH` 的并发竞争(统一 crate 级共享锁)
- models.dev 快照同步至 2026-10-10(8,456 模型 / 226 供应商)
- 测试:前端 146 项(新增 16 项)、Rust 197 项(新增 4 项)
## v0.8.2 (2026-10-09)
性能专项,功能与数据口径不变(用量数字、字段、IPC 接口、导出格式均与 v0.8.1 一致)。
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 6.4 万条记录 / 1.03 GB)。用量解析结果持久化到 `~/.kimi-switch/scan-cache-*.json`,启动直读快照渲染,后台线程全量校验,有变化时事件推送无感更新(先快后准)
- **切换时间范围几乎瞬时**:热力图 / 模型总览 / 各范围汇总按记录集缓存;并去掉原「全时段」聚合的整表无用遍历
- **界面不再闪加载态**:切范围与重进页面先呈现旧数据再静默刷新(stale-while-revalidate)
- **供应商卡片墙变快**:供应商配置读取缓存(含外部 CLI 改动失效路径),N 张卡片由 N 次全量加载降为 1 次
- **SQLite 连接复用**:连接与迁移改为每库一次,卡片墙场景由 200+ 次降为 1 次
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商)
## v0.8.1 (2026-10-04)
- **全局配置新增两个顶层开关**:自动生成会话标题(`auto_session_title`)与重复调用拦截(`repeat_breaker`),默认开启,关闭显式写 `false`、开启删键跟随上游默认(适配 kimi-code #3962 / #3995)
- **最大输出未匹配 models.dev 时回填默认值 131072**(128K 兜底,编辑页回填与获取模型列表预填同口径)
- **修复 `[watch] enabled` 默认值显示**:上游 #4015 已翻回默认开启,设置页同步修正
- **「最大输出 Token」列名精简为「最大输出」**,描述对齐上游 #4091(留空即不发送输出上限)
## v0.8.0 (2026-10-03)
- **模型映射新增「最大输出 Token」列**:逐模型设置 `max_output_size`(写入 config.toml,留空用上游默认),models.dev 有 output 上限时显示可点击参考值一键填入;获取模型列表时自动预填
+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,17 @@
# KimiSwitch v0.8.1
## 新功能
- **全局配置新增两个顶层开关**(适配 kimi-code 近期版本):
- **自动生成会话标题**(`auto_session_title`,上游 #3962):允许客户端自动生成会话标题,默认开启;关闭后显式写入 `false`,开启即删键跟随上游默认
- **重复调用拦截**(`repeat_breaker`,上游 #3995):连续重复相同工具调用时提醒并强制停止,默认开启;可被环境变量 `KIMI_CODE_REPEAT_BREAKER` 覆盖
- **最大输出未匹配时回填默认值 131072**(Kimi Code):打开供应商编辑页自动回填时,models.dev 未收录的模型按 128K(131072)兜底;「获取模型列表」新增模型同口径预填
## 修复
- **`[watch] enabled` 默认值修正为开启**:kimi-code #4015 已把文件监听默认值翻回开启(2.0.2 曾短暂默认关闭),设置页的开关显示默认值同步修正
- **「最大输出 Token」列名精简为「最大输出」**;列描述修正为「留空则不发送输出上限」(对齐上游 #4091:未显式配置时不发送 `max_tokens`,避免严格服务栈如 bare vLLM 报 400)
## 备注
- 前端 130 测试 / Rust 162 测试全量通过
@@ -0,0 +1,21 @@
# KimiSwitch v0.8.2
本版为**性能专项**:把主要操作的等待时间从「秒级」压到「感知不到」,功能与数据口径完全不变。
## 性能优化
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 1065 个 `wire.jsonl` / 1.03 GB / 6.4 万条记录)。用量解析结果持久化到本地快照(`~/.kimi-switch/scan-cache-*.json`),启动时直接读快照渲染,后台线程同时做全量校验;校验发现新记录时自动推送事件、无感更新数字(先快后准,无需手动刷新)
- **切换时间范围几乎瞬时**:热力图、模型总览、各范围汇总结果按记录集与日期缓存,来回切换不再重复全量聚合;未命中时也不再做无用的整表遍历(原「全时段」统计会为取一张模型表而白算日/明细/供应商三份数据)
- **界面不再闪加载态**:仪表盘切范围与重新进入页面时先呈现上次数据再后台静默刷新,不再出现数据已经拿过却先显示骨架屏的情况
- **供应商卡片墙显著变快**:供应商配置读取结果加缓存(含敏感字段写入与外部 CLI 改动两条失效路径),N 张卡片从 N 次全量读库 + 配置解析降为 1 次
- **SQLite 连接复用**:库连接与建表迁移从「每次调用各做一遍」改为每库一次,卡片墙场景下由 200+ 次降为 1 次
## 其他
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商),价格与上下文长度随之刷新
## 备注
- 数据口径与功能行为与 v0.8.1 完全一致:用量数字、字段、IPC 接口、导出格式均无变化;「强制刷新」仍会忽略一切缓存重新全量解析
- 测试全量通过:Rust 193 项(新增 16 项,覆盖缓存失效、磁盘快照往返等价、损坏快照降级等)、前端 130 项
- 首次使用本版本时会重建一次磁盘快照(沿用原有全量扫描耗时),此后每次启动均为快速加载
@@ -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 构建)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.8.0",
"version": "0.8.3-rc1",
"type": "module",
"scripts": {
"dev": "vite",
+3020 -2139
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.0"
version = "0.8.3-rc1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.8.0"
version = "0.8.3-rc1"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+350 -2
View File
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, UNIX_EPOCH};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
@@ -79,8 +79,61 @@ fn load_pi_native_config() -> Result<Config, String> {
Ok(config)
}
/// Cached `load_agent_config_command` results, keyed by agent. The full load
/// parses config.toml, reads all of SQLite and runs `merge_usage_kinds` (two
/// settings reads per provider); a usage-card wall triggers one load per
/// provider card, so caching turns N loads into one. Entries are validated
/// against the db generation counter (bumped by every write path in db.rs)
/// plus the config.toml file stamp (catches edits made outside the app, e.g.
/// the CLI's /provider command).
static CONFIG_CACHE: Mutex<Option<HashMap<String, (u64, Option<(u64, u64)>, Config)>>> =
Mutex::new(None);
/// (mtime_ms, len) of the authoritative on-disk config file for the agent, or
/// None when the file is absent / the agent has no authoritative file (Pi is
/// SQLite-first, so the db generation alone covers it).
fn config_file_stamp(agent: &Agent) -> Option<(u64, u64)> {
match agent {
Agent::KimiCode => {
let md = std::fs::metadata(crate::kimi_code_io::kimi_code_config_path()).ok()?;
let mtime_ms = md
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis() as u64;
Some((mtime_ms, md.len()))
}
Agent::Pi => None,
}
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
let stamp = config_file_stamp(&agent);
let gen = db::generation();
let key = agent.as_str().to_string();
{
let guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(map) = guard.as_ref() {
if let Some((cached_gen, cached_stamp, config)) = map.get(&key) {
if *cached_gen == gen && *cached_stamp == stamp {
return Ok(config.clone());
}
}
}
}
let config = load_agent_config_uncached(agent)?;
let mut guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
guard
.get_or_insert_with(HashMap::new)
.insert(key, (gen, stamp, config.clone()));
Ok(config)
}
fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
@@ -122,11 +175,18 @@ pub fn load_agent_config_command(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());
}
}
@@ -177,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(())
}
@@ -2314,3 +2393,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("{}"));
});
}
}
+2269 -118
View File
File diff suppressed because it is too large. Load diff
+100 -7
View File
@@ -6,6 +6,8 @@
//! a provider.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use anyhow::Context;
use indexmap::IndexMap;
@@ -17,6 +19,51 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type DbResult<T> = anyhow::Result<T>;
/// Process-wide cached connection, keyed by the resolved db path. Opening a
/// SQLite connection plus running the DDL migrations costs several ms; before
/// this cache every public helper paid that cost on every call (a usage-card
/// wall could open 200+ connections per refresh). `Connection` is `Send` but
/// not `Sync`, so a `Mutex` is the correct `static` wrapper — all db access is
/// serialized, which is fine for a single-user desktop app.
static DB_CONN: Mutex<Option<(PathBuf, Connection)>> = Mutex::new(None);
/// Monotonic generation counter, bumped by every write path. Read-side caches
/// (config cache, archived-session cache) key on this to detect staleness.
static DB_GEN: AtomicU64 = AtomicU64::new(0);
pub(crate) fn generation() -> u64 {
DB_GEN.load(Ordering::Relaxed)
}
pub(crate) fn bump_generation() {
DB_GEN.fetch_add(1, Ordering::Relaxed);
}
/// Run `f` against the cached connection for the current `db_path()`, opening
/// (and migrating) it on first use or whenever the path changed (tests point
/// `KIMI_SWITCH_DB_PATH` at per-test temp dirs).
fn with_conn<T>(f: impl FnOnce(&mut Connection) -> DbResult<T>) -> DbResult<T> {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
let path = db_path();
let needs_open = match guard.as_ref() {
Some((cached_path, _)) => cached_path != &path,
None => true,
};
if needs_open {
*guard = Some((path, init_db()?));
}
let (_, conn) = guard.as_mut().expect("connection just opened");
f(conn)
}
/// Drop the cached connection so tests that switch `KIMI_SWITCH_DB_PATH`
/// between temp dirs never observe cross-test state.
#[cfg(test)]
pub(crate) fn close_cached_conn_for_tests() {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
}
pub fn kimi_switch_data_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".kimi-switch"))
@@ -158,7 +205,10 @@ pub fn init_db() -> DbResult<Connection> {
}
pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut conn = init_db()?;
with_conn(|conn| load_config_inner(conn, agent))
}
fn load_config_inner(conn: &mut Connection, agent: &Agent) -> DbResult<Config> {
let tx = conn.transaction()?;
let default_model = get_setting_tx(&tx, &default_model_key(agent))?;
@@ -249,7 +299,12 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
}
pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
let mut conn = init_db()?;
with_conn(|conn| save_config_inner(conn, agent, config))?;
bump_generation();
Ok(())
}
fn save_config_inner(conn: &mut Connection, agent: &Agent, config: &Config) -> DbResult<()> {
let tx = conn.transaction()?;
tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?;
@@ -340,7 +395,7 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
/// Public helper: read a single setting without an explicit transaction.
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
let conn = init_db()?;
with_conn(|conn| {
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
@@ -348,22 +403,56 @@ pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
} else {
Ok(None)
}
})
}
/// Public helper: write a single setting in its own transaction.
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
let mut conn = init_db()?;
with_conn(|conn| {
let tx = conn.transaction()?;
set_setting_tx(&tx, key, value)?;
tx.commit()?;
Ok(())
})?;
bump_generation();
Ok(())
}
/// Public helper: delete a single setting (no-op if the key does not exist).
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
let conn = init_db()?;
with_conn(|conn| {
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
})?;
bump_generation();
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(())
}
// ---------------------------------------------------------------------------
@@ -413,7 +502,7 @@ pub struct ArchivedSessionSnapshot {
/// Insert or replace one archived-session snapshot (keyed by session id, which
/// is a UUID and never reused).
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
let conn = init_db()?;
with_conn(|conn| {
let day_stats = serde_json::to_string(&row.day_stats)?;
conn.execute(
"INSERT OR REPLACE INTO archived_sessions
@@ -432,12 +521,15 @@ pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
],
)?;
Ok(())
})?;
bump_generation();
Ok(())
}
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
/// to an empty breakdown instead of failing the whole list.
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
let conn = init_db()?;
with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
total_tokens, total_cost_usd, day_stats
@@ -462,6 +554,7 @@ pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
out.push(row?);
}
Ok(out)
})
}
fn provider_type_for_str(s: &str) -> ProviderType {
+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());
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.8.0",
"version": "0.8.3-rc1",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+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,
};
+24 -6
View File
@@ -236,19 +236,37 @@ export function AgentSettingsPanel({ rawOther, onChange, models, defaultModel }:
</Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; 2.0.2 flipped the
default off, #4015 flipped it back on). KIMI_CODE_WATCH outranks
this config at runtime — the env var is probed by
get_experimental_env_status as a non-flag entry but deliberately
locks nothing here, the toggle just documents the config value
that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
checked={settings.watch?.enabled ?? true}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("behaviorSettings")}>
{/* Top-level booleans, both default true upstream: false is written
explicitly, true removes the key (see setAgentSettings). */}
<Checkbox
label={t("autoSessionTitle")}
checked={settings.auto_session_title ?? true}
onChange={(checked) => update({ auto_session_title: checked })}
/>
<p className="text-xs text-content-muted">{t("autoSessionTitleDesc")}</p>
<Checkbox
label={t("repeatBreaker")}
checked={settings.repeat_breaker ?? true}
onChange={(checked) => update({ repeat_breaker: checked })}
/>
<p className="text-xs text-content-muted">{t("repeatBreakerDesc")}</p>
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+22 -9
View File
@@ -39,6 +39,9 @@ const CAPABILITY_LABELS: Record<
tool_use: "capToolUse",
};
/** Fallback max-output cap (128K) for models models.dev doesn't know. */
const FALLBACK_MAX_OUTPUT = 131_072;
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
@@ -577,8 +580,9 @@ function ModelMapping({
// the max-output "参考" hints appear even when this panel mounted first.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
// One-shot backfill: models with no max_output_size yet get the models.dev
// `output` cap filled in automatically. onModelChange only mutates the
// in-memory config — the user still reviews and presses 保存配置.
// `output` cap, or the 128K fallback when nothing matches. onModelChange
// only mutates the in-memory config — the user still reviews and presses
// 保存配置.
const backfillDone = useRef(false);
const modelsRef = useRef(models);
modelsRef.current = models;
@@ -591,8 +595,12 @@ function ModelMapping({
backfillDone.current = true;
for (const m of modelsRef.current) {
if (readMaxOutputSize(m.raw_other) !== undefined) continue;
const output = getModelRef(m.model)?.output;
if (output !== undefined) onModelChange(withMaxOutputSize(m, output));
onModelChange(
withMaxOutputSize(
m,
getModelRef(m.model)?.output ?? FALLBACK_MAX_OUTPUT
)
);
}
});
return () => {
@@ -669,11 +677,16 @@ function ModelMapping({
: fetchThinking
? ["thinking"]
: [],
// Seed the max_output_size override only when models.dev knows the
// cap — an absent key means the upstream default applies. kimi_code
// only: Pi's equivalent is `maxTokens` (not written by this UI).
...(agent === "kimi_code" && ref?.output
? { raw_other: setMaxOutputSize(undefined, ref.output) }
// Seed the max_output_size override with the models.dev cap, falling
// back to 128K when nothing matches. kimi_code only: Pi's equivalent
// is `maxTokens` (not written by this UI).
...(agent === "kimi_code"
? {
raw_other: setMaxOutputSize(
undefined,
ref?.output ?? FALLBACK_MAX_OUTPUT
),
}
: {}),
});
}
+1 -1
View File
@@ -179,7 +179,7 @@ export function DashboardPage() {
</div>
{loadStats && !loading && (
<span className="hidden text-[10px] text-content-muted sm:inline">
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
{t("loadStats", { ms: loadStats.ms })}
</span>
)}
</div>
+85 -13
View File
@@ -1,32 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
/**
* Emitted by the backend when the background verification walk found records the
* fast (disk-cache) path did not have. The payload is empty — the hook only
* needs to know that a re-fetch is worth making.
*/
const RECORDS_UPDATED_EVENT = "dashboard://records-updated";
const RANGES: readonly DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
function readStoredRange(): DashboardRange {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
if (stored && (RANGES as readonly string[]).includes(stored)) {
return stored;
}
} catch {
// ignore
}
return "30d";
});
}
const [data, setData] = useState<SummaryResult | null>(null);
/**
* Last successful payload per range, kept across mounts so a range switch (and
* reopening the page) paints instantly from memory and revalidates in the
* background instead of showing a spinner over data that is already known.
* Bounded by the five ranges the dashboard offers.
*/
const summaryCache = new Map<DashboardRange, SummaryResult>();
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(readStoredRange);
const [data, setData] = useState<SummaryResult | null>(
() => summaryCache.get(readStoredRange()) ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Round-trip timing for the last get_summary call (user-perceived lag). */
const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null);
const [loadStats, setLoadStats] = useState<{ ms: number } | null>(null);
// Generation counter: stale responses (superseded range / unmounted) are dropped.
const genRef = useRef(0);
// The event handler must call the *current* refresh, not the one captured when
// the listener was installed, so it reads the callback through a ref.
const refreshRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
const refresh = useCallback(async (force = false) => {
setLoading(true);
const gen = ++genRef.current;
// Stale-while-revalidate: hand the cached payload for this range to the
// view before the request goes out, so a range switch is instant and the
// response only refreshes numbers that are already on screen. `loading` is
// raised only when there is nothing to show (first load of a range) or when
// the user asked for a refresh — the button keeps its busy state, the silent
// revalidation behind a cached range does not.
const cached = summaryCache.get(range);
if (cached) setData(cached);
setLoading(force || !cached);
setError(null);
const start = performance.now();
try {
@@ -34,23 +70,59 @@ export function useDashboard() {
range,
refresh: force,
});
if (genRef.current !== gen) return;
summaryCache.set(range, result);
setData(result);
setLoadStats({
ms: Math.round(performance.now() - start),
kb: Math.round(JSON.stringify(result).length / 1024),
});
setLoadStats({ ms: Math.round(performance.now() - start) });
} catch (err) {
if (genRef.current !== gen) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
if (genRef.current === gen) setLoading(false);
}
}, [range]);
refreshRef.current = refresh;
useEffect(() => {
refresh();
}, [refresh]);
// The first load of a session answers from the on-disk snapshot, which can be
// behind a session written since the app last ran. The backend verifies in the
// background and signals here when the numbers moved; re-fetching silently
// (never raising `loading`) keeps the already-drawn dashboard on screen and
// just corrects it. A silent call still takes a fresh generation, so it cannot
// be interleaved with a range switch in flight.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen(RECORDS_UPDATED_EVENT, () => {
refreshRef.current();
})
.then((fn) => {
// The effect may have been torn down before `listen` resolved.
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// No event bridge (e.g. a browser build): the dashboard still works,
// it just will not be corrected mid-session.
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
const changeRange = useCallback((next: DashboardRange) => {
setRange(next);
try {
+11 -4
View File
@@ -112,7 +112,7 @@ export const enTranslations: Record<TranslationKey, string> = {
envPairs: "Env pairs",
addEnv: "+ Add",
modelMappingDesc: "Display name only affects the /model menu.",
maxOutputSizeDesc: "Max output tokens writes max_output_size — leave blank to use the upstream default.",
maxOutputSizeDesc: "Max output writes max_output_size — leave blank to send no output cap upstream.",
oneClickSetup: "One-click setup",
fetchModels: "Fetch models",
fetchingModels: "Fetching...",
@@ -122,7 +122,7 @@ export const enTranslations: Record<TranslationKey, string> = {
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
maxOutputSize: "Max output tokens",
maxOutputSize: "Max output",
maxOutputRef: "Ref {value}",
default: "Default",
operation: "Operation",
@@ -188,7 +188,14 @@ export const enTranslations: Record<TranslationKey, string> = {
watchSettings: "File Watching",
watchEnabled: "Enable file watching",
watchEnabledDesc:
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, on by default). The KIMI_CODE_WATCH environment variable overrides this setting.",
behaviorSettings: "Behavior",
autoSessionTitle: "Auto session title",
autoSessionTitleDesc:
"Let clients auto-generate session titles (on by default; turning it off writes false).",
repeatBreaker: "Repeat breaker",
repeatBreakerDesc:
"Remind and force-stop on consecutively repeated identical tool calls (on by default). The KIMI_CODE_REPEAT_BREAKER environment variable overrides this setting.",
permissionRules: "Permission Rules",
permissionDecision: "Decision",
permissionPattern: "Pattern",
@@ -250,7 +257,7 @@ export const enTranslations: Record<TranslationKey, string> = {
scanning: "Scanning…",
refresh: "Refresh",
refreshing: "Refreshing…",
loadStats: "Loaded in {ms} ms · {kb} KB payload",
loadStats: "Loaded in {ms} ms",
refreshed: "Refreshed",
cancel: "Cancel",
confirmDeleteTitle: "Delete session forever?",
+11 -4
View File
@@ -109,7 +109,7 @@ export const zhTranslations = {
envPairs: "Env 键值对",
addEnv: "+ 添加",
modelMappingDesc: "显示名称只影响 /model 菜单。",
maxOutputSizeDesc: "「最大输出 Token」写入 max_output_size,留空则用上游默认值。",
maxOutputSizeDesc: "「最大输出」写入 max_output_size,留空则不发送输出上限。",
oneClickSetup: "一键设置",
fetchModels: "获取模型列表",
fetchingModels: "获取中...",
@@ -119,7 +119,7 @@ export const zhTranslations = {
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
maxOutputSize: "最大输出 Token",
maxOutputSize: "最大输出",
maxOutputRef: "参考 {value}",
default: "默认",
operation: "操作",
@@ -181,7 +181,14 @@ export const zhTranslations = {
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,默认开启)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
behaviorSettings: "行为开关",
autoSessionTitle: "自动生成会话标题",
autoSessionTitleDesc:
"允许客户端自动生成会话标题(默认开启;关闭后写入 false)。",
repeatBreaker: "重复调用拦截",
repeatBreakerDesc:
"连续重复相同工具调用时提醒并强制停止(默认开启)。环境变量 KIMI_CODE_REPEAT_BREAKER 会覆盖本配置。",
permissionRules: "权限规则",
permissionDecision: "处置",
permissionPattern: "模式",
@@ -242,7 +249,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
loadStats: "加载 {ms} ms",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",
+48 -5
View File
@@ -201,8 +201,8 @@ describe("loop_control.compaction_max_attempts", () => {
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// [watch] enabled (kimi-code 2.0.1+) — top-level section; 2.0.2 flipped the
// default off, #4015 flipped it back on. Mirrors the [background] handling.
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
@@ -213,9 +213,9 @@ function watchOf(raw: unknown): Record<string, unknown> {
}
describe("[watch] enabled", () => {
it("reads as false when the section or key is absent (2.0.2 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(false);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
it("reads as true when the section or key is absent (#4015 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(true);
});
it("reads an explicit value", () => {
@@ -248,6 +248,49 @@ describe("[watch] enabled", () => {
});
});
// ---------------------------------------------------------------------------
// Top-level booleans (auto_session_title #3962 / repeat_breaker #3995):
// upstream default true — false is written explicitly, true removes the key.
// ---------------------------------------------------------------------------
describe("top-level boolean switches", () => {
it("reads as undefined when the key is absent (default true)", () => {
const s = getAgentSettings({});
expect(s.auto_session_title).toBeUndefined();
expect(s.repeat_breaker).toBeUndefined();
});
it("reads explicit false values", () => {
const s = getAgentSettings({
auto_session_title: false,
repeat_breaker: false,
});
expect(s.auto_session_title).toBe(false);
expect(s.repeat_breaker).toBe(false);
});
it("writes false explicitly and removes the key on true", () => {
const off = setAgentSettings({}, { repeat_breaker: false }) as Record<
string,
unknown
>;
expect(off.repeat_breaker).toBe(false);
const on = setAgentSettings(
{ repeat_breaker: false },
{ repeat_breaker: true }
) as Record<string, unknown>;
expect("repeat_breaker" in on).toBe(false);
});
it("keeps an explicit false on saves that do not touch it", () => {
const next = setAgentSettings(
{ auto_session_title: false },
{ thinking: { effort: "high" } }
) as Record<string, unknown>;
expect(next.auto_session_title).toBe(false);
});
});
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
+27 -3
View File
@@ -13,10 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
background: {
keep_alive_on_exit: false,
},
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
// default to off, so an absent key reads as "off".
// `[watch] enabled` — kimi-code 2.0.1 added the key; 2.0.2 flipped the
// default to off, then #4015 flipped it back on, so an absent key reads
// as "on".
watch: {
enabled: false,
enabled: true,
},
permission: { rules: [] },
hooks: [],
@@ -37,6 +38,7 @@ function getSection<T>(rawOther: unknown, key: string): T | undefined {
}
export function getAgentSettings(rawOther: unknown): AgentSettings {
const root = asRecord(rawOther);
const sectionLoop = getSection<AgentSettings["loop_control"]>(
rawOther,
"loop_control"
@@ -92,6 +94,16 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
: {}),
},
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
// Top-level booleans (upstream default true): keep only explicit values,
// an absent key means "on" — the UI shows `?? true`.
auto_session_title:
typeof root.auto_session_title === "boolean"
? root.auto_session_title
: undefined,
repeat_breaker:
typeof root.repeat_breaker === "boolean"
? root.repeat_breaker
: undefined,
};
}
@@ -166,5 +178,17 @@ export function setAgentSettings(
delete root.hooks;
}
// Top-level booleans with an upstream default of true: an explicit false
// is written; true/undefined removes the key so the config tracks the
// upstream default instead of pinning it.
for (const key of ["auto_session_title", "repeat_breaker"] as const) {
const value = patch[key] ?? current[key];
if (value === false) {
root[key] = false;
} else {
delete root[key];
}
}
return root;
}
+3281 -2318
View File
File diff suppressed because it is too large. Load diff
+3020 -2139
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 };
}
+7
View File
@@ -172,6 +172,13 @@ export interface AgentSettings {
background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
/** Top-level config.toml key (kimi-code #3962): let clients auto-generate
* session titles. Default true; only an explicit false disables. */
auto_session_title?: boolean;
/** Top-level config.toml key (kimi-code #3995): repeat-breaker for
* consecutively repeated identical tool calls. Default true; env
* KIMI_CODE_REPEAT_BREAKER outranks this config. */
repeat_breaker?: boolean;
permission?: {
rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is
+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.0",
"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",
+50 -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,25 @@ 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",
items: [
"全局配置新增两个顶层开关:自动生成会话标题(auto_session_title)与重复调用拦截(repeat_breaker),默认开启",
"最大输出未匹配 models.dev 时回填默认值 131072(128K 兜底)",
"修复 [watch] enabled 默认值显示(上游 #4015 翻回默认开启);「最大输出」列描述对齐上游 #4091",
],
},
{
version: "v0.8.0",
date: "2026-10-03",
@@ -339,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: "开发中",
@@ -408,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: "",
@@ -421,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" },
],
},
@@ -468,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",
},
{
@@ -481,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",
@@ -532,6 +551,25 @@ 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",
items: [
"Two new top-level switches in Global Settings: auto session title (auto_session_title) and repeat breaker (repeat_breaker), on by default",
"Max output backfills a 131072 (128K) default when models.dev has no match",
"Fixed the [watch] enabled default display (upstream #4015 flipped it back on); Max output column wording aligned with upstream #4091",
],
},
{
version: "v0.8.0",
date: "2026-10-03",
@@ -730,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.0";
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