Compare commits
13
Commits
3c3c18925d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35788c2783 | ||
|
|
317562781c | ||
|
|
6a8331d28b | ||
|
|
c08de0ebb0 | ||
|
|
ded166c4c8 | ||
|
|
803c346854 | ||
|
|
f9f9af0fc9 | ||
|
|
9f51ea5e84 | ||
|
|
d190a55bf2 | ||
|
|
ac82135dee | ||
|
|
ef6749b208 | ||
|
|
24cb04b98f | ||
|
|
977f433bb2 |
No files matched your search
@@ -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 上限时显示可点击参考值一键填入;获取模型列表时自动预填
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.3-rc1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+3020
-2139
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.0"
|
||||
version = "0.8.3-rc1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+100
-7
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: "永久删除会话?",
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+3020
-2139
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user