Compare commits

..
19 Commits
Author SHA1 Message Date
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
KimiSwitch Dev 3c3c18925d feat(models): 模型映射新增最大输出 Token 列(max_output_size 直写 config.toml,models.dev 参考值一键填入/空值自动补全),移除无效的声明支持 1M 复选框;思考等级顺序修正为 low/medium/high/xhigh/max 且标签全英文 — bump to v0.8.0
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-03 23:28:36 +08:00
KimiSwitch Dev 30371a1ba6 feat(website): 官网更新至 v0.8.0(下载版本号/截图说明/JSON-LD/模型库数据 8,385·226,更新日志补 v0.8.0 与 v0.7.23 条目) 2026-10-03 23:28:15 +08:00
KimiSwitch Dev 4d87183768 chore(data): 同步 models-dev 快照至 2026-10-03(8,385 模型 / 226 供应商,新增 output 字段) 2026-10-03 23:28:04 +08:00
KimiSwitch Dev 88683b331e fix(release): GitHub workflow 兼容 docs/release-notes/release-notes-<tag>.md 命名 2026-10-02 19:54:56 +08:00
KimiSwitch Dev 26cb88ad0e feat(thinking): 思考等级扩展五档(Max/XHigh)+ 按默认模型 support_efforts 能力感知;删除过时的 max→high 迁移;Segmented 支持逐选项禁用/tooltip — bump to v0.7.23
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-02 13:00:35 +08:00
KimiSwitch Dev 50f59cac79 fix(release): publish-gitea.sh 兼容 docs/release-notes/release-notes-<tag>.md 命名(此前 Release 正文回退为 tag 名) 2026-10-02 13:00:33 +08:00
KimiSwitch Dev ecc98fd8cd chore(data): 同步 models-dev 快照至 2026-10-02(8,359 模型 / 225 供应商) 2026-10-02 13:00:32 +08:00
KimiSwitch Dev 13e15657af fix(usage): 列表支持显式模板查询(Sub2API/NewAPI)——对齐后端绕过 usage_kinds 的口径 — bump to v0.7.22
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-09-29 10:42:13 +08:00
KimiSwitch Dev 11286419b1 feat(models-dev): 高级设置新增 models.dev 在线同步 — 上下文/能力/单价即时生效 + 一键恢复内置;修复 public 快照构建漂移;确认适配 kimi-code 2.1.0 — bump to v0.7.21
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-09-24 00:19:10 +08:00
KimiSwitch Dev 26e783c2ba chore(data): 同步 models-dev 快照至 2026-09-20(7870 模型 / 222 供应商) 2026-09-20 10:29:40 +08:00
KimiSwitch Dev 11f87fd9af chore(docs): release notes 归档 docs/release-notes/ + 根目录汇总 CHANGELOG.md
- 20 份 release-notes-v*.md 移入 docs/release-notes/(git mv 保留历史)
- 新增 CHANGELOG.md:v0.7.1~v0.7.20 倒序浓缩汇总(日期取各文件原始添加提交)
- publish-gitea.sh 与 release.yml 改为 docs/release-notes/<tag>.md 优先、根目录回退
2026-09-20 09:58:01 +08:00
64 changed files with 310885 additions and 264583 deletions

No files matched your search

+3 -1
View File
@@ -73,7 +73,9 @@ jobs:
id: notes
shell: bash
run: |
FILE="release-notes-${{ github.ref_name }}.md"
FILE="docs/release-notes/${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="docs/release-notes/release-notes-${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="release-notes-${{ github.ref_name }}.md"
{
echo 'body<<RELEASE_BODY_EOF'
if [ -f "$FILE" ]; then
+169
View File
@@ -0,0 +1,169 @@
# Changelog
**Kimi Switch** — Windows 桌面端的 Kimi Code CLI 配置管理器,统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
本文件汇总各版本要点,按版本倒序排列;每个版本的**完整 release notes**(含安装说明、平台产物、已知问题)见 [`docs/release-notes/`](./docs/release-notes/)。
---
## 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 上限时显示可点击参考值一键填入;获取模型列表时自动预填
- **models.dev 快照新增 output 上限字段**:同步至 2026-10-03(8,385 模型 / 226 供应商,output 覆盖 97.4%);应用内同步(Rust 侧)同步支持,避免同步后字段丢失
- **移除无效的「声明支持 1M」复选框**:该控件读时由上下文长度派生、写时不持久化,勾选从不生效,由「最大输出 Token」列取代
## v0.7.23 (2026-09-30)
- **思考等级五档 + 能力感知**:全局配置的思考等级扩展为低 / 中 / 高 / Max / XHigh,按当前默认模型声明的 `support_efforts` 自动置灰不支持档位并提示原因;models.dev 标记不支持思考的模型整体禁用思考区;未声明档位的模型保留可选并提示"上游可能拒绝"
- **修正过时适配**:删除 max→high 读取迁移(kimi-code 2.1.1 中 max 为合法档位),存储的 max 与任意手写档位值原样保留显示;子代理页继承档位显示同步修正
## v0.7.22 (2026-09-24)
- **修复 Sub2API / NewAPI 模板查询不显示**:为无预设识别类型的自定义供应商配置 Sub2API(或 NewAPI)模板后,弹窗测试可查但供应商列表始终不显示——前端列表的查询/显示门槛只看预设 `usageKinds`,显式模板被漏掉;现在与后端口径对齐(模板查询绕过 usage_kinds),列表正常显示余额 / 配额
## v0.7.21 (2026-09-24)
- **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
- **修复构建链路数据漂移**:`fetch-models-dev.mjs` 现在同时写入 `src/lib/models-dev.json`(Rust 计价内嵌)与 `public/models-dev.json`(前端静态资源)——此前 public 副本需手工拷贝,已两次滞后于 src/lib
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量/quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(tui.toml)不涉及配置管理面
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
## v0.7.20 (2026-09-20)
- **套餐用量阈值预警**:用量配置新增「预警阈值 %」(0-100,0/空为关闭),任一用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除
- **Kimi For Coding 加力钱包**:0.43.1 起解析 `boosterWallet` 并展示为独立余额行(💰 月度用量 / 总额 / 余额,含币种)
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762,凭证来源可切换「直接填写 / 环境变量」,密钥不写入 `config.toml`,与 `api_key` 互斥;SQLite 新增列含自动迁移
- **Kimi Code 版本跟踪器**:设置页新增版本卡片——本机已装版本、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
- **供应商一键体检**:并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重查账单,三态判定避免误报(端点不支持模型列表计中性)
- 新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5)与 `[watch] enabled` 开关(上游 2.0.1 #3892);已确认适配 kimi-code 2.0.2
## v0.7.19 (2026-09-14)
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title`(上游 #3749),上游注册表现剩 `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
- 兼容性核查:0.43.0 的 wire 持久化重建(#3737)不影响用量统计,相关记录全部保留;`loop_control.compaction_max_attempts`(#3750)保存不丢失
- 残留的 `auto_session_title` 旧键会被上游静默忽略,无需手动清理
## v0.7.18 (2026-09-14)
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期批量归档,替代逐个选择,并反馈已归档 / 跳过数量
- **归档用量快照落库**:归档瞬间按「天 × 模型」粒度把 token 用量与费用写入 SQLite `archived_sessions` 表
- 删除已归档会话释放磁盘后不再损失历史统计——趋势图、热力图、模型统计、区间合计自动合并快照
- 快照与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),pricing 断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
## v0.7.17 (2026-09-13)
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板余额查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,无需网页后台 Access Token
- 解析三种模式:单 Key 总额度(quota)、5 小时 / 每日 / 7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI,修正主域被误判导致查询 404 的问题
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
- **模型映射「获取模型列表」新增批量选择**:全选 / 取消全选 / 反选,配合已添加标记批量接入中转站模型
## v0.7.16 (2026-09-10)
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除 4 个已转正/删除的开关(子代理次主力模型、MiniDB 读模型、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭)
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 静态目录同步
## v0.7.15 (2026-09-09)
- **用量统计新增「昨天」选项卡**:时间范围为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点,概览卡扩展为 5 张
- **新增「模型用量统计」趋势选项卡**:跨供应商按模型名汇总(同一模型合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用
- **修复 opencode Go 套餐子代理请求 400**:Go 网关强制要求 `x-opencode-session` 头,导出时凡指向 Go 端点的供应商自动补写(已有自定义值保留),按量计费的 `zen/v1` 不受影响
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商);`kimi/k2.5` 官方条目下架后暂解析到 302ai 转售价(0.66 / 3.3)
## v0.7.14 (2026-09-06)
- **修复关闭「启用用量查询」后仍残留查询行为**:此前关闭后前端仍发起查询并持续显示「查询失败 · 用量查询已关闭」红色错误行,现在关闭即停止查询并隐藏整行用量信息,重开后自动恢复
- 在途查询结果在开关切换瞬间作废且不再写入共享缓存,避免过期失败状态在 5 分钟内复活
## v0.7.13 (2026-09-05)
- **适配 kimi-code 0.41.0**:上游移除 `file_history` flag(轮级文件历史转为恒开),镜像全链路同步删除;Auto(永不询问)模式不再拦截危险命令,设置页文案(中英)同步修正
- **账单查询改进**:`codingplan.site` 系供应商自动识别为 NewAPI 余额查询;凭据缺失不再误报「网络异常」而给出本地化配置提示;金额显示统一为两位小数并去掉 `¤` 占位符
- 新增 usage 错误本地化单元测试
## v0.7.12 (2026-09-03)
- **适配 kimi-code 0.40.1(flag 9→10)**:新增 `file_history` / `search_worker`;`secondary-model` 默认开启并显示徽章;新增危险命令守卫开关 `[permission] dangerous_command_guard`(默认开启)
- **flag 优先级语义修正**:单 flag 环境变量 > `[experimental]` 显式配置 > 总开关环境变量 > 默认值,总开关不再锁定全部开关
- **修复 WebUI 打开竞态**:并发调用串行化 + 后到点击自动聚焦已有窗口,失败路径不再误杀正在使用的 server,前端增加「打开中...」防重入态
- models-dev 快照更新至 7495 模型 / 212 供应商
## v0.7.11 (2026-08-29)
- **适配 kimi-code 0.39.1**:`[thinking]` 保留开关改写 `keep = "off"`(修复布尔值导致整个节被 v2 校验丢弃);`loop_control` 默认只写新键 `max_attempts_per_step` 并清理旧键;思考强度移除 `max` 档(上游迁移为 `high`)
- **会话归档与上游 v2 对齐**:不再搬动文件,改为在 `state.json` 写入 `archived` / `archivedAt` 元数据,CLI 侧与 Kimi Switch 侧归档状态互通;旧 `.kcd-archive/` 物理归档仍可识别恢复
- **修复子代理模型池无法添加第二个条目**:默认模型自动物化进池,不再被校验拦截
- models.dev 快照刷新至 7482 个模型 / 207 个供应商
## v0.7.10 (2026-08-29)
- **修复保存配置时误删 CLI 新增的顶层配置节**:以导入时顶层键为基线,CLI 后加的 `[task]` / `[swarm]` / `[cron]` / `[tools]` / `[identity]` / `[token_counting]` 等节原样保留,界面中显式删除的节仍会移除
- **修复未知供应商类型被改写**:`config.toml` 中新增的供应商 `type` 往返读写后原样保留(此前会被改成 `kimi`),下拉框标注「未知类型」,SQLite 快照恢复路径同步修复
- 新增 3 个配置往返回归测试,Rust 测试 106 项全绿,前端 tsc / vitest 通过
## v0.7.9 (2026-08-27)
- 模型库刷新至 **7343 个模型 / 203 个供应商**,内置快照与官网静态资源同步更新
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare Workers AI、DeepInfra、HuggingFace、火山引擎等约 20 条渠道,GLM-5.3-Flash 定价 $0.075/$0.25(每百万 token)
- 新增 Qwen3.8 系列、豆包 Seed 2.x 全系、DeepSeek-V4 GA、MiniMax-M2.7/M3 等共 74 个模型条目;价格更新 58 处、能力信息修正 56 处
## v0.7.8 (2026-08-25)
- **修复模型用量上下显示不同步**:上方用量摘要刷新后下方进度条仍停留旧数据(如 24% vs 8%),用量查询状态提升至卡片层统一持有,两处同帧更新
- **实验功能开关同步 kimi-code 上游 7 项旗标**:新增 Tower 模式、子代理 Fork 上下文、WaitFor 工具、自动会话标题;移除已废弃的 ACP v2 开关(手写配置仍完整保留)
- 自动刷新间隔改为每卡片恰好一份定时器,行为不变
## v0.7.7 (2026-08-22)
- **双区域 OAuth 支持(适配 kimi-code 0.38.0)**:适配 mainland-cn `auth.kimi.com` / global `auth.kimi.ai`(#2862),按 provider 的 oauth ref 推导凭据文件与刷新端点,global 账号(`credentials/kimi-code-env-<sha256>.json`)可正常查询用量
- **内置登录区域选择**:应用内 Kimi 登录对话框新增「中国大陆 / 国际版 (kimi.ai)」,登录成功后按官方 CLI 行为 provision `[providers."managed:kimi-code"]`(global 写 `oauthHost`,cn 不写)
- **修复**:手动添加模型后别名自动跟随为 `<provider>/<model-id>`;managed(OAuth)供应商自填 `api_key` 保存时不再被清空
- models.dev 快照刷新至 **7246 模型 / 193 供应商**,新增 DeepSeek V4 Flash Vision Exp、Ox Alpha Free(opencode-go,免费)
## v0.7.6 (2026-08-18)
- **新增 OpenCode Go 套餐用量查询**:卡片用量页脚显示 **5 小时滚动 / 7 天 / 30 天** 三窗口的已用百分比与重置时间(数据源 `GET https://opencode.ai/zen/go/v1/usage`)
- 已配置 OpenCode Go API Key 的老用户无需手动设置,程序按 `base_url` 自动识别并启用;已适配 opencode.ai 的 Cloudflare 拦截(携带浏览器 User-Agent)
- 插件目录被运行中进程占用时的错误提示给出明确指引
## v0.7.5 (2026-08-15)
- **子代理模型池适配 kimi-code 0.36.0 新引擎**:适配 `[secondary_model]` 的 `default_model` + `[secondary_model.models]` 表 + `force`;写入时 `model` 与 `default_model` 双写同值,v1 legacy 与 v2 引擎均可识别
- **高级设置页新增模型池管理**:升级旧配置、增删池条目、编辑路由描述(渲染进主代理 Agent/AgentSwarm 工具描述);支持 force 强制默认模型(带二次确认);保存前 6 类错误前置拦截
- 新增 32 个单元测试覆盖池读写与校验逻辑(vitest)
- **热力图详情改为按需加载**:双击方块调用后端 `get_day_detail` 聚合,不再受 7d / 30d 范围限制
- models.dev 快照刷新至 2026-08-15(6583 模型 / 185 供应商),新增 GLM-5.3 等
## v0.7.4 (2026-08-09)
- **热力图方块双击弹窗**:查看当日模型用量分布,每种模型独立展示 Token 用量 / 请求次数 / 费用 / 缓存命中率(此前仅有 token 数)
- 后端 `[dashboard] by_model` 从纯 token 数升级为结构化对象(含 requests / cost / cacheHitRate),弹窗与柱状图双击均展示全量指标
- 范围外日期(不在当前 7d / 30d / all 内)不可双击;Escape / 遮罩 / 关闭按钮均可关闭弹窗
## v0.7.3 (2026-08-07)
- **Kimi Code WebUI 应用内嵌窗口**:新版 Web 界面以独立顶层窗口打开(1100×750、可缩放、居中),不再跳转外部浏览器
- **单例 + 服务器自动管理**:窗口最多一个,重复点击自动聚焦;复用已运行的 `kimi web` 服务,自启动的服务器在窗口关闭或应用退出时自动清理
- 「在应用内打开」与「在浏览器打开」两个入口并存(后者复用本地服务器,直接打开 `http://127.0.0.1:58627`)
## v0.7.2 (2026-08-07)
- 「子代理设置」升级为「**高级设置**」:聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
- **新增 Kimi Code WebUI 快捷打开**:高级设置页可将新版 Web 界面以独立窗口在应用内打开,也可在系统浏览器打开(需 kimi-code 0.33+,kimi 命令在 PATH 中)
- **子代理模型指定**:为子代理选择次主力模型后默认绑定该模型,不再继承主模型;实验开关改为滑动开关并修复开启态不可见问题
- 兼容 kimi-code 0.33+(v2 引擎):`loop_control` 改用新键名 `max_attempts_per_step`(旧配置自动迁移,保留旧键兼容 legacy 引擎);Release 附带 `install-macos.sh` 一键安装脚本
## v0.7.1 (2026-08-06)
- **新增「子代理模型(次主力模型)」设置**:选择后子代理默认绑定该模型,不再继承主模型,仅保存模型引用即可继承上下文长度与思考模式
- **仪表盘用量统计**:识别子代理请求(`__secondary__`)归为「子代理模型(估算)」独立展示,并缓存次主力模型定价,删除配置后历史记录仍稳定估算
- 自定义网关模型(未收录 models.dev)按官方同名模型跨 provider 匹配价格,不再落入兜底估算
+2 -2
View File
@@ -79,7 +79,7 @@
| **连通性测试** | 实测 `base_url` 延迟,绿/橙/红彩色气泡,6 秒自动消失 |
| **重复供应商** | 一键深拷贝供应商 + 全部模型,key 改 `xxx-copy` |
| **图标按钮操作** | 启用 / 编辑 / 复制 / 测试连通 / 删除 全图标化(lucide-react) |
| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、1M 上下文声明、能力 |
| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、最大输出 Token(`max_output_size`)、能力(仅 Kimi Code) |
| **自动上下文** | 拉取模型时 **API 返回 > models.dev ref > 正则兜底** 三级优先级自动适配 |
| **能力自动推导** | `image_in / video_in / tool_use` 全部由 models.dev 推得;UI 仅暴露 `thinking` 一个手动开关 |
| **全局设置** | `[thinking]` 表完整支持(enabled / effort / keep),仅 Kimi Code 生效 |
@@ -111,7 +111,7 @@
![编辑供应商-模型映射](docs/screenshots/provider-model-mapping.png)
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、1M 上下文声明、能力(仅"思考")、设为默认、删除。
一张表管理全部模型映射:显示名、实际请求模型、上下文长度、最大输出 Token(`max_output_size`,可点参考值填 models.dev 上限)、能力(仅"思考")、设为默认、删除(后两项仅 Kimi Code)。
**用量仪表盘**
+3 -3
View File
@@ -73,7 +73,7 @@ Build instructions: [`docs/BUILD.md`](./docs/BUILD.md).
| **Connectivity test** | Real `base_url` latency, coloured bubble (green / orange / red), auto-dismiss in 6 seconds |
| **Duplicate provider** | Deep-copy a provider + all its models; key auto-suffixed to `xxx-copy` |
| **Iconified actions** | Activate / Edit / Duplicate / Test Connectivity / Delete via lucide-react |
| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, 1M-context flag, and capabilities |
| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, max output tokens (`max_output_size`), and capabilities (Kimi Code only) |
| **Auto context size** | On model fetch: **API response > models.dev ref > regex fallback** — three-tier priority |
| **Auto capabilities** | `image_in / video_in / tool_use` all derived from models.dev; UI only exposes `thinking` as a manual toggle |
| **Global settings** | Full `[thinking]` table (enabled / effort / keep); Kimi Code only |
@@ -105,7 +105,7 @@ Provider name, notes, official URL, managed-provider toggle, API format, API key
![Edit provider — model mapping](docs/screenshots/provider-model-mapping.png)
A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability (thinking only), default toggle, delete.
A single table for all model mappings: display name, real model ID, context length, max output tokens (`max_output_size`, with a click-to-fill models.dev reference), capability (thinking only), default toggle, delete (the last two are Kimi Code only).
**Usage dashboard**
@@ -175,7 +175,7 @@ Per-workspace Kimi Code session browsing, active / archived / all filters; strea
- **`config.toml` is the authoritative source for Kimi Code**: all providers and models are always written in full; `default_model` selects the active one (matching the CLI's native `/provider` behaviour). Switching only changes `default_model`; newly added providers are auto-promoted to the top of the list and never get overwritten
- **SQLite holds Kimi Switch-private metadata only**: notes, official URLs, per-agent remembered default model (the `settings` table), ordering. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not under `~/.kimi-switch`. It acts as a fallback when `config.toml` is incomplete
- **`raw_other` passes unknown fields through untouched**, including `[oauth]` blocks — round-trips never drop fields
- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name`
- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name / max output tokens`
- **Override env vars**: `KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` override the Kimi Code / Pi dirs; Kimi Switch's own data dir is fixed at `~/.kimi-switch` (no env override yet). See [Data Storage Locations](#data-storage-locations)
## Feature Details
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,23 @@
# KimiSwitch v0.7.21
## 新增
- **models.dev 在线同步**:高级设置新增「模型参考数据(models.dev)」卡片——一键在线拉取 models.dev 最新快照,新模型发布后无需等待 KimiSwitch 发版:
- 覆盖三类参考数据:模型上下文长度、能力标记(思考 / 工具 / 图像 / 视频)、单价($/M tokens)
- 同步后立即生效:供应商编辑页的参数自动填充、仪表盘用量计价(Rust 侧价格索引按快照文件 mtime 自动重建,无需重启应用)
- 数据落在 `~/.kimi-switch/models-dev.json`,打包内置快照始终作为兜底;无效副本自动回退
- 支持一键「恢复内置数据」,随时可重新同步
- 状态行显示当前数据来源(在线同步 / 随版本内置)、快照日期与模型 / 供应商数量
- 代理环境自动适配:依次读取 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量与 git `http.proxy` 配置
## 修复
- **构建链路数据漂移**:`fetch-models-dev.mjs` 此前只写 `src/lib/models-dev.json`(Rust 计价内嵌),前端实际加载的 `public/models-dev.json` 需手工拷贝,历史上已两次滞后。现在两个文件由脚本同步写入,彻底消除漂移
## 适配
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量 / quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(`~/.kimi-code/tui.toml`)不涉及配置管理面,暂无适配需求
## 数据
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
@@ -0,0 +1,9 @@
# KimiSwitch v0.7.22
## 修复
- **Sub2API / NewAPI 模板查询在供应商列表不显示**:为没有预设识别类型的自定义供应商(如自家中转站)配置「Sub2API 中转站」或「NewAPI 中转站」模板后,配置弹窗里「测试查询」正常返回余额,但回到供应商列表却始终空白。根因:列表的查询与显示门槛只认预设注入的 `usageKinds`,而后端模板查询本就绕过 `usageKinds` 直接执行——前端口径与后端不一致。现在二者对齐,显式模板的供应商在列表正常显示余额 / 配额窗口、支持自动查询间隔与预警阈值。
## 备注
- 仅前端修复(`useUsageQuery` 支持判定 + 卡片渲染门槛),后端查询逻辑无变化;已补 3 个单测覆盖支持判定。
@@ -0,0 +1,18 @@
# KimiSwitch v0.7.23
## 新功能
- **思考等级五档 + 能力感知**:全局配置的思考等级由「低 / 中 / 高」三档扩展为「低 / 中 / 高 / Max / XHigh」五档,并按当前默认模型的实际能力自适应——
- 模型在 config.toml 声明了 `support_efforts` 时,不支持的档位自动置灰并给出原因提示(如 deepseek-v4.1-flash 仅支持「高」)
- models.dev 标记不支持思考(reasoning=false)的模型,整个思考等级区禁用并给出警告
- 未声明档位的模型保留全部可选,Max / XHigh 附带「上游可能拒绝此档位」提示
- 等级行下方新增说明:实际生效档位取决于当前默认模型
## 修复
- **max 档位被误显示为「高」**:此前代码基于过时信息("上游已移除 max 档位")把存储的 `effort = "max"` 读取时强制改为 `high`;实测 kimi-code 2.1.1 中 max 为合法档位(如 kimi-for-coding 默认即为 max),现已原样保留与显示。手写的任意档位值(上游本就是自由字符串)也会作为额外可选项正常显示,不再被丢弃
- **子代理设置页**:继承档位显示同步修正,max / xhigh 正常显示,未知值按原样展示
## 备注
- 仅前端改动(新增 `thinking-efforts` 能力判定模块 + `Segmented` 控件逐选项禁用/tooltip),无 Rust 变更;新增 14 个单测覆盖三层能力判定规则,全量 117 个测试通过
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,14 @@
# KimiSwitch v0.8.0
## 新功能
- **模型映射表新增「最大输出 Token」列**(Kimi Code):为每个模型单独设置 `max_output_size`,写入 config.toml 的 `[models."<alias>"]`,留空则用上游默认值。models.dev 收录了该模型的输出上限时,输入框下方显示可点击的参考值(如 `参考 128000`),一键填入;「获取模型列表」添加模型时也会自动预填参考值
- **models.dev 快照新增 output 上限字段**:内置快照同步至 2026-10-03(8,385 模型 / 226 供应商,97.4% 模型带 output 上限);应用内「同步 models.dev」(Rust 侧)同步支持该字段,同步后参考值不丢失
## 修复
- **移除无效的「声明支持 1M」复选框**(Kimi Code):该控件在读取时由上下文长度派生、写入时不持久化,勾选永远不会生效,已由「最大输出 Token」列取代
## 备注
- 前端 + 抓取脚本 + Rust 快照构建逻辑;新增 `max_output_size` 读写纯函数 9 个单测 + config.toml 全链路往返测试,全量测试通过(前端 126 / Rust 162)
@@ -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 测试全量通过
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.20",
"version": "0.8.1",
"type": "module",
"scripts": {
"dev": "vite",
+100673 -82467
View File
File diff suppressed because it is too large. Load diff
+21 -3
View File
@@ -1,9 +1,17 @@
#!/usr/bin/env node
/**
* Fetch the latest model reference data from models.dev and write:
* 1. src/lib/models-dev.json — compact per-model snapshot (frontend)
* 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
* 3. src/lib/models-dev.last-good.json — local backup of the last success
* 1. src/lib/models-dev.json — compact per-model snapshot; embedded
* into the Rust binary by dashboard.rs
* (include_str!) and loaded by the
* frontend as fallback
* 2. public/models-dev.json — the same snapshot as a static asset;
* this is what the frontend actually
* serves at runtime (must stay in sync
* with #1 — they serve different halves
* of the app)
* 3. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
* 4. src/lib/models-dev.last-good.json — local backup of the last success
*
* The data source is https://models.dev/api.json (176 providers; each model
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
@@ -63,6 +71,8 @@ if (proxy && process.env.NODE_USE_ENV_PROXY !== "1") {
const SOURCE_URL = "https://models.dev/api.json";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
// Static-asset copy served to the frontend (see header — must equal SNAPSHOT).
const SNAPSHOT_PUBLIC = join(ROOT, "public", "models-dev.json");
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
// Local backup of the last successful snapshot. Not committed to git (see
// .gitignore); the committed models-dev.json itself is the versioned fallback.
@@ -123,6 +133,11 @@ async function main() {
if (typeof m.limit?.context === "number" && m.limit.context > 0) {
entry.context = m.limit.context;
}
// Max output tokens — the reference value behind the max_output_size
// column (model mapping table).
if (typeof m.limit?.output === "number" && m.limit.output > 0) {
entry.output = m.limit.output;
}
if (m.reasoning === true) entry.reasoning = true;
if (m.tool_call === true) entry.tool_call = true;
if (m.structured_output === true) entry.structured_output = true;
@@ -168,12 +183,15 @@ async function main() {
}
await mkdir(dirname(SNAPSHOT), { recursive: true });
await mkdir(dirname(SNAPSHOT_PUBLIC), { recursive: true });
const json = JSON.stringify(snapshot, null, 2) + "\n";
await writeFile(SNAPSHOT, json, "utf8");
await writeFile(SNAPSHOT_PUBLIC, json, "utf8");
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
await writeFile(BACKUP, json, "utf8");
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
console.log(`models.dev static asset: -> ${SNAPSHOT_PUBLIC}`);
console.log(
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
);
+5 -2
View File
@@ -3,7 +3,8 @@
# (git.codingplan.site/admin/KimiCodeSwitch).
#
# Usage: publish-gitea.sh <tag> <asset> [asset...]
# tag : e.g. v0.7.15; notes read from release-notes-<tag>.md
# tag : e.g. v0.7.15; notes read from docs/release-notes/<tag>.md
# (legacy root release-notes-<tag>.md as fallback)
# assets : files to attach (MSI, install-macos.sh, ...)
#
# Auth: reuses the stored git credential for git.codingplan.site
@@ -15,7 +16,9 @@ REPO="admin/KimiCodeSwitch"
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift
NOTES_FILE="release-notes-${TAG}.md"
NOTES_FILE="docs/release-notes/${TAG}.md"
[ -f "$NOTES_FILE" ] || NOTES_FILE="docs/release-notes/release-notes-${TAG}.md"
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
json_escape() {
python - "$1" <<'PYEOF'
import json, sys
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.20"
version = "0.8.1"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.20"
version = "0.8.1"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+1033 -135
View File
File diff suppressed because it is too large. Load diff
+108
View File
@@ -648,6 +648,114 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_writes_and_drops_max_output_size() {
// max_output_size is not a first-class Model field: it round-trips
// through raw_other and must land in config.toml verbatim, and must be
// removable (unset = upstream default) without disturbing its siblings.
let mut providers = IndexMap::new();
providers.insert(
"p".to_string(),
Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".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: Value::Null,
usage_kinds: None,
usage_config: None,
},
);
let make_model = |raw_other: Value| Model {
alias: "p/m".to_string(),
provider: "p".to_string(),
model: "m".to_string(),
max_context_size: 128_000,
display_name: None,
supports_1m: false,
capabilities: vec![],
raw_other,
};
let mut models = IndexMap::new();
models.insert(
"p/m".to_string(),
make_model(serde_json::json!({
"max_output_size": 32768,
"support_efforts": ["low", "high"],
})),
);
let config = Config {
default_model: None,
providers,
models,
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let model = root
.get("models")
.unwrap()
.as_table()
.unwrap()
.get("p/m")
.unwrap()
.as_table()
.unwrap();
assert_eq!(model.get("max_output_size").and_then(|v| v.as_integer()), Some(32768));
assert!(model.get("support_efforts").is_some());
// Re-import through real TOML text (the same serialize → parse path
// save_kimi_code_config / load_kimi_code_config use): the key must
// come back in the model's raw_other as an integer.
let toml_text = toml::to_string_pretty(&exported).unwrap();
assert!(toml_text.contains("max_output_size = 32768"), "toml: {toml_text}");
let reparsed: TomlValue = toml_text.parse().unwrap();
let parsed = kimi_code_to_config(&reparsed);
let loaded = parsed.models.get("p/m").unwrap();
assert_eq!(
loaded.raw_other.get("max_output_size").and_then(|v| v.as_i64()),
Some(32768)
);
// Clearing the override drops the key entirely.
let mut models = IndexMap::new();
models.insert(
"p/m".to_string(),
make_model(serde_json::json!({ "support_efforts": ["low", "high"] })),
);
let config = Config {
default_model: None,
providers: IndexMap::new(),
models,
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let model = root
.get("models")
.unwrap()
.as_table()
.unwrap()
.get("p/m")
.unwrap()
.as_table()
.unwrap();
assert!(model.get("max_output_size").is_none());
}
#[test]
fn kimi_code_export_adds_opencode_go_session_header() {
let make_provider = |base_url: &str, raw_other: Value| Provider {
+5
View File
@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod db;
pub mod kimi_code_io;
pub mod models;
pub mod models_dev;
pub mod oauth;
pub mod pi_io;
pub mod plugins;
@@ -119,6 +120,10 @@ pub fn run() {
commands::kimi_oauth_start,
commands::kimi_oauth_poll,
commands::get_experimental_env_status,
models_dev::sync_models_dev,
models_dev::get_models_dev_status,
models_dev::get_models_dev_snapshot,
models_dev::reset_models_dev,
dashboard::get_paths,
dashboard::get_prices,
dashboard::get_summary,
+362
View File
@@ -0,0 +1,362 @@
//! models.dev reference-data sync.
//!
//! The app carries a compiled-in models.dev snapshot (generated by
//! scripts/fetch-models-dev.mjs before every build). This module lets the
//! user refresh it at runtime from https://models.dev/api.json without
//! rebuilding: the synced copy lives at `~/.kimi-switch/models-dev.json` and
//! takes precedence whenever it exists and parses. The dashboard price index
//! (see dashboard::models_dev_cost_index) re-checks that file's mtime so new
//! prices take effect without an app restart.
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use chrono::Utc;
use serde::Serialize;
use serde_json::Value;
use crate::db::kimi_switch_data_dir;
/// Compiled-in snapshot — the same file the dashboard price index used to
/// embed directly. Always available; the runtime-synced copy (if any) wins.
const BUILTIN_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
const SOURCE_URL: &str = "https://models.dev/api.json";
const SYNCED_FILE: &str = "models-dev.json";
/// Where the runtime-synced snapshot lives. The dashboard stats this path's
/// mtime to decide when to rebuild its price index.
pub fn synced_path() -> PathBuf {
kimi_switch_data_dir().join(SYNCED_FILE)
}
/// Read + validate the synced copy. Anything unparsable or missing
/// `last_updated` is treated as absent so the built-in snapshot takes over.
fn read_synced() -> Option<String> {
let raw = fs::read_to_string(synced_path()).ok()?;
let v: Value = serde_json::from_str(&raw).ok()?;
if v.get("last_updated").and_then(|x| x.as_str()).is_some() {
Some(raw)
} else {
None
}
}
/// The snapshot the app should use right now: the synced copy when present
/// and valid, otherwise the compiled-in one.
pub fn effective_snapshot() -> String {
read_synced().unwrap_or_else(|| BUILTIN_SNAPSHOT.to_string())
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDevStatus {
/// "synced" = runtime online copy; "builtin" = compiled at build time.
pub source: &'static str,
pub last_updated: String,
pub model_count: u64,
pub provider_count: u64,
}
/// Count models / providers and read the `last_updated` stamp from a
/// snapshot string ("<provider>/<model>" keys, plus the `last_updated` meta).
fn status_of(source: &'static str, raw: &str) -> ModelsDevStatus {
let v: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
let mut model_count = 0u64;
let mut providers = HashSet::new();
if let Some(obj) = v.as_object() {
for (k, entry) in obj {
if k == "last_updated" || !entry.is_object() {
continue;
}
model_count += 1;
if let Some((p, _)) = k.split_once('/') {
providers.insert(p.to_string());
}
}
}
ModelsDevStatus {
source,
last_updated: v
.get("last_updated")
.and_then(|x| x.as_str())
.unwrap_or("unknown")
.to_string(),
model_count,
provider_count: providers.len() as u64,
}
}
fn status(source: &'static str) -> ModelsDevStatus {
match source {
"synced" => {
let raw = read_synced().unwrap_or_default();
status_of("synced", &raw)
}
_ => status_of("builtin", BUILTIN_SNAPSHOT),
}
}
/// Numbers serialize as integers when they are whole (matches the mjs
/// script's JSON output, e.g. context 1000000 not 1000000.0).
fn num(v: f64) -> Value {
if v.fract() == 0.0 && v.abs() < 9e15 {
Value::from(v as i64)
} else {
Value::from(v)
}
}
/// Transform models.dev api.json into the compact snapshot shape produced by
/// scripts/fetch-models-dev.mjs (the canonical field-picking logic lives
/// there; this is the runtime port). Pure — unit-tested below.
fn build_snapshot(raw: &Value) -> Result<Value, String> {
let providers = raw.as_object().ok_or("api.json is not a JSON object")?;
let mut snapshot = serde_json::Map::new();
snapshot.insert(
"last_updated".into(),
Value::String(Utc::now().format("%Y-%m-%d").to_string()),
);
for (provider_id, provider) in providers {
let Some(models) = provider.get("models").and_then(|m| m.as_object()) else {
continue;
};
for (model_id, m) in models {
let mut entry = serde_json::Map::new();
if let Some(name) = m.get("name").and_then(|x| x.as_str()) {
entry.insert("name".into(), Value::String(name.to_string()));
}
// Context 0 means "not applicable" (image/audio models) — treat as
// missing so callers fall back to defaults.
if let Some(ctx) = m
.pointer("/limit/context")
.and_then(|x| x.as_f64())
.filter(|c| *c > 0.0)
{
entry.insert("context".into(), num(ctx));
}
// Max output tokens — reference for the max_output_size column.
if let Some(out) = m
.pointer("/limit/output")
.and_then(|x| x.as_f64())
.filter(|o| *o > 0.0)
{
entry.insert("output".into(), num(out));
}
for flag in ["reasoning", "tool_call", "structured_output"] {
if m.get(flag).and_then(|x| x.as_bool()) == Some(true) {
entry.insert(flag.into(), Value::Bool(true));
}
}
if let Some(input) = m.pointer("/modalities/input").and_then(|x| x.as_array()) {
let has = |s: &str| input.iter().any(|v| v.as_str() == Some(s));
if has("image") {
entry.insert("image".into(), Value::Bool(true));
}
if has("video") {
entry.insert("video".into(), Value::Bool(true));
}
}
if let Some(cost) = m.get("cost").and_then(|c| c.as_object()) {
let mut c = serde_json::Map::new();
for k in ["input", "output", "cache_read", "cache_write"] {
if let Some(n) = cost.get(k).and_then(|x| x.as_f64()) {
c.insert(k.into(), num(n));
}
}
if !c.is_empty() {
entry.insert("cost".into(), Value::Object(c));
}
}
snapshot.insert(format!("{provider_id}/{model_id}"), Value::Object(entry));
}
}
Ok(Value::Object(snapshot))
}
/// reqwest is built without the default system-proxy feature, so proxies must
/// be attached explicitly. Mirror fetch-models-dev.mjs: env vars first, then
/// git's http.proxy.
fn proxy_url() -> Option<String> {
let env = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
.iter()
.find_map(|k| std::env::var(k).ok().filter(|p| !p.trim().is_empty()));
env.or_else(git_http_proxy)
}
fn git_http_proxy() -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", "http.proxy"])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
fn http_client() -> Result<reqwest::Client, String> {
let mut b = reqwest::Client::builder()
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(60));
if let Some(p) = proxy_url() {
b = b.proxy(reqwest::Proxy::all(&p).map_err(|e| format!("invalid proxy '{p}': {e}"))?);
}
b.build().map_err(|e| format!("failed to build HTTP client: {e}"))
}
/// Download api.json, transform it and atomically replace the synced copy.
/// On any failure the previous snapshot (synced or built-in) stays untouched.
async fn sync_from_remote() -> Result<ModelsDevStatus, String> {
let client = http_client()?;
let raw: Value = client
.get(SOURCE_URL)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("request failed: {e}"))?
.error_for_status()
.map_err(|e| format!("models.dev returned {e}"))?
.json()
.await
.map_err(|e| format!("failed to parse api.json: {e}"))?;
let snapshot = build_snapshot(&raw)?;
let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())? + "\n";
let path = synced_path();
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &json).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
// Windows rename replaces an existing destination (MOVEFILE_REPLACE_EXISTING).
fs::rename(&tmp, &path).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("failed to replace {}: {e}", path.display())
})?;
Ok(status_of("synced", &json))
}
/// Refresh the models.dev snapshot from the network.
#[tauri::command]
pub async fn sync_models_dev() -> Result<ModelsDevStatus, String> {
sync_from_remote().await
}
/// Where the current reference data comes from (for the settings card).
#[tauri::command]
pub fn get_models_dev_status() -> Result<ModelsDevStatus, String> {
Ok(if read_synced().is_some() {
status("synced")
} else {
status("builtin")
})
}
/// The synced snapshot's raw JSON, or None when the frontend should keep
/// using the bundled static asset.
#[tauri::command]
pub fn get_models_dev_snapshot() -> Result<Option<String>, String> {
Ok(read_synced())
}
/// Drop the synced copy and fall back to the compiled-in snapshot.
#[tauri::command]
pub fn reset_models_dev() -> Result<ModelsDevStatus, String> {
match fs::remove_file(synced_path()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("failed to remove synced snapshot: {e}")),
}
Ok(status("builtin"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_snapshot_picks_reference_fields() {
let raw = json!({
"moonshotai": {
"models": {
"kimi-k3": {
"name": "Kimi K3",
"limit": { "context": 262144, "output": 16384 },
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"cost": { "input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 0.0 }
},
"kimi-image": {
"name": "Kimi Image",
"limit": { "context": 0, "output": 0 },
"modalities": { "input": ["text", "image", "video"] }
}
}
},
"zhipuai": {
"models": {
"glm-5.3": {
"name": "GLM-5.3",
"reasoning": false,
"cost": { "input": 0.6, "output": 2.2 }
}
}
}
});
let snap = build_snapshot(&raw).unwrap();
let obj = snap.as_object().unwrap();
assert!(obj.contains_key("last_updated"));
let k3 = &obj["moonshotai/kimi-k3"];
assert_eq!(k3["name"], "Kimi K3");
assert_eq!(k3["context"], 262144);
assert_eq!(k3["output"], 16384);
assert_eq!(k3["reasoning"], true);
assert_eq!(k3["tool_call"], true);
assert_eq!(k3["structured_output"], true);
assert_eq!(k3["image"], true);
assert!(k3.get("video").is_none());
assert_eq!(k3["cost"]["input"], 3.0);
assert_eq!(k3["cost"]["cache_read"], 0.3);
assert_eq!(k3["cost"]["cache_write"], 0.0);
// Context 0 / output 0 → dropped; video modality → flagged.
let img = &obj["moonshotai/kimi-image"];
assert!(img.get("context").is_none());
assert!(img.get("output").is_none());
assert_eq!(img["video"], true);
assert!(img.get("cost").is_none());
// Explicit false is omitted (same as the mjs script).
let glm = &obj["zhipuai/glm-5.3"];
assert!(glm.get("reasoning").is_none());
assert_eq!(glm["cost"]["input"], 0.6);
}
#[test]
fn status_counts_models_and_providers() {
let raw = r#"{
"last_updated": "2026-09-21",
"moonshotai/kimi-k3": { "cost": { "input": 3.0 } },
"moonshotai/kimi-k2": { "name": "K2" },
"zhipuai/glm-5.3": {}
}"#;
let st = status_of("builtin", raw);
assert_eq!(st.last_updated, "2026-09-21");
assert_eq!(st.model_count, 3);
assert_eq!(st.provider_count, 2);
}
#[test]
fn num_serializes_whole_numbers_as_integers() {
assert_eq!(num(262144.0), json!(262144));
assert_eq!(num(0.3), json!(0.3));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.20",
"version": "0.8.1",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+104 -20
View File
@@ -1,12 +1,20 @@
import { useEffect, useState } from "react";
import { useEffect, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import { getModelRef, modelsDevReady } from "../lib/models-dev";
import {
THINKING_EFFORTS,
readSupportEfforts,
resolveThinkingEffortSupport,
type EffortAvailability,
} from "../lib/thinking-efforts";
import type {
AgentSettings,
ExperimentalEnvStatus,
Hook,
Model,
PermissionRule,
} from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
@@ -14,14 +22,18 @@ import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps {
rawOther: unknown;
onChange: (nextRawOther: unknown) => void;
/** Configured models (alias → entry) — used to resolve effort support. */
models?: Record<string, Model>;
/** Alias of the model the effort tier actually applies to. */
defaultModel?: string | null;
}
// Upstream removed the "max" effort tier (auto-migrates to "high").
const THINKING_LEVELS = ["low", "medium", "high"] as const;
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
const THINKING_LABELS: Record<(typeof THINKING_EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
@@ -44,7 +56,7 @@ const COMMON_EVENTS = [
"SessionEnd",
] as const;
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
export function AgentSettingsPanel({ rawOther, onChange, models, defaultModel }: AgentSettingsPanelProps) {
const { t } = useTranslation();
const settings = getAgentSettings(rawOther);
/**
@@ -52,6 +64,9 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
* v1-engine users; the default (v2) writes only max_attempts_per_step.
*/
const [legacyV1, setLegacyV1] = useState(false);
// The models.dev index loads in the background; re-render once it lands so
// the reasoning flag can disable the thinking area.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -62,6 +77,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
.catch(() => setLegacyV1(false));
}, []);
useEffect(() => {
// The panel only needs the flag once; App owns the permanent
// onModelsDevReady listener for later hot swaps.
let alive = true;
modelsDevReady().then(() => {
if (alive) forceModelsDevReady();
});
return () => {
alive = false;
};
}, []);
const update = (patch: Partial<AgentSettings>) => {
onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
};
@@ -92,6 +119,42 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
const thinkingEnabled = settings.thinking?.enabled ?? true;
// Which tiers the current default model accepts: `[models."<alias>"]
// support_efforts` first, then the models.dev `reasoning` flag.
const defaultEntry = defaultModel ? models?.[defaultModel] : undefined;
const effortSupport = resolveThinkingEffortSupport({
supportEfforts: readSupportEfforts(defaultEntry),
reasoning: defaultEntry ? getModelRef(defaultEntry.model)?.reasoning : undefined,
});
const effortNote = (availability: EffortAvailability): string | undefined => {
const note = availability.note;
if (!note) return undefined;
if (note.kind === "thinkingUnsupported") return t("thinkingEffortUnsupported");
if (note.kind === "tierUnsupported") {
return t("thinkingEffortTierUnsupported", { levels: note.supported.join(", ") });
}
return t("thinkingEffortTierUndeclared");
};
// Upstream takes a free-form string: keep a hand-written tier visible and
// selectable instead of dropping it from the control.
const effort = settings.thinking?.effort ?? "medium";
const effortOptions: {
key: string;
label: string;
disabled?: boolean;
title?: string;
}[] = THINKING_EFFORTS.map((tier) => ({
key: tier,
label: t(THINKING_LABELS[tier]),
disabled: !effortSupport.levels[tier].enabled,
title: effortNote(effortSupport.levels[tier]),
}));
if (!THINKING_EFFORTS.includes(effort as (typeof THINKING_EFFORTS)[number])) {
effortOptions.push({ key: effort, label: effort });
}
return (
<div className="mt-6 space-y-4">
<h3 className="text-content-muted text-sm font-medium">{t("agentSettings")}</h3>
@@ -105,17 +168,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-content-muted">{t("thinkingLevel")}</span>
<Segmented
options={THINKING_LEVELS.map((lvl) => ({
key: lvl,
label: t(THINKING_LABELS[lvl]),
}))}
value={settings.thinking?.effort ?? "medium"}
onChange={(effort) =>
updateThinking({ effort: effort as NonNullable<AgentSettings["thinking"]>["effort"] })
}
disabled={!thinkingEnabled}
options={effortOptions}
value={effort}
onChange={(next) => updateThinking({ effort: next })}
disabled={!thinkingEnabled || !effortSupport.thinkingSupported}
/>
</div>
{!effortSupport.thinkingSupported && (
<p className="text-xs text-amber-500 dark:text-amber-400">
{t("thinkingEffortUnsupported")}
</p>
)}
<p className="text-xs text-content-muted">
{t("thinkingEffortDependsOnDefaultModel")}
</p>
<Checkbox
label={t("thinkingKeep")}
checked={settings.thinking?.keep === "all"}
@@ -170,19 +236,37 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</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")
+122 -17
View File
@@ -1,11 +1,17 @@
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { findPresetForProvider } from "../config/providerPresets";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import {
parseMaxOutputInput,
readMaxOutputSize,
setMaxOutputSize,
withMaxOutputSize,
} from "../lib/model-max-output";
import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
@@ -33,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",
@@ -142,6 +151,13 @@ export function ProviderEdit({
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
// Alias → entry lookup for panels that resolve a model by alias
// (AgentSettingsPanel reads the default model's support_efforts).
const modelsByAlias = useMemo(
() => Object.fromEntries(models.map((m) => [m.alias, m])),
[models]
);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
@@ -462,6 +478,8 @@ export function ProviderEdit({
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
models={modelsByAlias}
defaultModel={defaultModel}
/>
)}
</>
@@ -558,6 +576,38 @@ function ModelMapping({
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [fetchThinking, setFetchThinking] = useState(true);
// The models.dev index loads in the background; re-render once it lands so
// 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, 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;
useEffect(() => {
let alive = true;
modelsDevReady().then(() => {
if (!alive) return;
forceModelsDevReady();
if (backfillDone.current || agent !== "kimi_code") return;
backfillDone.current = true;
for (const m of modelsRef.current) {
if (readMaxOutputSize(m.raw_other) !== undefined) continue;
onModelChange(
withMaxOutputSize(
m,
getModelRef(m.model)?.output ?? FALLBACK_MAX_OUTPUT
)
);
}
});
return () => {
alive = false;
};
}, []);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
@@ -627,6 +677,17 @@ function ModelMapping({
: fetchThinking
? ["thinking"]
: [],
// 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
),
}
: {}),
});
}
onBulkAdd(toAdd);
@@ -639,7 +700,10 @@ function ModelMapping({
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-content-primary">{t("modelMapping")}</h3>
<p className="text-xs text-content-muted mt-1">{t("modelMappingDesc")}</p>
<p className="text-xs text-content-muted mt-1">
{t("modelMappingDesc")}
{agent === "kimi_code" ? ` ${t("maxOutputSizeDesc")}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -747,7 +811,9 @@ function ModelMapping({
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium w-32">{t("maxOutputSize")}</th>
)}
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
)}
@@ -788,19 +854,14 @@ function ModelMapping({
}}
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={m.supports_1m || false}
onChange={(e) =>
onModelChange({
...m,
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
</td>
{agent === "kimi_code" && (
<td className="px-4 py-2">
<MaxOutputCell
model={m}
onChange={(next) => onModelChange(next)}
/>
</td>
)}
{agent === "kimi_code" && (
<td className="px-4 py-2">
<CapabilitiesCell
@@ -976,3 +1037,47 @@ function CapabilitiesCell({
</div>
);
}
/**
* Max output tokens override (`[models."<alias>"] max_output_size`).
*
* Not a first-class Model field, so the value lives in `raw_other` (see
* lib/model-max-output.ts). Blank input removes the key — "unset" means the
* upstream default applies. When models.dev knows the model's cap it is shown
* as a click-to-fill reference underneath.
*/
function MaxOutputCell({
model,
onChange,
}: {
model: Model;
onChange: (next: Model) => void;
}) {
const { t } = useTranslation();
const current = readMaxOutputSize(model.raw_other);
const refOutput = getModelRef(model.model)?.output;
return (
<div>
<input
type="number"
min={0}
step={1024}
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={current ?? ""}
onChange={(e) =>
onChange(withMaxOutputSize(model, parseMaxOutputInput(e.target.value)))
}
/>
{refOutput !== undefined && refOutput !== current && (
<button
type="button"
onClick={() => onChange(withMaxOutputSize(model, refOutput))}
className="mt-1 text-[11px] text-content-muted hover:text-blue-500"
>
{t("maxOutputRef", { value: refOutput })}
</button>
)}
</div>
);
}
+4 -2
View File
@@ -96,7 +96,9 @@ function ProviderCard({
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
provider.usageConfig?.threshold,
provider.usageConfig?.templateType === "newapi" ||
provider.usageConfig?.templateType === "sub2api"
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
@@ -221,7 +223,7 @@ function ProviderCard({
)}
{/* compact usage summary — sits left of the switch button,
mirroring cc-switch's card layout (usage → action buttons) */}
{(provider.usageKinds?.length ?? 0) > 0 && (
{usage.supported && (
<UsageFooter usage={usage} variant="compact" />
)}
<button
+128 -5
View File
@@ -24,6 +24,15 @@ import {
type ValidationErrorKey,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev";
/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */
interface ModelsDevStatus {
source: "synced" | "builtin";
lastUpdated: string;
modelCount: number;
providerCount: number;
}
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
@@ -34,12 +43,15 @@ interface SubagentSettingsPageProps {
onBack: () => void;
}
// Upstream removed the "max" effort tier (auto-migrates to "high").
const EFFORTS = ["low", "medium", "high"] as const;
// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
// per-model support comes from `[models."<alias>"] support_efforts`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
@@ -155,13 +167,65 @@ export function SubagentSettingsPage({
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
/** models.dev snapshot provenance (synced copy vs bundled asset). */
const [modelsDevStatus, setModelsDevStatus] = useState<ModelsDevStatus | null>(null);
/** Sync/restore button in flight; blocks the card's buttons. */
const [modelsDevBusy, setModelsDevBusy] = useState(false);
/** Last sync error (inline) and success flag (auto-cleared on next action). */
const [syncError, setSyncError] = useState<string | null>(null);
const [syncOk, setSyncOk] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => setModelsDevStatus(null));
}, []);
const refreshModelsDevStatus = () => {
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => {});
};
const handleModelsDevSync = async () => {
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("sync_models_dev");
setModelsDevStatus(st);
// Hot-swap the frontend index; the dashboard price index rebuilds on
// its next query (mtime-triggered on the Rust side).
const raw = await invoke<string | null>("get_models_dev_snapshot");
if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record<string, unknown>);
setSyncOk(true);
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
} finally {
setModelsDevBusy(false);
}
};
const handleModelsDevRestore = async () => {
if (!confirm(t("modelsDataRestoreConfirm"))) return;
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("reset_models_dev");
setModelsDevStatus(st);
await reloadModelsDev();
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
refreshModelsDevStatus();
} finally {
setModelsDevBusy(false);
}
};
const flags = getExperimentalFlags(rawOther);
const pool = getSubagentModelPool(rawOther);
const poolKeys = pool ? Object.keys(pool.models) : [];
@@ -220,9 +284,8 @@ export function SubagentSettingsPage({
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
// Stored "max" tiers from old configs are shown as "high" (upstream
// removed the tier; it auto-migrates to "high").
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
// Unknown values (hand-written config) are shown verbatim.
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
@@ -398,6 +461,66 @@ export function SubagentSettingsPage({
</div>
</Card>
{/* models.dev reference data: online sync / restore bundled */}
<Card title={t("modelsDataSection")}>
<p className="text-xs text-content-muted">{t("modelsDataDesc")}</p>
{modelsDevStatus && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`shrink-0 rounded border px-1.5 py-0.5 text-xs ${
modelsDevStatus.source === "synced"
? "border-blue-500/30 text-blue-600 dark:text-blue-400"
: "border-border text-content-muted"
}`}
>
{modelsDevStatus.source === "synced"
? t("modelsDataSourceSynced")
: t("modelsDataSourceBuiltin")}
</span>
<span className="text-xs text-content-muted">
{t("modelsDataStats", {
date: modelsDevStatus.lastUpdated,
models: modelsDevStatus.modelCount.toLocaleString(),
providers: modelsDevStatus.providerCount.toLocaleString(),
})}
</span>
</div>
)}
{syncOk && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-3 py-2 text-xs text-green-700 dark:text-green-400">
{t("modelsDataSyncOk")}
</div>
)}
{syncError && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-xs text-red-700 dark:text-red-400"
>
{t("modelsDataSyncFailed")}: {syncError}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevSync}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{modelsDevBusy ? t("modelsDataSyncing") : t("modelsDataSyncNow")}
</button>
{modelsDevStatus?.source === "synced" && (
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevRestore}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("modelsDataRestore")}
</button>
)}
</div>
</Card>
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
{isPoolView && pool ? (
+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>
+22 -16
View File
@@ -106,7 +106,7 @@ export function Segmented({
onChange,
disabled,
}: {
options: { key: string; label: string }[];
options: { key: string; label: string; disabled?: boolean; title?: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
@@ -117,21 +117,27 @@ export function Segmented({
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
{options.map((opt) => {
const optDisabled = disabled || opt.disabled === true;
return (
// The title lives on the wrapper: a disabled button swallows pointer
// events in Chromium, so its own tooltip would never show.
<span key={opt.key} title={opt.title} className="inline-flex">
<button
type="button"
disabled={optDisabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
} ${optDisabled ? "cursor-not-allowed opacity-40 hover:bg-input" : ""}`}
>
{opt.label}
</button>
</span>
);
})}
</div>
);
}
+16 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { SummaryResult } from "../types/dashboard";
@@ -23,9 +23,12 @@ export function useDashboard() {
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);
const refresh = useCallback(async (force = false) => {
const gen = ++genRef.current;
setLoading(true);
setError(null);
const start = performance.now();
@@ -34,16 +37,15 @@ export function useDashboard() {
range,
refresh: force,
});
if (genRef.current !== gen) return;
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]);
@@ -51,6 +53,13 @@ export function useDashboard() {
refresh();
}, [refresh]);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
const changeRange = useCallback((next: DashboardRange) => {
setRange(next);
try {
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
import { isAlertTier, usageQuerySupported, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
@@ -42,3 +42,22 @@ describe("isAlertTier", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
describe("usageQuerySupported", () => {
it("有识别类型即可查询", () => {
expect(usageQuerySupported(["balance:deepseek"])).toBe(true);
expect(usageQuerySupported([], true)).toBe(true);
});
it("显式模板(newapi/sub2api)绕过 usage_kinds——自定义供应商也该查", () => {
expect(usageQuerySupported(undefined, true)).toBe(true);
});
it("无识别类型且非显式模板时不查询", () => {
expect(usageQuerySupported(undefined)).toBe(false);
expect(usageQuerySupported([])).toBe(false);
// auto 模板没有识别到类型 = 不支持(与弹窗短路口径一致)
expect(usageQuerySupported([], false)).toBe(false);
expect(usageQuerySupported(undefined, undefined)).toBe(false);
});
});
+17 -2
View File
@@ -75,15 +75,30 @@ export function isAlertTier(
return d.unit === "%" && d.used != null && d.used >= threshold;
}
/**
* 是否值得为该供应商发起用量查询:有预设识别类型,或后端按显式模板
* (newapi / sub2api,二者绕过 usage_kinds)查询。
*/
export function usageQuerySupported(
usageKinds?: string[],
explicitTemplate?: boolean
): boolean {
return (usageKinds?.length ?? 0) > 0 || explicitTemplate === true;
}
export function useUsageQuery(
agent: Agent,
providerName: string,
usageKinds?: string[],
autoIntervalMinutes?: number,
disabled?: boolean,
threshold?: number
threshold?: number,
/** Backend resolves the query from an explicit template (newapi / sub2api)
* regardless of usageKinds — those bypass kinds entirely, so the row is
* supported even when the provider has no preset-detected kinds. */
explicitTemplate?: boolean
): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0;
const supported = usageQuerySupported(usageKinds, explicitTemplate);
// Cache key includes the agent so a Kimi Code provider and a Pi provider
// with the same name do not clobber each other's cached result.
const cacheKey = `${agent}:${providerName}`;
+37 -4
View File
@@ -111,7 +111,8 @@ export const enTranslations: Record<TranslationKey, string> = {
baseUrlHint: "Enter a {type} API-compatible endpoint without a trailing slash.",
envPairs: "Env pairs",
addEnv: "+ Add",
modelMappingDesc: "Display name only affects the /model menu; 1M declares context capability for Kimi Code.",
modelMappingDesc: "Display name only affects the /model menu.",
maxOutputSizeDesc: "Max output writes max_output_size — leave blank to send no output cap upstream.",
oneClickSetup: "One-click setup",
fetchModels: "Fetch models",
fetchingModels: "Fetching...",
@@ -121,7 +122,8 @@ export const enTranslations: Record<TranslationKey, string> = {
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
supports1M: "Supports 1M",
maxOutputSize: "Max output",
maxOutputRef: "Ref {value}",
default: "Default",
operation: "Operation",
isDefault: "Default",
@@ -164,6 +166,15 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingLow: "Low",
thinkingMedium: "Medium",
thinkingHigh: "High",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "The current default model does not support thinking.",
thinkingEffortTierUnsupported:
"The current default model does not support this tier (supported: {levels}).",
thinkingEffortTierUndeclared:
"This model declares no thinking effort tiers; the upstream may reject this one.",
thinkingEffortDependsOnDefaultModel:
"The tier that actually applies depends on the current default model.",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step",
@@ -177,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",
@@ -239,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?",
@@ -594,6 +612,21 @@ export const enTranslations: Record<TranslationKey, string> = {
openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// models.dev reference data (context / capabilities / pricing)
modelsDataSection: "Model Reference Data (models.dev)",
modelsDataDesc:
"Model context limits, capability flags, and pricing come from an online models.dev snapshot. Syncing applies new models immediately — no app update needed.",
modelsDataSourceSynced: "Online sync",
modelsDataSourceBuiltin: "Bundled with release",
modelsDataStats: "{date} · {models} models / {providers} providers",
modelsDataSyncNow: "Sync Now",
modelsDataSyncing: "Syncing...",
modelsDataSyncOk: "Synced — model parameters and pricing refreshed",
modelsDataSyncFailed: "Sync failed",
modelsDataRestore: "Restore Built-in Data",
modelsDataRestoreConfirm:
"Delete the local synced copy and fall back to the snapshot bundled with this release? You can sync again at any time.",
// Plugin marketplace
pluginMarketplace: "Plugin Marketplace",
pluginMarketplaceSubtitle:
+37 -7
View File
@@ -108,7 +108,8 @@ export const zhTranslations = {
baseUrlHint: "填写兼容 {type} API 的服务端点地址,不要以斜杠结尾",
envPairs: "Env 键值对",
addEnv: "+ 添加",
modelMappingDesc: "显示名称只影响 /model 菜单;1M 只是给 Kimi Code 的上下文能力声明。",
modelMappingDesc: "显示名称只影响 /model 菜单。",
maxOutputSizeDesc: "「最大输出」写入 max_output_size,留空则不发送输出上限。",
oneClickSetup: "一键设置",
fetchModels: "获取模型列表",
fetchingModels: "获取中...",
@@ -118,7 +119,8 @@ export const zhTranslations = {
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
supports1M: "声明支持 1M",
maxOutputSize: "最大输出",
maxOutputRef: "参考 {value}",
default: "默认",
operation: "操作",
isDefault: "已默认",
@@ -158,9 +160,15 @@ export const zhTranslations = {
enableThinking: "启用思考",
thinkingLevel: "思考等级",
thinkingKeep: "保留思考内容",
thinkingLow: "低",
thinkingMedium: "中",
thinkingHigh: "高",
thinkingLow: "Low",
thinkingMedium: "Medium",
thinkingHigh: "High",
thinkingMax: "Max",
thinkingXHigh: "XHigh",
thinkingEffortUnsupported: "当前默认模型不支持思考。",
thinkingEffortTierUnsupported: "当前默认模型不支持该档位(支持:{levels})。",
thinkingEffortTierUndeclared: "该模型未声明思考等级,上游可能拒绝此档位。",
thinkingEffortDependsOnDefaultModel: "实际生效的档位取决于当前默认模型。",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数",
@@ -173,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: "模式",
@@ -234,7 +249,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
loadStats: "加载 {ms} ms",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",
@@ -583,6 +598,21 @@ export const zhTranslations = {
openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// models.dev reference data (context / capabilities / pricing)
modelsDataSection: "模型参考数据(models.dev)",
modelsDataDesc:
"模型上下文长度、能力标记与单价参考来自 models.dev 在线快照。同步后新模型的参数与价格立即生效,无需等待新版本。",
modelsDataSourceSynced: "在线同步",
modelsDataSourceBuiltin: "随版本内置",
modelsDataStats: "{date} · {models} 模型 / {providers} 供应商",
modelsDataSyncNow: "立即同步",
modelsDataSyncing: "同步中...",
modelsDataSyncOk: "已同步,模型参数与单价已刷新",
modelsDataSyncFailed: "同步失败",
modelsDataRestore: "恢复内置数据",
modelsDataRestoreConfirm:
"删除本地同步副本并回退到随版本内置的快照数据?之后可随时重新同步。",
// Plugin marketplace
pluginMarketplace: "插件市场",
pluginMarketplaceSubtitle:
+69 -9
View File
@@ -20,7 +20,7 @@ function loopOf(raw: unknown): Record<string, unknown> {
}
// ---------------------------------------------------------------------------
// Reading — legacy off values normalized to "off", "max" → "high"
// Reading — legacy off values normalized to "off"; effort tiers pass through
// ---------------------------------------------------------------------------
describe("getAgentSettings — thinking.keep normalization", () => {
@@ -52,12 +52,29 @@ describe("getAgentSettings — thinking.keep normalization", () => {
});
});
describe("getAgentSettings — effort \"max\" read mapping", () => {
it("normalizes a stored \"max\" to \"high\" on read", () => {
describe("getAgentSettings — thinking.effort read mapping", () => {
it("keeps a stored \"max\" as-is (the tier is valid upstream)", () => {
expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe(
"high"
"max"
);
});
it("passes an \"xhigh\" tier through untouched", () => {
expect(getAgentSettings({ thinking: { effort: "xhigh" } }).thinking?.effort).toBe(
"xhigh"
);
});
it("keeps the default \"medium\" when the key is absent", () => {
expect(getAgentSettings({}).thinking?.effort).toBe("medium");
});
it("round-trips an arbitrary hand-written tier on save", () => {
const next = setAgentSettings({ thinking: { effort: "ultra" } }, {
thinking: { effort: "ultra" },
});
expect(thinkingOf(next).effort).toBe("ultra");
});
});
// ---------------------------------------------------------------------------
@@ -184,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> {
@@ -196,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", () => {
@@ -231,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 -8
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"
@@ -65,11 +67,6 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
if (thinking.keep !== undefined && thinking.keep !== "all") {
thinking.keep = "off";
}
// The "max" effort tier was removed upstream (auto-migrates to "high");
// old configs still carrying it are shown as "high" (not rewritten on read).
if (thinking.effort === "max") {
thinking.effort = "high";
}
const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther,
"permission"
@@ -97,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,
};
}
@@ -171,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;
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
parseMaxOutputInput,
readMaxOutputSize,
setMaxOutputSize,
withMaxOutputSize,
} from "./model-max-output";
const model = (raw_other: unknown) =>
({
alias: "a",
provider: "p",
model: "m",
max_context_size: 0,
display_name: null,
raw_other,
}) as const;
describe("readMaxOutputSize", () => {
it("reads the key from a model entry's preserved config fields", () => {
expect(readMaxOutputSize({ max_output_size: 32768 })).toBe(32768);
});
it("treats absent / malformed / non-positive values as unset", () => {
expect(readMaxOutputSize(undefined)).toBeUndefined();
expect(readMaxOutputSize(null)).toBeUndefined();
expect(readMaxOutputSize({})).toBeUndefined();
expect(readMaxOutputSize("32768")).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: 0 })).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: -1 })).toBeUndefined();
expect(readMaxOutputSize({ max_output_size: NaN })).toBeUndefined();
expect(readMaxOutputSize([1, 2])).toBeUndefined();
});
});
describe("parseMaxOutputInput", () => {
it("keeps positive integers", () => {
expect(parseMaxOutputInput("32768")).toBe(32768);
});
it("maps blank / zero / garbage to unset", () => {
expect(parseMaxOutputInput("")).toBeUndefined();
expect(parseMaxOutputInput("0")).toBeUndefined();
expect(parseMaxOutputInput("-5")).toBeUndefined();
expect(parseMaxOutputInput("abc")).toBeUndefined();
});
});
describe("setMaxOutputSize", () => {
it("sets the key while preserving other preserved fields", () => {
expect(setMaxOutputSize({ support_efforts: ["low"] }, 16384)).toEqual({
support_efforts: ["low"],
max_output_size: 16384,
});
});
it("drops the key when the value is unset", () => {
expect(setMaxOutputSize({ max_output_size: 16384, force: true }, undefined)).toEqual({
force: true,
});
});
it("starts from an empty object for null / non-object raw_other", () => {
expect(setMaxOutputSize(null, 1024)).toEqual({ max_output_size: 1024 });
expect(setMaxOutputSize("junk", 1024)).toEqual({ max_output_size: 1024 });
expect(setMaxOutputSize({ max_output_size: 1 }, undefined)).toEqual({});
});
});
describe("withMaxOutputSize", () => {
it("returns a new model with the override applied", () => {
const before = model({ max_output_size: 4096 });
const after = withMaxOutputSize(before, 8192);
expect(readMaxOutputSize(after.raw_other)).toBe(8192);
expect(readMaxOutputSize(before.raw_other)).toBe(4096);
expect(after).not.toBe(before);
});
it("clears the override without touching other fields", () => {
const after = withMaxOutputSize(model({ max_output_size: 4096 }), undefined);
expect(after.raw_other).toEqual({});
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { Model } from "../types";
/**
* Helpers for the `max_output_size` model field.
*
* kimi-code keeps it in `[models."<alias>"] max_output_size`; it is not a
* first-class field of Kimi Switch's Rust `Model` struct, so it round-trips
* through `raw_other` (the pass-through bucket for unknown keys). Absent key =
* unset = the upstream default applies, so clearing the field removes the key
* rather than writing 0.
*/
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
/** Read the override; undefined when absent or malformed (treated as unset). */
export function readMaxOutputSize(rawOther: unknown): number | undefined {
const value = asRecord(rawOther).max_output_size;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
/** Parse the number input: blank / 0 / NaN mean "unset". */
export function parseMaxOutputInput(text: string): number | undefined {
const value = parseInt(text, 10);
if (isNaN(value) || value <= 0) return undefined;
return value;
}
/**
* Set (positive value) or drop (undefined) `max_output_size` on a raw_other
* blob; every other preserved key is kept.
*/
export function setMaxOutputSize(
rawOther: unknown,
value: number | undefined,
): Record<string, unknown> {
const next = { ...asRecord(rawOther) };
if (value === undefined) {
delete next.max_output_size;
} else {
next.max_output_size = Math.floor(value);
}
return next;
}
/** Immutable update of a model entry's max_output_size override. */
export function withMaxOutputSize(
model: Model,
value: number | undefined,
): Model {
return { ...model, raw_other: setMaxOutputSize(model.raw_other, value) };
}
+106717 -97393
View File
File diff suppressed because it is too large. Load diff
+100547 -84433
View File
File diff suppressed because it is too large. Load diff
+62 -23
View File
@@ -3,13 +3,18 @@
* (see scripts/fetch-models-dev.mjs).
*
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
* as a static asset (`/models-dev.json`) and loaded once in the background.
* `getModelRef` stays synchronous and returns undefined until the index is
* ready — callers already fall back to defaults — and `modelsDevReady()`
* lets the app re-render once the index arrives.
* parsing a 1.3 MB JSON literal at startup blocks first paint. Loading order
* (see modelsDevReady): the runtime-synced copy under ~/.kimi-switch/
* (via models_dev.rs, written by the 高级设置 sync button) when present,
* otherwise the bundled static asset (`/models-dev.json`). `getModelRef`
* stays synchronous and returns undefined until the index is ready — callers
* already fall back to defaults — and `modelsDevReady()` lets the app
* re-render once the index arrives. `applyModelsDevSnapshot` hot-swaps the
* index after an online sync without a restart.
*/
import { invoke } from "@tauri-apps/api/core";
export interface ModelCost {
input?: number;
output?: number;
@@ -20,6 +25,8 @@ export interface ModelCost {
export interface ModelRef {
name?: string;
context?: number;
/** Upper bound on generated tokens (`limit.output` on models.dev). */
output?: number;
reasoning?: boolean;
tool_call?: boolean;
structured_output?: boolean;
@@ -49,32 +56,64 @@ function buildIndex(raw: Record<string, unknown>) {
return lower;
}
/**
* Swap in a new snapshot (initial load, online sync, or restore-to-builtin)
* and re-notify listeners so context/capability/price columns re-render.
* Listeners stay registered across reloads by design.
*/
function applySnapshot(raw: Record<string, unknown>) {
byLowerKey = buildIndex(raw);
for (const cb of readyListeners) cb();
}
async function fetchBundled(): Promise<Record<string, unknown>> {
const r = await fetch(`${import.meta.env.BASE_URL}models-dev.json`);
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return (await r.json()) as Record<string, unknown>;
}
let loadPromise: Promise<void> | null = null;
export function modelsDevReady(): Promise<void> {
if (!loadPromise) {
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
.then((r) => {
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
})
.then((raw) => {
byLowerKey = buildIndex(raw);
const listeners = readyListeners;
readyListeners = [];
for (const cb of listeners) cb();
})
.catch((err) => {
// A failed load is permanent for this session: fall back to defaults.
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
// Prefer the runtime-synced copy (~/.kimi-switch/models-dev.json, via
// models_dev.rs); outside Tauri (or without one) fall back to the bundled
// static asset. A failed load is permanent for this session.
loadPromise = (async () => {
let raw: Record<string, unknown> | null = null;
try {
const synced = await invoke<string | null>("get_models_dev_snapshot");
if (synced) raw = JSON.parse(synced) as Record<string, unknown>;
} catch {
/* not running inside Tauri — use the bundled asset */
}
applySnapshot(raw ?? (await fetchBundled()));
})().catch((err) => {
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
}
return loadPromise;
}
/** Register a callback invoked once the models.dev index is ready. */
/**
* Apply a freshly synced snapshot (already fetched by the caller) and notify
* listeners so the UI refreshes without a restart.
*/
export function applyModelsDevSnapshot(raw: Record<string, unknown>): void {
applySnapshot(raw);
}
/**
* Reload from the bundled static asset (after "restore built-in data" —
* the synced copy was already deleted on the Rust side).
*/
export function reloadModelsDev(): Promise<void> {
return fetchBundled().then(applySnapshot);
}
/** Register a callback invoked whenever the models.dev index changes. */
export function onModelsDevReady(cb: () => void): void {
if (byLowerKey) {
cb();
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
THINKING_EFFORTS,
normalizeSupportEfforts,
readSupportEfforts,
resolveThinkingEffortSupport,
} from "./thinking-efforts";
// ---------------------------------------------------------------------------
// support_efforts normalization — an absent / malformed key means "unknown",
// never "no tiers supported"
// ---------------------------------------------------------------------------
describe("normalizeSupportEfforts", () => {
it("keeps a non-empty list of strings", () => {
expect(normalizeSupportEfforts(["low", "high"])).toEqual(["low", "high"]);
});
it("drops blanks and non-strings but keeps the order", () => {
expect(normalizeSupportEfforts(["low", "", 3, null, "max"])).toEqual([
"low",
"max",
]);
});
it("returns null for absent / empty / non-array values", () => {
expect(normalizeSupportEfforts(undefined)).toBeNull();
expect(normalizeSupportEfforts([])).toBeNull();
expect(normalizeSupportEfforts([" "])).toBeNull();
expect(normalizeSupportEfforts("low")).toBeNull();
});
});
describe("readSupportEfforts", () => {
const model = (raw_other: unknown) =>
({ alias: "a", provider: "p", model: "m", max_context_size: 0, display_name: null, raw_other }) as const;
it("reads the key from a model entry's preserved config fields", () => {
expect(readSupportEfforts(model({ support_efforts: ["low", "max"] }))).toEqual([
"low",
"max",
]);
});
it("returns null when the entry or the key is absent", () => {
expect(readSupportEfforts(undefined)).toBeNull();
expect(readSupportEfforts(model(undefined))).toBeNull();
expect(readSupportEfforts(model({ other: 1 }))).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Tier resolution — three layers, in priority order
// ---------------------------------------------------------------------------
describe("resolveThinkingEffortSupport — declared support_efforts wins", () => {
const support = resolveThinkingEffortSupport({
supportEfforts: ["low", "high", "max"],
reasoning: true,
});
it("exposes the declared list and keeps thinking enabled", () => {
expect(support.declared).toEqual(["low", "high", "max"]);
expect(support.thinkingSupported).toBe(true);
});
it("enables exactly the declared tiers", () => {
for (const tier of THINKING_EFFORTS) {
expect(support.levels[tier].enabled).toBe(tier === "low" || tier === "high" || tier === "max");
}
});
it("explains a disabled tier with the supported list", () => {
expect(support.levels.medium).toEqual({
enabled: false,
note: { kind: "tierUnsupported", supported: ["low", "high", "max"] },
});
expect(support.levels.xhigh.enabled).toBe(false);
});
it("does not disable thinking even when models.dev says reasoning: false", () => {
const conflicting = resolveThinkingEffortSupport({
supportEfforts: ["high"],
reasoning: false,
});
expect(conflicting.thinkingSupported).toBe(true);
expect(conflicting.levels.low.enabled).toBe(false);
expect(conflicting.levels.high.enabled).toBe(true);
});
it("matches tiers case-insensitively", () => {
const upper = resolveThinkingEffortSupport({ supportEfforts: ["HIGH"] });
expect(upper.levels.high.enabled).toBe(true);
});
});
describe("resolveThinkingEffortSupport — model without thinking", () => {
const support = resolveThinkingEffortSupport({ reasoning: false });
it("disables the whole thinking area", () => {
expect(support.thinkingSupported).toBe(false);
expect(support.declared).toBeNull();
for (const tier of THINKING_EFFORTS) {
expect(support.levels[tier]).toEqual({
enabled: false,
note: { kind: "thinkingUnsupported" },
});
}
});
});
describe("resolveThinkingEffortSupport — nothing known about the model", () => {
it("offers low/medium/high plain and flags max/xhigh as undeclared", () => {
const support = resolveThinkingEffortSupport({});
expect(support.thinkingSupported).toBe(true);
expect(support.declared).toBeNull();
for (const tier of ["low", "medium", "high"] as const) {
expect(support.levels[tier]).toEqual({ enabled: true });
}
for (const tier of ["max", "xhigh"] as const) {
expect(support.levels[tier]).toEqual({
enabled: true,
note: { kind: "tierUndeclared" },
});
}
});
it("treats an undefined reasoning flag as unknown, not as unsupported", () => {
expect(
resolveThinkingEffortSupport({ reasoning: undefined }).thinkingSupported
).toBe(true);
});
it("ignores a malformed support_efforts value and falls back to unknown", () => {
const support = resolveThinkingEffortSupport({ supportEfforts: "low" });
expect(support.declared).toBeNull();
expect(support.levels.low).toEqual({ enabled: true });
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Thinking-effort capability resolution.
*
* kimi-code's `[thinking] effort` is a free-form string (low/medium/high/
* xhigh/max in practice) that the CLI forwards verbatim to OpenAI-compatible
* upstreams as `reasoning_effort`. Which tiers a given model accepts is
* declared per model in config.toml (`[models."<alias>"] support_efforts`);
* models.dev only carries a boolean `reasoning` flag with no tier list. The
* two sources are merged here so the UI can grey out tiers the current default
* model would reject, without ever rejecting a stored value on read.
*/
import type { Model } from "../types";
export const THINKING_EFFORTS = [
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ThinkingEffort = (typeof THINKING_EFFORTS)[number];
/** Why a tier is blocked, or (for undeclared models) merely unverified. */
export type EffortNote =
| { kind: "thinkingUnsupported" }
| { kind: "tierUnsupported"; supported: string[] }
| { kind: "tierUndeclared" };
export interface EffortAvailability {
/** Whether the tier can be picked. */
enabled: boolean;
/** Advisory message — set for blocked tiers and for unverified ones. */
note?: EffortNote;
}
export interface ThinkingEffortSupport {
/** False only when the model is known not to support thinking at all. */
thinkingSupported: boolean;
/** Declared tiers, or null when nothing is known about the model. */
declared: string[] | null;
levels: Record<ThinkingEffort, EffortAvailability>;
}
/** Tiers offered unverified when a model declares nothing at all. */
const ALWAYS_OFFERED = new Set<string>(["low", "medium", "high"]);
/**
* Normalize a `support_efforts` value into a comparable list, or null when the
* key is absent / malformed (treating it as "unknown", not "none supported").
*/
export function normalizeSupportEfforts(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const tiers = value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter((v) => v.length > 0);
return tiers.length > 0 ? tiers : null;
}
/** Read `support_efforts` from a model entry's preserved config.toml fields. */
export function readSupportEfforts(model: Model | undefined): string[] | null {
if (!model) return null;
const raw = model.raw_other;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return normalizeSupportEfforts(
(raw as Record<string, unknown>).support_efforts
);
}
function buildLevels(
resolve: (effort: ThinkingEffort) => EffortAvailability
): Record<ThinkingEffort, EffortAvailability> {
const levels = {} as Record<ThinkingEffort, EffortAvailability>;
for (const effort of THINKING_EFFORTS) levels[effort] = resolve(effort);
return levels;
}
/**
* Resolve per-tier availability from the two capability sources, in priority
* order:
* 1. `supportEfforts` declared → tiers follow the list exactly.
* 2. models.dev `reasoning === false` → the whole thinking area is off.
* 3. Nothing known → low/medium/high are offered plain, max/xhigh stay
* selectable but carry a "not declared" note (upstream may reject them).
*/
export function resolveThinkingEffortSupport(input: {
supportEfforts?: unknown;
reasoning?: boolean;
}): ThinkingEffortSupport {
const declared = normalizeSupportEfforts(input.supportEfforts);
if (declared) {
const supported = new Set(declared.map((t) => t.toLowerCase()));
return {
thinkingSupported: true,
declared,
levels: buildLevels((effort) =>
supported.has(effort)
? { enabled: true }
: {
enabled: false,
note: { kind: "tierUnsupported", supported: declared },
}
),
};
}
if (input.reasoning === false) {
return {
thinkingSupported: false,
declared: null,
levels: buildLevels(() => ({
enabled: false,
note: { kind: "thinkingUnsupported" },
})),
};
}
return {
thinkingSupported: true,
declared: null,
levels: buildLevels((effort) =>
ALWAYS_OFFERED.has(effort)
? { enabled: true }
: { enabled: true, note: { kind: "tierUndeclared" } }
),
};
}
+12 -4
View File
@@ -111,11 +111,12 @@ export interface DiscoveredModel {
export interface ThinkingConfig {
enabled?: boolean;
/**
* Effort tier. `max` is read-compatible only — upstream removed the tier
* (old configs auto-migrate to `high`); the UI normalizes it and the
* serialization path never writes it.
* Effort tier, forwarded verbatim to OpenAI-compatible upstreams as
* `reasoning_effort`. Upstream accepts a free-form string; the UI offers
* low/medium/high/max/xhigh and derives per-model support from
* `[models."<alias>"] support_efforts`.
*/
effort?: "low" | "medium" | "high" | "max";
effort?: string;
/**
* Keep thinking content. The legacy off values (`false`, `0`, "no", "none",
* `null`) are read-compatible — old configs may carry them; the UI
@@ -171,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
+1 -1
View File
@@ -50,7 +50,7 @@
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.7.12",
"softwareVersion": "0.8.1",
"dateModified": "2026-09-03",
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
+64 -12
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.7.12",
downloadBtn: "下载 v0.8.0",
},
hero: {
badge: "v0.7.12 · 开源 MIT · Windows / macOS / Linux",
badge: "v0.8.0 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
@@ -29,7 +29,7 @@ const zh = {
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "7495", label: "模型价格库" },
{ value: "8385", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
@@ -76,7 +76,7 @@ const zh = {
{
id: "model-discovery",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 7495 个模型、212 家供应商)。",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,385 个模型、226 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -89,7 +89,7 @@ const zh = {
},
showcase: {
title: "界面演示",
subtitle: "以下截图取自 v0.7.12 实机运行。",
subtitle: "以下截图取自 v0.8.0 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -140,6 +140,32 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
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",
items: [
"模型映射新增「最大输出 Token」列:逐模型设置 max_output_size(留空用上游默认),models.dev 有 output 上限时显示可点击参考值,打开编辑页自动补全空值",
"models.dev 快照新增 output 上限字段,同步至 2026-10-03(8,385 模型 / 226 供应商,output 覆盖 97.4%)",
"移除无效的「声明支持 1M」复选框(勾选从不生效),由「最大输出 Token」列取代",
],
},
{
version: "v0.7.23",
date: "2026-09-30",
items: [
"思考等级五档 + 能力感知:Low / Medium / High / XHigh / Max,按当前默认模型声明的 support_efforts 自动置灰不支持档位并提示原因",
"models.dev 标记不支持思考的模型整体禁用思考区;未声明档位的模型保留可选并提示上游可能拒绝",
],
},
{
version: "v0.7.12",
date: "2026-09-03",
@@ -322,7 +348,7 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.12 · Windows / macOS / Linux 三平台已发布",
subtitle: "当前版本 v0.8.0 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
@@ -391,10 +417,10 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.7.12",
downloadBtn: "Download v0.8.0",
},
hero: {
badge: "v0.7.12 · Open source MIT · Windows / macOS / Linux",
badge: "v0.8.0 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
@@ -404,7 +430,7 @@ const en: Dict = {
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "7495", label: "Model price DB" },
{ value: "8385", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
@@ -451,7 +477,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 (7495 models, 212 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,385 models, 226 providers).",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -464,7 +490,7 @@ const en: Dict = {
},
showcase: {
title: "Screenshots",
subtitle: "All screenshots below are taken from the running v0.7.12 build.",
subtitle: "All screenshots below are taken from the running v0.8.0 build.",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -515,6 +541,32 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
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",
items: [
"New per-model Max Output Token column: sets max_output_size in config.toml (blank keeps the upstream default), shows a click-to-fill reference when models.dev knows the cap, and backfills empty rows automatically",
"models.dev snapshot gains the output-limit field, synced to 2026-10-03 (8,385 models / 226 providers, 97.4% output coverage)",
"Removed the ineffective 'Supports 1M' checkbox (it was never persisted), replaced by the Max Output Token column",
],
},
{
version: "v0.7.23",
date: "2026-09-30",
items: [
"Thinking levels expanded to five tiers with capability awareness: Low / Medium / High / XHigh / Max, greying out tiers the current default model's support_efforts doesn't declare, with an explanation",
"Models flagged non-reasoning by models.dev disable the whole thinking area; undeclared tiers stay selectable with an upstream-may-reject hint",
],
},
{
version: "v0.7.12",
date: "2026-09-03",
@@ -696,7 +748,7 @@ const en: Dict = {
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.7.12 · Windows, macOS and Linux now released",
subtitle: "Current version v0.8.0 · 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",
+1 -1
View File
@@ -8,7 +8,7 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo,
};
const VERSION = "0.7.12";
const VERSION = "0.8.1";
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}`;