Compare commits

..
18 Commits
Author SHA1 Message Date
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
KimiSwitch Dev fcd08eae32 feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
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-20 09:49:49 +08:00
KimiSwitch Dev 35581103ed feat(usage): plan:kimi_coding 兼容 kimi-code 0.43.1 quota 用量模型
服务端 api.kimi.com/coding/v1/usages 已由绝对 used/limit 行切换为
quota 模型(usages.limit_5h/limit_7d/limit_month_total/limit_month_code,
条目为 used_ratio 0-1 比率 + reset_time),见上游 #3787。

- parse_kimi_coding 优先解析新结构,旧 limits/usage 结构保留为回退
- used_ratio >1 时按已是百分数处理(防御)
- 新 tier id month_code + 前端 planLabel/i18n 中英文案
- 新增 3 个测试:四窗口解析 / 比率防御 / 空窗口回退旧结构
2026-09-16 14:14:29 +08:00
KimiSwitch Dev 1b80d66239 chore(flags): 适配 kimi-code 0.43.0 — 移除转正 auto_session_title 实验 flag — bump to v0.7.19
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-14 22:40:44 +08:00
KimiSwitch Dev 0f421b4e44 feat(sessions): 一键归档 + 归档用量快照落库 — bump to v0.7.18
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
- archive_sessions_before:按 updatedAt 截止点批量元数据归档(1月/半月/周/指定日期)
- 归档瞬间聚合 wire.jsonl 用量按天×模型快照进 SQLite archived_sessions 表
- 统计合并:文件已删除的归档会话由快照合成 UsageRecord 注入
  get_summary/日明细,趋势/热力图/模型统计全兼容且绝不双计
- models-dev 快照同步 2026-09-14(7784 模型);pricing 测试跟随
  deepseek-v4-flash 调价(0.15/0.60)
- cargo test 129 通过(含端到端归档→删目录→统计保留用例)、tsc 零错误
2026-09-14 01:52:21 +08:00
KimiSwitch Dev 00cf194b5c feat(usage): Sub2API 中转站账单查询 + 模型发现列表全选/反选 — bump to v0.7.17
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
- 新增 balance:sub2api:GET {base}/v1/usage 复用推理 sk- key,解析 quota /
  5h·1d·7d rate_limits / 订阅组日周月额度 / 钱包余额(USD)
- detect_provider 修正 codingplan.site 家族:主域=Sub2API,ai.=NewAPI
- UsageConfigModal 新增 Sub2API 模板(无需凭据,可选 Base URL 覆盖)
- ProviderEdit 模型发现列表新增 全选/取消全选/反选
- cargo test 124 通过(+6 sub2api 解析用例)、tsc 零错误
2026-09-13 14:51:09 +08:00
KimiSwitch Dev b9002281a3 chore(data): 同步 models-dev 2026-09-10(7615 模型 / 213 供应商)+ v0.7.16 release notes
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-10 23:37:15 +08:00
KimiSwitch Dev 0ea9e5b3de chore(release): bump to v0.7.16 — 适配 kimi-code 0.42.0 实验 flag 9→6 + 模型用量统计滚动 2026-09-10 13:19:54 +08:00
KimiSwitch Dev 36e64a1f72 fix(dashboard): 模型用量统计图表固定最大高度 360px 并滚动,与右侧模型用量表一致 2026-09-10 11:09:06 +08:00
KimiSwitch Dev 9ce1ed65b0 feat(settings): 适配 kimi-code 0.42.0 —— 实验 flag 镜像 9→6
- 删除已转正/移除的 4 个 flag:secondary-model(模型池恒开)、
  persistence_minidb_readmodel(改由 [database] 配置节)、remote-control、
  search_worker;env 探测清单同步 11→8
- 新增 notify_user flag(KIMI_CODE_EXPERIMENTAL_NOTIFY_USER,实验性
  Updates 面板,默认关闭)
- 设置页移除 secondary-model 专属卡片与「默认开启」徽章,6 个 flag 统一渲染;
  [secondary_model] 模型池配置逻辑不变
- 残留旧键上游 schema 宽容忽略,无需迁移
2026-09-09 19:48:41 +08:00
KimiSwitch Dev 33a27ba31b fix(release): publish-gitea.sh 修复含空格文件名上传失败(暂存去空格副本 + 点号命名) 2026-09-09 14:32:34 +08:00
KimiSwitch Dev 7bc2dee8b2 chore(release): 新增国内 Gitea 发布脚本 publish-gitea.sh(先国内后 GitHub 流程) 2026-09-09 14:29:29 +08:00
KimiSwitch Dev 5864d661c1 ci(release): Release 文案改为自动读取 release-notes-<tag>.md
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
不再硬编码过期文案;缺失时回退到提交记录说明,保留下载与 macOS 安装说明模板
2026-09-09 14:06:30 +08:00
KimiSwitch Dev 34a9c4c2d9 chore(release): bump to v0.7.15 — 昨天选项卡 + 模型用量统计 + Go 套餐 x-opencode-session 自动补头 2026-09-09 13:51:58 +08:00
KimiSwitch Dev 4f0b0cae12 feat(providers): Go 套餐导出自动补 x-opencode-session 头 + models-dev 2026-09-09
- opencode Console Go 网关强制要求 x-opencode-session 请求头(缺失 400),
  config 导出时对 zen/go 端点供应商自动补 custom_headers(保留用户已有值),
  zen/v1 按量端点不受影响
- models-dev 快照 7583 模型 / 213 供应商;public/ 前端静态目录同步补齐(原 08-27)
2026-09-09 13:51:57 +08:00
KimiSwitch Dev 35fd2e7b0e feat(dashboard): 用量统计新增「昨天」范围 + 跨供应商「模型用量统计」选项卡
- 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤
- RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并,
  子代理记录归入 __secondary__ 单独成行,按 Token 降序
- 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图,
  悬停显示请求数/Token/缓存命中/费用
- 概览卡网格扩展为 5 张;i18n 中英同步
- 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3
2026-09-09 13:51:43 +08:00
78 changed files with 286808 additions and 255023 deletions

No files matched your search

+41 -30
View File
@@ -69,6 +69,46 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Compose release body
id: notes
shell: bash
run: |
FILE="docs/release-notes/${{ github.ref_name }}.md"
[ -f "$FILE" ] || FILE="release-notes-${{ github.ref_name }}.md"
{
echo 'body<<RELEASE_BODY_EOF'
if [ -f "$FILE" ]; then
cat "$FILE"
else
echo "## ${{ github.ref_name }}"
echo
echo "见提交记录(未找到 $FILE)。"
fi
echo
cat <<'TAIL_EOF'
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
Full changelog: see commit history.
TAIL_EOF
echo 'RELEASE_BODY_EOF'
} >> "$GITHUB_OUTPUT"
- name: Build and upload release
uses: tauri-apps/tauri-action@v0
env:
@@ -76,36 +116,7 @@ jobs:
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
releaseBody: |
## ${{ github.ref_name }}
### 本次更新
- **模型数据同步(models.dev 2026-08-27)**:7343 个模型 / 203 个供应商
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare、DeepInfra、HuggingFace、火山引擎等约 20 条渠道;GLM-5.3-Flash 定价 $0.075 / $0.25(输入/输出,每百万 token)
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
- 新增 Qwen3.8 系列、火山引擎豆包 Seed 2.x 全系、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
- 价格更新 58 处、能力信息修正 56 处(详见 release-notes-v0.7.9.md)
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
Full changelog: see commit history.
releaseBody: ${{ steps.notes.outputs.body }}
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
overwrite: true
args: ${{ matrix.args }}
+147
View File
@@ -0,0 +1,147 @@
# Changelog
**Kimi Switch** — Windows 桌面端的 Kimi Code CLI 配置管理器,统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
本文件汇总各版本要点,按版本倒序排列;每个版本的**完整 release notes**(含安装说明、平台产物、已知问题)见 [`docs/release-notes/`](./docs/release-notes/)。
---
## 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 匹配价格,不再落入兜底估算
+11
View File
@@ -253,6 +253,17 @@ KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的
## 5. 新增能力实现清单(改动点)
> 实施状态更新(2026-09-13):**Sub2API 兼容已实现(`balance:sub2api`)**。
> 实测澄清:`codingplan.site`(主域)**不是** NewAPI 而是 Wei-Shaw/sub2api 面板
> (NewAPI 端点 `/api/status`、`/api/user/self` 全 404;`/api/v1/settings/public`
> 返回 `data.affiliate_enabled` 指纹命中;`/v1/usage`、`/v1/sub2api/billing` 为
> API-key 网关端点)。`ai.codingplan.site` 才是 NewAPI 实例。detect_provider
> 已按此拆分家族规则。Sub2API 查询复用推理 sk- key(`GET {base}/v1/usage`,
> Bearer),解析三种 mode:quota_limited(quota + 5h/1d/7d rate_limits)、
> unrestricted 订阅组(daily/weekly/monthly usage/limit USD)、钱包余额。
> UsageConfigModal 模板加 "sub2api"(无需凭据,可选 Base URL 覆盖)。
> 验证:cargo test 124 通过(含 6 个 sub2api 解析用例)、tsc 零错误。
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
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,16 @@
# KimiSwitch v0.7.15
## 新功能
- **用量统计新增「昨天」选项卡**:时间范围现为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点(不渗入今天),顶部概览卡同步扩展为 5 张
- **新增「模型用量统计」趋势选项卡**:在「供应商模型用量趋势」之后,不按天、跨供应商按模型名汇总(同一模型经不同供应商合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用;子代理模型仍单独成行
## 修复
- **opencode Go 套餐子代理请求 400**:Go 网关(opencode.ai/zen/go)近期强制要求 `x-opencode-session` 请求头用于路由与提示词缓存,缺失即 400 拒绝。现在导出配置时,凡指向 Go 端点的供应商会自动补写 `custom_headers.x-opencode-session`(已有用户自定义值保留不覆盖);按量计费的 `zen/v1` 端点不受影响
- 定价测试基准条目更新:models.dev 上游下架 `moonshotai/kimi-k2.5` 官方条目,测试基准切换到 `moonshotai/kimi-k3`
## 数据
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商),并补齐 `public/` 前端静态目录(原停留在 08-27)
- 注意:k2.5 官方定价条目下架后,`kimi/k2.5` 费用估算暂解析到 302ai 转售价格(0.66 / 3.3),略高于原官方价
@@ -0,0 +1,7 @@
# KimiSwitch v0.7.16
## 适配
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除已转正/删除的 4 个开关(子代理次主力模型——模型池恒开、MiniDB 读模型——改由 `[database]` 配置节、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭);环境变量探测清单同步;老配置残留旧键上游静默忽略,无需迁移
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 前端静态目录同步
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.17
## 新增
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板的余额/额度查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,**无需**网页后台 Access Token;解析三种模式——单 Key 总额度(quota)、5 小时/每日/7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI(修正此前主域被误判为 NewAPI 导致查询 404 的问题)
- **模型映射「获取模型列表」新增批量选择**:发现列表标题右侧新增「全选 / 取消全选 / 反选」三个按钮,配合已有的已添加标记与「添加选中的」,批量接入中转站模型列表更省事
## 修复
- 修复 `codingplan.site`(Sub2API 面板)走 NewAPI 模板查询必然失败的问题——NewAPI 模板(Access Token + User ID)仍适用于 NewAPI / OneAPI 站点,两者互不影响
## 备注
- Sub2API 查询的金额单位由上游硬编码为 USD;速率窗口标签沿用现有本地化(5 小时 / 每日 / 7 天)
@@ -0,0 +1,14 @@
# KimiSwitch v0.7.18
## 新增
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期,一键归档所有最后活跃时间早于所选时间点的会话(一次批量操作,替代逐个选择);结果反馈已归档 / 跳过数量
- **归档用量快照落库**:归档瞬间把每个会话的 token 用量与费用按「天 × 模型」粒度快照进 SQLite(`archived_sessions` 表)。之后删除已归档会话(释放磁盘)不再损失历史统计——仪表盘的趋势图、热力图、模型统计、区间合计均会自动合并快照数据,且与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
## 修复
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),`public/` 前端静态目录同步;pricing 测试价格断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
## 备注
- 归档仍是元数据语义(文件原地保留),删除文件才会触发快照合并;用量配置、账单查询等其余功能不受影响
@@ -0,0 +1,10 @@
# KimiSwitch v0.7.19
## 适配
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title` 开关(上游 #3749,AI 会话标题不再需要 flag,首轮对话后自动生成、可在重命名处重新生成)。设置页「实验性 flag」、环境变量探测(`get_experimental_env_status`)、中英文案与测试断言同步清理;上游 0.43.0 注册表现剩 5 个 flag: `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
- **兼容性核查结论(无需改动)**:0.43.0 的 wire 持久化重建(#3737)不影响用量统计——`usage.record` / `token_counting.measured` / `context.apply_compaction` / `turn.prompt` 记录全部保留(`turn.prompt` 仅新增可选 `turnId` 字段);新增配置项 `loop_control.compaction_max_attempts`(#3750)经现有合并式写入验证保存不丢失;MCP `deferred` 字段(#3667)不涉及本应用管理的配置节
## 备注
- 若此前在 `[experimental]` 中显式开启过 `auto_session_title`,残留键会被上游静默忽略,无需手动清理
File renamed without changes.
@@ -0,0 +1,23 @@
# KimiSwitch v0.7.20
## 新增
- **套餐用量阈值预警**:用量配置面板新增「预警阈值 %」(0-100,0 或空 = 关闭)。每次账单查询后,任一百分比用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除;自动查询间隔会周期性重复评估
- **Kimi For Coding 加力钱包**:0.43.1 起服务端 quota 响应中的 `boosterWallet`(加力钱包)现在解析展示为一行余额(💰 月度用量 / 总额 / 余额,含币种),与四个用量窗口并列
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762——供应商编辑页「凭证来源」可切换「直接填写 / 环境变量」,密钥不写进 config.toml;导出时与 `api_key` 互斥;账单查询 / 获取模型列表会实时解析环境变量(变量未设置给出明确错误);SQLite 新增列(含自动迁移)
- **Kimi Code 版本跟踪器**:设置页新增「Kimi Code 版本」卡片——本机已装版本(`kimi --version` 检测)、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 KimiSwitch / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
- **供应商一键体检**:供应商列表新增「一键体检」按钮——并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重新查询账单,每行状态点 + 延迟 tooltip + 顶部汇总。状态判定为三态:账单或探活任一真实通过 = 绿;鉴权失败必红;端点不支持模型列表(404)计中性不误报
## 设置
- **压缩重试次数**:Agent 设置新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5,0/空不写键)
- **文件监听开关**:新增 `[watch] enabled` 开关(上游 2.0.1 #3892,2.0.2 起上游默认关闭),并探测 `KIMI_CODE_WATCH` 环境变量
## 适配
- **kimi-code 2.0.2 已适配确认**:wire 记录格式、实验 flag(5 个)、config 模式均无变化;`api_key_env` / `[watch]` / `[database]` 新增配置节经现有合并式写入验证无损往返
## 备注
- 版本跟踪器的「最新版本」查询直连 GitHub,受限网络环境下可能显示 `—`(已装版本与状态判定不受影响)
- 体检跳过大模型平台类供应商(anthropic / google-genai / vertexai 无 `/v1/models` 路由),显示灰点「未检测」
@@ -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 供应商)
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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.14",
"version": "0.7.21",
"type": "module",
"scripts": {
"dev": "vite",
+89710 -79115
View File
File diff suppressed because it is too large. Load diff
+16 -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.
@@ -168,12 +178,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}`,
);
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Publish a release (notes + assets) to the domestic Gitea mirror
# (git.codingplan.site/admin/KimiCodeSwitch).
#
# Usage: publish-gitea.sh <tag> <asset> [asset...]
# 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
# (`git credential fill`); the secret never appears in argv or output.
set -euo pipefail
GITEA="https://git.codingplan.site/api/v1"
REPO="admin/KimiCodeSwitch"
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift
NOTES_FILE="docs/release-notes/${TAG}.md"
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
json_escape() {
python - "$1" <<'PYEOF'
import json, sys
print(json.dumps(open(sys.argv[1], encoding="utf-8").read()))
PYEOF
}
if [[ -f "$NOTES_FILE" ]]; then
BODY=$(json_escape "$NOTES_FILE")
else
BODY="\"${TAG}\""
fi
# Resolve stored git credential for this host (no secret echoed).
CRED=$(printf 'protocol=https\nhost=git.codingplan.site\n\n' | git credential fill)
USER=$(grep '^username=' <<<"$CRED" | cut -d= -f2-)
PASS=$(grep '^password=' <<<"$CRED" | cut -d= -f2-)
[[ -n "$USER" && -n "$PASS" ]] || { echo "no stored credential for git.codingplan.site" >&2; exit 1; }
AUTH=(-u "$USER:$PASS")
# Create (or reuse) the release for this tag.
EXISTING=$(curl -sf --max-time 20 "${AUTH[@]}" "$GITEA/repos/$REPO/releases/tags/$TAG" || true)
if [[ -n "$EXISTING" && "$EXISTING" != *"message"* ]]; then
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$EXISTING" | head -1 | cut -d: -f2)
echo "release $TAG exists (id=$RELEASE_ID), attaching assets"
else
CREATED=$(curl -sf --max-time 20 "${AUTH[@]}" \
-H 'Content-Type: application/json' -X POST \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":$BODY,\"draft\":false,\"prerelease\":false}" \
"$GITEA/repos/$REPO/releases")
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$CREATED" | head -1 | cut -d: -f2)
echo "created release $TAG (id=$RELEASE_ID)"
fi
for ASSET in "$@"; do
[[ -f "$ASSET" ]] || { echo "skip missing asset: $ASSET" >&2; continue; }
NAME=$(basename "$ASSET" | tr ' ' '.')
STAGED=$(mktemp "${TMPDIR:-/tmp}/upload.XXXXXX.${NAME##*.}")
cp "$ASSET" "$STAGED"
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 600 "${AUTH[@]}" \
-F "attachment=@$STAGED" \
"$GITEA/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME")
rm -f "$STAGED"
echo "upload $NAME -> HTTP $CODE"
[[ "$CODE" == 2* ]] || { echo "upload failed: $NAME" >&2; exit 1; }
done
echo "done: https://git.codingplan.site/$REPO/releases/tag/$TAG"
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.14"
version = "0.7.21"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.14"
version = "0.7.21"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+968 -36
View File
File diff suppressed because it is too large. Load diff
+812 -128
View File
File diff suppressed because it is too large. Load diff
+146 -12
View File
@@ -10,6 +10,7 @@ use std::path::PathBuf;
use anyhow::Context;
use indexmap::IndexMap;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models::{Agent, Config, Model, Provider, ProviderType};
@@ -23,6 +24,13 @@ pub fn kimi_switch_data_dir() -> PathBuf {
}
pub fn db_path() -> PathBuf {
// Tests (and power users) can point the store elsewhere; production always
// resolves to the default location under the home directory.
if let Some(p) = std::env::var_os("KIMI_SWITCH_DB_PATH") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
kimi_switch_data_dir().join("kimi-switch.db")
}
@@ -31,6 +39,10 @@ pub fn db_path() -> PathBuf {
/// configuration. Safe to call on every startup — it is a no-op once the new
/// directory exists.
fn migrate_legacy_data_dir() {
// A redirected store (tests) must never touch the real home directory.
if std::env::var_os("KIMI_SWITCH_DB_PATH").is_some() {
return;
}
let new_dir = kimi_switch_data_dir();
if new_dir.exists() {
return;
@@ -69,6 +81,7 @@ pub fn init_db() -> DbResult<Connection> {
provider_type TEXT NOT NULL,
base_url TEXT,
api_key TEXT,
api_key_env TEXT,
env TEXT,
note TEXT,
official_url TEXT,
@@ -106,11 +119,32 @@ pub fn init_db() -> DbResult<Connection> {
[],
)?;
// Usage snapshots taken when sessions are bulk-archived: keep the session's
// token/cost history queryable after its directory (and wire.jsonl) is
// deleted from disk.
conn.execute(
"CREATE TABLE IF NOT EXISTS archived_sessions (
session_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
title TEXT,
archived_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER,
created_at_ms INTEGER,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd REAL NOT NULL DEFAULT 0,
day_stats TEXT NOT NULL DEFAULT '[]'
)",
[],
)?;
// Add icon columns for provider icon picker (introduced in v0.3.1).
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
for stmt in [
"ALTER TABLE providers ADD COLUMN icon TEXT",
"ALTER TABLE providers ADD COLUMN icon_color TEXT",
// `api_key_env` (kimi-code 2.0.0+): the name of the environment
// variable holding the credential, an alternative to `api_key`.
"ALTER TABLE providers ADD COLUMN api_key_env TEXT",
] {
if let Err(e) = conn.execute(stmt, []) {
let msg = e.to_string();
@@ -132,28 +166,29 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut providers = IndexMap::new();
{
let mut stmt = tx.prepare(
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
"SELECT name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
FROM providers WHERE agent = ?1 ORDER BY id",
)?;
let provider_rows = stmt.query_map(params![agent.as_str()], |row| {
let provider_type: String = row.get(1)?;
let env_json: Option<String> = row.get(4)?;
let raw_json: Option<String> = row.get(12)?;
let env_json: Option<String> = row.get(5)?;
let raw_json: Option<String> = row.get(13)?;
Ok(Provider {
name: row.get(0)?,
provider_type: provider_type_for_str(&provider_type),
base_url: row.get(2)?,
api_key: row.get(3)?,
api_key_env: row.get(4)?,
env: env_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(),
note: row.get(5)?,
official_url: row.get(6)?,
managed: row.get::<_, i32>(7)? != 0,
enabled: row.get::<_, i32>(8)? != 0,
active: row.get::<_, i32>(9)? != 0,
icon: row.get(10)?,
icon_color: row.get(11)?,
note: row.get(6)?,
official_url: row.get(7)?,
managed: row.get::<_, i32>(8)? != 0,
enabled: row.get::<_, i32>(9)? != 0,
active: row.get::<_, i32>(10)? != 0,
icon: row.get(11)?,
icon_color: row.get(12)?,
raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
@@ -223,8 +258,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
{
let mut insert_provider = tx.prepare(
"INSERT INTO providers
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
(agent, name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;
for provider in config.providers.values() {
@@ -234,6 +269,7 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
provider.provider_type.as_str(),
provider.base_url,
provider.api_key,
provider.api_key_env,
serde_json::to_string(&provider.env).ok(),
provider.note,
provider.official_url,
@@ -330,6 +366,104 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// Archived-session usage snapshots
// ---------------------------------------------------------------------------
/// Per-model usage totals for one calendar day of an archived session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayModelStat {
/// Raw model key as written to wire.jsonl (`__secondary__` kept verbatim).
pub model: String,
pub requests: u64,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
}
/// One day's per-model usage, stored as JSON in `archived_sessions.day_stats`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayStat {
/// Local calendar day, `YYYY-MM-DD`.
pub day: String,
/// Local midnight of `day`, epoch ms — used as the synthesized record time.
pub day_start_ms: u64,
pub models: Vec<DayModelStat>,
}
/// Usage snapshot of one session, taken at the moment it is archived.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchivedSessionSnapshot {
pub session_id: String,
pub workspace_id: String,
pub title: Option<String>,
pub archived_at_ms: u64,
pub updated_at_ms: Option<u64>,
pub created_at_ms: Option<u64>,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub day_stats: Vec<DayStat>,
}
/// Insert or replace one archived-session snapshot (keyed by session id, which
/// is a UUID and never reused).
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
let conn = init_db()?;
let day_stats = serde_json::to_string(&row.day_stats)?;
conn.execute(
"INSERT OR REPLACE INTO archived_sessions
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
row.session_id,
row.workspace_id,
row.title,
row.archived_at_ms as i64,
row.updated_at_ms.map(|v| v as i64),
row.created_at_ms.map(|v| v as i64),
row.total_tokens as i64,
row.total_cost_usd,
day_stats,
],
)?;
Ok(())
}
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
/// to an empty breakdown instead of failing the whole list.
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
let conn = init_db()?;
let mut stmt = conn.prepare(
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
total_tokens, total_cost_usd, day_stats
FROM archived_sessions",
)?;
let rows = stmt.query_map([], |row| {
let day_stats_json: String = row.get(8)?;
Ok(ArchivedSessionSnapshot {
session_id: row.get(0)?,
workspace_id: row.get(1)?,
title: row.get(2)?,
archived_at_ms: row.get::<_, i64>(3)? as u64,
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
total_tokens: row.get::<_, i64>(6)? as u64,
total_cost_usd: row.get(7)?,
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
fn provider_type_for_str(s: &str) -> ProviderType {
ProviderType::from_kimi_type(s)
}
+237 -2
View File
@@ -153,6 +153,14 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
// kimi-code 2.0.0+: `api_key_env` names the environment variable
// holding the credential; it is mutually exclusive with `api_key`.
// Captured as its own field (not left in raw_other) so export can
// enforce the exclusivity and the UI can edit it.
let api_key_env = table
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -181,6 +189,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
rest.remove("type");
rest.remove("base_url");
rest.remove("api_key");
rest.remove("api_key_env");
rest.remove("managed");
rest.remove("enabled");
rest.remove("env");
@@ -200,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
provider_type,
base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()),
api_key_env: api_key_env.filter(|s| !s.is_empty()),
env,
note: None,
official_url: None,
@@ -236,6 +246,30 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
}
}
/// opencode Console Go (the "Go 套餐" gateway) rejects requests that lack an
/// `x-opencode-session` header (400: required for routing and prompt-cache
/// affinity). When exporting a provider pointing at the Go endpoint, ensure
/// the header exists; an existing user-set value is kept as-is.
fn ensure_opencode_go_session_header(provider: &Provider, pt: &mut Table) {
let is_go = provider
.base_url
.as_deref()
.map(|u| u.trim_end_matches('/').starts_with("https://opencode.ai/zen/go"))
.unwrap_or(false);
if !is_go {
return;
}
let mut headers = pt
.get("custom_headers")
.and_then(|v| v.as_table())
.cloned()
.unwrap_or_default();
headers
.entry("x-opencode-session".to_string())
.or_insert_with(|| TomlValue::String("kimi-switch".to_string()));
pt.insert("custom_headers".to_string(), TomlValue::Table(headers));
}
/// Export Kimi Switch's internal `Config` to a Kimi Code TOML config value.
///
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
@@ -265,11 +299,30 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
pt.insert("base_url".to_string(), TomlValue::String(base_url));
}
// `api_key_env` (kimi-code 2.0.0+) and `api_key` are mutually
// exclusive upstream: emitting both makes the CLI reject the provider
// block, so the env var name alone is written whenever the provider is
// configured that way. A legacy row that still carries the key in
// raw_other (imports before the field existed) is used as the source
// too, so the setting survives the upgrade.
let api_key_env = provider
.api_key_env
.clone()
.or_else(|| {
provider
.raw_other
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty());
// Managed (OAuth) providers persist an empty api_key line so the block
// matches the official CLI's provisioned shape (apiKey: ''). A
// user-set api_key on a managed provider (api_key outranks OAuth in
// the CLI's credential priority) must survive the round-trip.
if provider.managed
if let Some(env_key) = api_key_env {
pt.insert("api_key_env".to_string(), TomlValue::String(env_key));
} else if provider.managed
&& provider.api_key.as_deref().map_or(true, |s| s.is_empty())
{
pt.insert("api_key".to_string(), TomlValue::String("".to_string()));
@@ -304,10 +357,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
});
pt.insert("oauth".to_string(), oauth);
}
// Merge remaining raw fields (oauth and env are handled explicitly above).
// Merge remaining raw fields (oauth, env and api_key_env are handled
// explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth");
extra.remove("env");
extra.remove("api_key_env");
// Strip Kimi-Switch-private field: the remembered per-provider
// default model is stored in raw_other.default_model and must NOT
// leak into the agent's config.toml.
@@ -316,6 +371,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
pt.insert(k, v);
}
}
ensure_opencode_go_session_header(provider, &mut pt);
providers_table.insert(name.clone(), TomlValue::Table(pt));
}
root.insert("providers".to_string(), TomlValue::Table(providers_table));
@@ -537,6 +593,7 @@ api_key = ""
provider_type: ProviderType::Anthropic,
base_url: Some("https://fast.cdks.work".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -591,6 +648,71 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_adds_opencode_go_session_header() {
let make_provider = |base_url: &str, raw_other: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some(base_url.to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let mut providers = IndexMap::new();
providers.insert("p".to_string(), p);
let config = Config {
default_model: None,
providers,
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap().clone();
root.get("providers")
.and_then(|v| v.as_table())
.and_then(|t| t.get("p"))
.and_then(|v| v.as_table().cloned())
.unwrap()
};
// Go endpoint without headers → header injected.
let p = export(make_provider("https://opencode.ai/zen/go/v1", Value::Null));
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
assert_eq!(
headers.get("x-opencode-session").and_then(|v| v.as_str()),
Some("kimi-switch")
);
// Existing user-set value is preserved, not overwritten.
let raw = serde_json::json!({
"custom_headers": { "x-opencode-session": "my-session" }
});
let p = export(make_provider("https://opencode.ai/zen/go/v1", raw));
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
assert_eq!(
headers.get("x-opencode-session").and_then(|v| v.as_str()),
Some("my-session")
);
// PAYG zen endpoint and unrelated providers are left alone.
let p = export(make_provider("https://opencode.ai/zen/v1", Value::Null));
assert!(p.get("custom_headers").is_none());
let p = export(make_provider("https://api.deepseek.com/v1", Value::Null));
assert!(p.get("custom_headers").is_none());
}
#[test]
fn kimi_code_export_syncs_raw_other_top_level_sections() {
// Regression: UI-managed top-level sections ([thinking],
@@ -671,6 +793,7 @@ default_effort = "low"
provider_type: ProviderType::Anthropic,
base_url: Some("https://example.com".to_string()),
api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -779,6 +902,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic,
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,
@@ -799,6 +923,7 @@ max_context_size = 1048576
provider_type: ProviderType::Openai,
base_url: Some("https://b.example.com".to_string()),
api_key: Some("sk-b".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -840,6 +965,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic,
base_url: None,
api_key: Some("sk-x".to_string()),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -885,6 +1011,7 @@ max_context_size = 1048576
provider_type: ProviderType::Kimi,
base_url: Some("https://api.kimi.com/coding/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
@@ -1064,4 +1191,112 @@ enabled = true
"user-removed section must be dropped on export"
);
}
#[test]
fn kimi_code_import_extracts_api_key_env() {
// kimi-code 2.0.0+: `api_key_env` names the environment variable that
// holds the credential. It must become its own Provider field and stop
// living in raw_other, so export can enforce the api_key exclusivity.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
base_url = "https://proxy.example.com/v1"
api_key_env = "MY_PROXY_KEY"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let provider = config.providers.get("p1").unwrap();
assert_eq!(provider.api_key_env.as_deref(), Some("MY_PROXY_KEY"));
assert_eq!(provider.api_key, None);
assert!(
provider.raw_other.get("api_key_env").is_none(),
"api_key_env must not stay in raw_other"
);
let exported = config_to_kimi_code(&config, Some(&value));
let exported_provider = exported
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p1").unwrap()
.as_table().unwrap();
assert_eq!(
exported_provider.get("api_key_env").and_then(|v| v.as_str()),
Some("MY_PROXY_KEY")
);
assert!(
!exported_provider.contains_key("api_key"),
"api_key and api_key_env are mutually exclusive upstream"
);
}
#[test]
fn kimi_code_export_api_key_env_wins_over_stored_api_key() {
// A provider switched to env-var credentials in the UI must export
// ONLY `api_key_env`: writing both keys makes the CLI reject the block.
// A legacy raw_other copy of the key acts as the source when the field
// is empty (rows saved before the field existed).
let make_provider = |api_key: Option<&str>, api_key_env: Option<&str>, raw: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://proxy.example.com/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: api_key_env.map(String::from),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: raw,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let config = Config {
default_model: None,
providers: IndexMap::from([("p".to_string(), p)]),
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
config_to_kimi_code(&config, None)
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p").unwrap()
.as_table()
.unwrap()
.clone()
};
// Both fields set (a DB row saved before the UI cleared the key).
let pt = export(make_provider(Some("sk-stale"), Some("MY_PROXY_KEY"), Value::Null));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("MY_PROXY_KEY"));
assert!(!pt.contains_key("api_key"), "the stored key must be dropped");
// Legacy row: only raw_other carries the key.
let pt = export(make_provider(
Some("sk-stale"),
None,
serde_json::json!({"api_key_env": "LEGACY_KEY"}),
));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("LEGACY_KEY"));
assert!(!pt.contains_key("api_key"));
// No env reference → the stored key is written as before.
let pt = export(make_provider(Some("sk-keep"), None, Value::Null));
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
assert!(!pt.contains_key("api_key_env"));
}
}
+10
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;
@@ -103,10 +104,14 @@ pub fn run() {
commands::list_provider_models,
commands::test_connectivity,
commands::query_provider_usage,
commands::health_check_all,
commands::debug_log,
commands::get_app_setting,
commands::set_app_setting,
commands::check_for_update,
commands::get_kimi_code_version,
commands::get_kimi_code_latest,
commands::get_kimi_code_version_info,
commands::download_update,
commands::open_installer,
commands::open_external_url,
@@ -115,12 +120,17 @@ 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,
dashboard::get_day_detail,
dashboard::list_sessions,
dashboard::archive_session,
dashboard::archive_sessions_before,
dashboard::unarchive_session,
dashboard::delete_session,
dashboard::delete_workspace,
+14
View File
@@ -107,6 +107,12 @@ pub struct Provider {
pub provider_type: ProviderType,
pub base_url: Option<String>,
pub api_key: Option<String>,
/// kimi-code 2.0.0+: name of the process environment variable holding the
/// credential, as an alternative to a stored `api_key` (the two are
/// mutually exclusive upstream — exporting both makes the CLI reject the
/// provider block). Only the variable *name* is stored, never its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default)]
pub env: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +155,7 @@ impl PartialEq for Provider {
&& self.provider_type == other.provider_type
&& self.base_url == other.base_url
&& self.api_key == other.api_key
&& self.api_key_env == other.api_key_env
&& self.env == other.env
&& self.note == other.note
&& self.official_url == other.official_url
@@ -172,6 +179,7 @@ impl std::fmt::Debug for Provider {
.field("provider_type", &self.provider_type)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("api_key_env", &self.api_key_env)
.field("env", &"<redacted>")
.field("managed", &self.managed)
.field("enabled", &self.enabled)
@@ -285,11 +293,17 @@ pub struct UsageConfig {
/// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
/// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端
/// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在
/// 保存时把前端写下的 threshold 静默丢掉。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
}
impl UsageConfig {
pub const TEMPLATE_AUTO: &'static str = "auto";
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
pub const TEMPLATE_SUB2API: &'static str = "sub2api";
}
impl std::fmt::Debug for Config {
+352
View File
@@ -0,0 +1,352 @@
//! 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));
}
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 },
"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["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 → dropped; video modality → flagged.
let img = &obj["moonshotai/kimi-image"];
assert!(img.get("context").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
View File
@@ -659,6 +659,7 @@ fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(),
provider_type: crate::models::ProviderType::Kimi,
base_url: None,
api_key: None,
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
+3
View File
@@ -332,6 +332,9 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
provider_type,
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
// Pi's provider file has no env-var credential reference; the
// field stays empty so a Kimi-Code-only setting never leaks here.
api_key_env: None,
env: IndexMap::new(),
note: None,
official_url: None,
+254
View File
@@ -521,6 +521,159 @@ fn parse_newapi(
}])
}
// ── Sub2API (Wei-Shaw/sub2api) 中转站 ────────────────────────
// GET {base_url}/v1/usage
// 头:Authorization: Bearer <api_key>(就是推理用的 sk- key,无需额外凭据)
// Response(三种 mode):
// - quota_limited:key 设了总额度/速率限制 → quota{limit,used,remaining} +
// rate_limits[{window: 5h|1d|7d, limit, used, remaining, reset_at}]
// - unrestricted + subscription:订阅组 → planName + subscription 的
// daily/weekly/monthly usage/limit(USD)
// - unrestricted + 钱包:planName="钱包余额" → balance / remaining(USD)
//
// 站点指纹(可选探测):GET /api/v1/settings/public 返回 data.affiliate_enabled
// 即为 sub2api 面板。注意 NewAPI 的 /api/user/self、/api/status 在 sub2api 上
// 全部 404 —— 两套中转站协议不互通。
/// 把供应商 base_url 归一成站点根(去掉结尾 `/` 与 `/v1`),网关路由挂在根路径。
fn sub2api_site_root(base_url: &str) -> String {
let mut base = base_url.trim_end_matches('/');
if let Some(stripped) = base.strip_suffix("/v1") {
base = stripped;
}
base.to_string()
}
pub async fn query_sub2api(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<UsageResult, String> {
let url = format!("{}/v1/usage", sub2api_site_root(base_url));
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(parse_sub2api(&body)),
Fetched::Failed(err) => Ok(err),
}
}
/// 速率窗口名 → 本地化 tier 名(前端 planLabel 会翻译;five_hour /
/// weekly_limit 已有映射,daily_limit 新增)。
fn sub2api_window_tier(window: &str) -> &'static str {
match window {
"5h" => "five_hour",
"1d" => "daily_limit",
"7d" => "weekly_limit",
_ => "window",
}
}
fn parse_sub2api(body: &serde_json::Value) -> UsageResult {
let is_valid = body
.get("isValid")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("");
if mode.is_empty() {
return UsageResult::failure(
"Missing 'mode' field in response (not a sub2api gateway?)".to_string(),
);
}
let mut out: Vec<UsageData> = Vec::new();
// key 总额度(quota_limited 模式才有)
if let Some(quota) = body.get("quota") {
if let Some(limit) = parse_f64_field(quota, "limit").filter(|&l| l > 0.0) {
let used = parse_f64_field(quota, "used").unwrap_or(0.0);
let remaining = parse_f64_field(quota, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
..Default::default()
});
}
}
// 速率窗口(5h / 1d / 7d;reset_at 为 RFC3339,窗口过期时缺省)
if let Some(rates) = body.get("rate_limits").and_then(|v| v.as_array()) {
for w in rates {
let window = w.get("window").and_then(|v| v.as_str()).unwrap_or("");
let Some(limit) = parse_f64_field(w, "limit").filter(|&l| l > 0.0) else {
continue;
};
let used = parse_f64_field(w, "used").unwrap_or(0.0);
let remaining = parse_f64_field(w, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some(sub2api_window_tier(window).to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: w
.get("reset_at")
.and_then(|v| v.as_str())
.map(str::to_string),
});
}
}
// unrestricted 订阅组:daily/weekly/monthly 三窗口(usage_usd vs limit_usd)
if mode == "unrestricted" {
if let Some(sub) = body.get("subscription") {
for (prefix, tier) in [
("daily", "daily_limit"),
("weekly", "weekly_limit"),
("monthly", "monthly_limit"),
] {
let Some(usage) = parse_f64_field(sub, &format!("{prefix}_usage_usd")) else {
continue;
};
let Some(limit) = parse_f64_field(sub, &format!("{prefix}_limit_usd"))
.filter(|&l| l > 0.0)
else {
continue;
};
let remaining = limit - usage;
out.push(UsageData {
plan_name: Some(tier.to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(usage),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: None,
});
}
}
}
// 钱包余额(unrestricted 非订阅)或以上全部缺失时的兜底
if out.is_empty() {
if let Some(balance) = parse_f64_field(body, "balance")
.or_else(|| parse_f64_field(body, "remaining"))
{
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(balance),
unit: Some("USD".to_string()),
is_valid: Some(is_valid),
..Default::default()
});
}
}
if out.is_empty() {
UsageResult::failure("No quota/rate-limit/balance fields in response".to_string())
} else {
UsageResult::ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -688,4 +841,105 @@ mod tests {
assert_eq!(data[0].remaining, Some(1.0));
assert_eq!(data[0].used, Some(0.2));
}
#[test]
fn sub2api_site_root_strips_v1_and_slash() {
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1/"), "https://x.com");
}
#[test]
fn sub2api_quota_limited_parses_quota_and_windows() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 50.0, "used": 20.0, "remaining": 30.0, "unit": "USD" },
"remaining": 30.0,
"unit": "USD",
"rate_limits": [
{ "window": "5h", "limit": 10.0, "used": 4.0, "remaining": 6.0,
"window_start": "2026-09-13T00:00:00Z", "reset_at": "2026-09-13T05:00:00Z" },
{ "window": "1d", "limit": 30.0, "used": 10.0, "remaining": 20.0,
"window_start": "2026-09-13T00:00:00Z" }
]
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(30.0));
assert_eq!(data[0].total, Some(50.0));
assert_eq!(data[0].used, Some(20.0));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
assert_eq!(data[1].plan_name.as_deref(), Some("five_hour"));
assert_eq!(data[1].remaining, Some(6.0));
assert_eq!(data[1].resets_at.as_deref(), Some("2026-09-13T05:00:00Z"));
assert_eq!(data[2].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[2].resets_at, None);
}
#[test]
fn sub2api_subscription_windows() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "claude-pro",
"unit": "USD",
"subscription": {
"daily_usage_usd": 1.0, "daily_limit_usd": 10.0,
"weekly_usage_usd": 8.0, "weekly_limit_usd": 30.0,
"monthly_usage_usd": 55.0, "monthly_limit_usd": 60.0
}
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[0].remaining, Some(9.0));
assert_eq!(data[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(data[1].remaining, Some(22.0));
assert_eq!(data[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(data[2].remaining, Some(5.0));
assert_eq!(data[2].is_valid, Some(true));
}
#[test]
fn sub2api_wallet_balance() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "钱包余额",
"remaining": 12.5,
"unit": "USD",
"balance": 12.5
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(12.5));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
}
#[test]
fn sub2api_missing_mode_is_deterministic_failure() {
let body = json!({ "foo": 1 });
let result = parse_sub2api(&body);
assert!(!result.success);
assert!(result.error.unwrap().contains("Missing 'mode'"));
}
#[test]
fn sub2api_exhausted_window_is_invalid() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 10.0, "used": 10.0, "remaining": 0.0, "unit": "USD" }
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data[0].is_valid, Some(false));
}
}
+178 -5
View File
@@ -9,6 +9,9 @@
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//!
//! 例外:Kimi 的加力钱包(`PLAN_BOOSTER_WALLET`)不是百分比窗口而是金额行,
//! `used` / `total` / `remaining` 均为货币金额(分换算而来),`unit` 为币种。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
@@ -16,11 +19,17 @@ use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及
// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
// 未来新增)都只用这四个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
// 此约定做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
/// Kimi quota 模型的月度代码窗口(kimi-code #3787 后 `limit_month_code`)。
const TIER_MONTH_CODE: &str = "month_code";
/// Kimi quota 响应里的加力钱包(boosterWallet)。**不是百分比 tier**:这条是
/// 金额行,前端 src/lib/usage-display.ts 的 planLabel() 负责本地化,
/// UsageFooter 按「金额行」而非配额行渲染(used/total 是钱数,不是配额比例)。
const PLAN_BOOSTER_WALLET: &str = "booster_wallet";
/// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
@@ -70,8 +79,14 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
// GET {base_url}/usages
// 默认 https://api.kimi.com/coding/v1/usages
// global: https://api.kimi.ai/coding/v1/usages
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
// Response(kimi-code #3787 / 0.43.1 起服务端切换为 quota 模型):
// { usages: { limit_5h, limit_7d, limit_month_total, limit_month_code:
// { used_ratio: 0-1, reset_time?: ISO 8601 } },
// boosterWallet: { balanceCents, totalCents, monthlyChargeLimitEnabled,
// monthlyChargeLimitCents, monthlyUsedCents, currency },
// goods_version }
// 旧结构(兼容保留): { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
/// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。
/// 纯函数,便于单测。
@@ -95,8 +110,9 @@ pub async fn query_kimi_coding(
Fetched::Body(body) => {
let tiers = parse_kimi_coding(&body);
if tiers.is_empty() {
// 响应里没有可解析的套餐档位(limits/usage 缺失或字段变了)。
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。
// 响应里没有可解析的套餐档位(quota/usages 与旧 limits/usage
// 均缺失或字段变了)。把原始响应(仅用量数字,无密钥)透出,
// 方便对照接口结构修复。
let preview = serde_json::to_string(&body)
.unwrap_or_else(|_| "<unserializable body>".into());
let trimmed: String = preview.chars().take(400).collect();
@@ -111,6 +127,11 @@ pub async fn query_kimi_coding(
}
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
// 新 quota 模型(kimi-code #3787 / 0.43.1 起服务端下发):优先解析。
if let Some(tiers) = parse_kimi_quota(body) {
return tiers;
}
let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示)
@@ -130,6 +151,67 @@ fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
tiers
}
/// 解析 quota 模型的 `usages` 窗口映射(`limit_5h` / `limit_7d` /
/// `limit_month_total` / `limit_month_code`)+ 加力钱包。窗口与钱包都缺失或
/// 无可解析条目时返回 None,让调用方回退旧结构。
fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
let usages = body.get("usages")?;
let mut tiers = Vec::new();
for (key, tier) in [
("limit_5h", TIER_FIVE_HOUR),
("limit_7d", TIER_WEEKLY_LIMIT),
("limit_month_total", TIER_MONTHLY_LIMIT),
("limit_month_code", TIER_MONTH_CODE),
] {
let Some(entry) = usages.get(key) else { continue };
let Some(ratio) = entry.get("used_ratio").and_then(parse_f64) else {
continue;
};
// used_ratio 规范为 0-1 比率;>1 时视为已是百分数,原样使用。
let used_percent = if ratio <= 1.0 { ratio * 100.0 } else { ratio };
let resets_at = entry.get("reset_time").and_then(extract_reset_time);
tiers.push(percent_tier(tier, used_percent, resets_at));
}
// 加力钱包排在四个窗口之后(钱包是余额,不是滚动窗口)。
if let Some(wallet) = body.get("boosterWallet").and_then(booster_wallet_tier) {
tiers.push(wallet);
}
if tiers.is_empty() { None } else { Some(tiers) }
}
/// 解析加力钱包(boosterWallet)为金额行。字段单位为「分」,统一换算成元/美元。
///
/// 只在响应含新版 `*Cents` 字段时产出条目:kimi-code #3787 之前的
/// `boosterWallet` 只有 `balance`(无币种/精度语义),产出全 0 的伪行只会误导。
fn booster_wallet_tier(wallet: &serde_json::Value) -> Option<UsageData> {
let cents = |key: &str| wallet.get(key).and_then(parse_f64);
let (balance, total, used) = (
cents("balanceCents"),
cents("totalCents"),
cents("monthlyUsedCents"),
);
if balance.is_none() && total.is_none() && used.is_none() {
return None;
}
// 币种缺失时不猜(前端 formatAmount 会退化为纯数字展示)。
let unit = wallet
.get("currency")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(UsageData {
plan_name: Some(PLAN_BOOSTER_WALLET.to_string()),
remaining: balance.map(|c| c / 100.0),
total: total.map(|c| c / 100.0),
used: used.map(|c| c / 100.0),
unit,
is_valid: Some(true),
// 钱包没有窗口重置时间。
resets_at: None,
})
}
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
@@ -459,6 +541,97 @@ mod tests {
);
}
#[test]
fn kimi_coding_quota_model_four_windows() {
// kimi-code #3787 / 0.43.1 起服务端下发的 quota 模型:used_ratio 为
// 0-1 比率,reset_time 为 ISO 8601 字符串。
let body = json!({
"goods_version": 3,
"usages": {
"limit_5h": { "used_ratio": 0.6, "reset_time": "2026-09-15T17:00:00Z" },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1, "reset_time": "2026-10-01T00:00:00Z" },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": { "balance": 0 }
});
let tiers = parse_kimi_coding(&body);
// 旧版 boosterWallet(只有 balance,无 *Cents)不产出钱包行。
assert_eq!(tiers.len(), 4);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
assert_eq!(tiers[0].remaining, Some(40.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(
tiers[0].resets_at.as_deref(),
Some("2026-09-15T17:00:00Z")
);
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(25.0));
assert!(tiers[1].resets_at.is_none());
assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(tiers[2].used, Some(10.0));
assert_eq!(tiers[3].plan_name.as_deref(), Some("month_code"));
assert_eq!(tiers[3].used, Some(88.0));
assert_eq!(tiers[3].remaining, Some(12.0));
}
#[test]
fn kimi_coding_quota_appends_booster_wallet_row() {
// 加力钱包是金额行(分 → 元/美元),追加在四个窗口之后。
let body = json!({
"usages": {
"limit_5h": { "used_ratio": 0.6 },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1 },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": {
"balanceCents": 1234,
"totalCents": 5000,
"monthlyChargeLimitEnabled": true,
"monthlyChargeLimitCents": 2000,
"monthlyUsedCents": 800,
"currency": "USD"
}
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 5);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
let wallet = &tiers[4];
assert_eq!(wallet.plan_name.as_deref(), Some("booster_wallet"));
assert_eq!(wallet.used, Some(8.0));
assert_eq!(wallet.total, Some(50.0));
assert_eq!(wallet.remaining, Some(12.34));
assert_eq!(wallet.unit.as_deref(), Some("USD"));
assert_eq!(wallet.is_valid, Some(true));
assert!(wallet.resets_at.is_none());
}
#[test]
fn kimi_coding_quota_ratio_above_one_is_percent() {
// 防御:>1 的 used_ratio 视为已是百分数,不再乘 100。
let body = json!({
"usages": { "limit_5h": { "used_ratio": 42.0 } }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].used, Some(42.0));
}
#[test]
fn kimi_coding_quota_missing_windows_falls_back_to_legacy() {
// usages 存在但没有任何可解析条目 → 回退旧结构解析。
let body = json!({
"usages": { "limit_5h": {} },
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[0].used, Some(10.0));
}
#[test]
fn kimi_coding_reset_time_seconds_vs_millis() {
// 秒级时间戳自动 ×1000
+25 -8
View File
@@ -10,6 +10,7 @@
pub mod balance;
pub mod coding_plan;
pub mod probe;
pub mod usage_types;
pub use usage_types::{UsageData, UsageResult};
@@ -23,6 +24,7 @@ pub enum UsageKind {
BalanceNovita,
BalanceKimi,
BalanceNewapi,
BalanceSub2Api,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
@@ -40,6 +42,7 @@ impl UsageKind {
UsageKind::BalanceNovita => "balance:novita",
UsageKind::BalanceKimi => "balance:kimi",
UsageKind::BalanceNewapi => "balance:newapi",
UsageKind::BalanceSub2Api => "balance:sub2api",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
@@ -47,7 +50,7 @@ impl UsageKind {
}
}
pub const ALL: [UsageKind; 11] = [
pub const ALL: [UsageKind; 12] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
@@ -55,6 +58,7 @@ impl UsageKind {
UsageKind::BalanceNovita,
UsageKind::BalanceKimi,
UsageKind::BalanceNewapi,
UsageKind::BalanceSub2Api,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
@@ -74,6 +78,7 @@ impl std::str::FromStr for UsageKind {
"balance:novita" => UsageKind::BalanceNovita,
"balance:kimi" => UsageKind::BalanceKimi,
"balance:newapi" => UsageKind::BalanceNewapi,
"balance:sub2api" => UsageKind::BalanceSub2Api,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
@@ -97,10 +102,13 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
// codingplan.site 家族:主域(含任意子域除 ai. 外)是 Sub2API 面板
// (/api/v1/settings/public 指纹命中,NewAPI 端点全 404);
// ai.codingplan.site 才是 NewAPI 实例(/api/status、/api/user/self 可用)。
if url.contains("ai.codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
} else if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceSub2Api);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
@@ -186,6 +194,14 @@ pub async fn query_kind(
.unwrap_or(base_url);
balance::query_newapi(url, token, uid, timeout).await
}
UsageKind::BalanceSub2Api => {
// sub2api 复用推理 API Key,无需额外凭据;base_url 同样允许面板覆盖。
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(base_url);
balance::query_sub2api(url, api_key, timeout).await
}
UsageKind::PlanKimiCoding => {
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
}
@@ -279,15 +295,15 @@ mod tests {
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
// codingplan.site 家族分家:主域是 Sub2API 面板(推理 key 直查 /v1/usage),
// ai. 子域才是 NewAPI 实例。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
@@ -321,6 +337,7 @@ mod tests {
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
threshold: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
+155
View File
@@ -0,0 +1,155 @@
//! 供应商推理端点探活(「一键体检」的第二步)。
//!
//! GET `{base_url}/v1/models`,带 `Authorization: Bearer <api_key>`。
//! 2xx 且响应体是合法 JSON 即算通过(不校验内容结构,只验证端点能应答);
//! 其余状态码 / 网络错误归类成确定性错误文案。
//!
//! 错误文案与 balance.rs 一致用英文短句,前端 localizeHealthError 负责
//! 双语展示(见 src/hooks/useHealthCheck.ts)。
use std::time::Duration;
/// 探活请求超时。体检要快速出结果、不卡住 UI,比账单查询的 8s 更短。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// 由 base_url 拼出 `/v1/models` 探测地址。
///
/// 供应商配置里的 base_url 多数已经带 `/v1` 后缀(如
/// `https://api.kimi.com/coding/v1`),直接再拼 `/v1/models` 会得到
/// `/v1/v1/models`,所以先剥掉重复后缀;结尾斜杠与首尾空白一并归一。
/// 空/纯空白返回 None(调用方直接判失败,不发请求)。纯函数,便于单测。
fn models_url(base_url: &str) -> Option<String> {
let base = base_url.trim().trim_end_matches('/');
if base.is_empty() {
return None;
}
let root = base.strip_suffix("/v1").unwrap_or(base);
Some(format!("{root}/v1/models"))
}
/// 探活前置校验:返回最终请求 URL。base_url 缺失、api_key 为空都是确定性
/// 配置错误,不发请求即失败(没有 key 时必然 401,直接给出更可操作的结论)。
fn validate_probe_input(base_url: &str, api_key: &str) -> Result<String, String> {
let Some(url) = models_url(base_url) else {
return Err("missing base_url".to_string());
};
if api_key.trim().is_empty() {
return Err("no API key configured".to_string());
}
Ok(url)
}
/// 探活一个供应商的推理端点。`Ok(())` = 端点正常应答且返回合法 JSON。
pub async fn probe_provider(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<(), String> {
let url = validate_probe_input(base_url, api_key)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let resp = client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"request timed out".to_string()
} else if e.is_connect() {
"connection failed (DNS or refused)".to_string()
} else {
format!("network error: {e}")
}
})?;
let status = resp.status();
if status.is_success() {
// 先取完整响应体再解析:读体超时/中断是瞬时错误,与「返回的不是
// JSON」区分开(reqwest 的 .json() 会把两者都包成 decode)。
let raw = resp
.bytes()
.await
.map_err(|e| format!("failed to read response: {e}"))?;
return match serde_json::from_slice::<serde_json::Value>(&raw) {
Ok(_) => Ok(()),
Err(_) => Err("response is not valid JSON".to_string()),
};
}
match status.as_u16() {
401 | 403 => Err(format!("Authentication failed (HTTP {status})")),
404 => Err(format!("endpoint not found (HTTP {status})")),
code => Err(format!("API error (HTTP {code})")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn models_url_dedupes_v1_suffix() {
assert_eq!(
models_url("https://api.kimi.com/coding/v1").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
// 不带 /v1 的裸域补上 /v1
assert_eq!(
models_url("https://api.deepseek.com").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
}
#[test]
fn models_url_normalizes_slashes_and_whitespace() {
// 结尾斜杠(含多个)与首尾空白都要归一,且不得拼出 /v1/v1
assert_eq!(
models_url("https://api.kimi.com/coding/v1/").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
assert_eq!(
models_url(" https://api.deepseek.com/v1/// ").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
assert_eq!(
models_url("https://api.deepseek.com/").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
// 非 /v1 的路径后缀原样保留(只有 /v1 需要去重)
assert_eq!(
models_url("https://api.novita.ai/v3").as_deref(),
Some("https://api.novita.ai/v3/v1/models")
);
}
#[test]
fn models_url_rejects_empty_base() {
assert_eq!(models_url(""), None);
assert_eq!(models_url(" "), None);
assert_eq!(models_url("/"), None);
}
#[test]
fn probe_input_validation_rejects_empty_base_and_missing_key() {
// 两者都是确定性配置错误,不发请求即返回。
assert_eq!(
validate_probe_input(" ", "sk-x").unwrap_err(),
"missing base_url"
);
assert_eq!(
validate_probe_input("https://example.com", " ").unwrap_err(),
"no API key configured"
);
assert_eq!(
validate_probe_input("https://example.com/v1", "sk-x").unwrap(),
"https://example.com/v1/models"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.14",
"version": "0.7.21",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+27
View File
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
update({ background: { ...settings.background, ...patch } });
};
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
update({ watch: { ...settings.watch, ...patch } });
};
const setRules = (rules: PermissionRule[]) => {
update({ permission: { rules } });
};
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
value={settings.loop_control?.reserved_context_size ?? 50000}
onChange={(v) => updateLoopControl({ reserved_context_size: v })}
/>
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
upstream default is 5; an empty field or 0 removes the key instead
of writing a literal 0 (see setAgentSettings). */}
<NumberField
label={t("compactionMaxAttempts")}
value={settings.loop_control?.compaction_max_attempts ?? 5}
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
/>
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
</Card>
<Card title={t("backgroundSettings")}>
@@ -156,6 +169,20 @@ 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. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+113 -16
View File
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import { Segmented } from "./ui/controls";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
@@ -127,6 +128,19 @@ export function ProviderEdit({
// Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input.
const preset = findPresetForProvider(provider);
/**
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
* variable instead of storing the key in config.toml; the two are mutually
* exclusive upstream, so the UI is a mode switch and each mode clears the
* other field. Derived from the provider itself (no local state) so the
* switch follows provider switches and reloads; an empty-but-present value
* ("env mode picked, name not typed yet") still reads as env mode.
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
* equivalent, so the switch and the env input are both gated on the agent
* (a Pi provider that somehow carries the key keeps its plain key input).
*/
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
@@ -322,27 +336,73 @@ export function ProviderEdit({
{!provider.managed && (
<div>
{apiKeyEnvSupported && (
<div className="flex items-center gap-3 mb-2">
<span className="text-sm text-content-muted">
{t("apiKeySource")}
</span>
<Segmented
options={[
{ key: "direct", label: t("apiKeySourceDirect") },
{ key: "env", label: t("apiKeySourceEnv") },
]}
value={apiKeyEnvMode ? "env" : "direct"}
onChange={(mode) => {
// Mutual exclusion: picking a source clears the
// other one so export never writes both keys.
if (mode === "env") {
onChange({
...provider,
api_key: null,
api_key_env: provider.api_key_env ?? "",
});
} else {
onChange({ ...provider, api_key_env: null });
}
}}
/>
</div>
)}
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span>
{apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
<span className="text-red-500">*</span>
</label>
<div className="relative">
{apiKeyEnvMode ? (
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key_env ?? ""}
placeholder="KIMI_API_KEY"
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
onChange({ ...provider, api_key_env: e.target.value })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
) : (
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
)}
{apiKeyEnvMode && (
<p className="mt-1.5 text-xs text-content-muted">
{t("apiKeyEnvHint")}
</p>
)}
{(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null;
@@ -527,6 +587,18 @@ function ModelMapping({
});
};
const selectAllModels = () => {
if (!discovered) return;
setSelected(new Set(discovered.map((m) => m.id)));
};
const deselectAllModels = () => setSelected(new Set());
const invertSelection = () => {
if (!discovered) return;
setSelected((prev) => new Set(discovered.filter((m) => !prev.has(m.id)).map((m) => m.id)));
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
@@ -596,8 +668,33 @@ function ModelMapping({
{discovered && (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={selectAllModels}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("selectAll")}
</button>
<button
type="button"
onClick={deselectAllModels}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("deselectAll")}
</button>
<button
type="button"
onClick={invertSelection}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("invertSelection")}
</button>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
<input
+133 -23
View File
@@ -1,10 +1,26 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
import {
Pencil,
Copy,
Activity,
Loader2,
Trash2,
BarChart3,
Stethoscope,
} from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery";
import {
useHealthCheck,
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type HealthResult,
} from "../hooks/useHealthCheck";
import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult {
@@ -40,6 +56,12 @@ interface ProviderCardProps {
models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined;
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
health: HealthResult | undefined;
/** 体检请求进行中。 */
healthRunning: boolean;
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
healthRan: boolean;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onDuplicate: (name: string) => void;
@@ -54,6 +76,9 @@ function ProviderCard({
defaultModel,
models,
ts,
health,
healthRunning,
healthRan,
onEdit,
onDelete,
onDuplicate,
@@ -70,7 +95,8 @@ function ProviderCard({
provider.name,
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false
provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
@@ -80,6 +106,17 @@ function ProviderCard({
: null;
const isActive = provider.active === true;
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
const healthTip = health
? [
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
health.usage ? usageLine(health.usage, t) : null,
]
.filter((s): s is string => s !== null)
.join(" · ")
: "";
return (
<div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
@@ -102,6 +139,34 @@ function ProviderCard({
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
{health ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
health.verdict
)}`}
title={healthTip}
aria-label={healthTip}
/>
) : healthRunning ? (
<span
className="shrink-0 flex items-center"
title={t("healthCheckRunning")}
aria-label={t("healthCheckRunning")}
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
</span>
) : healthRan ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
"neutral"
)}`}
title={t("healthNotChecked")}
aria-label={t("healthNotChecked")}
/>
) : null}
{isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")}
@@ -273,6 +338,7 @@ export function ProviderList({
}: ProviderListProps) {
const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({});
const health = useHealthCheck(agent);
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort(
@@ -312,28 +378,69 @@ export function ProviderList({
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<h2 className="font-medium text-content-primary shrink-0">
{t("providers")}
</h2>
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
{(health.running || health.ran || health.error) && (
<span
className={`text-xs tabular-nums truncate ${
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
}`}
title={health.error ? localizeHealthError(health.error, t) : undefined}
>
{health.error
? t("healthRequestFailed", {
error: localizeHealthError(health.error, t),
})
: health.running
? t("healthCheckRunning")
: t("healthSummary", {
ok: health.okCount,
fail: health.failCount,
})}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={health.run}
disabled={health.running}
title={t("healthCheckHint")}
aria-label={t("healthCheck")}
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{health.running ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Stethoscope className="w-4 h-4" />
)}
{t("healthCheck")}
</button>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
@@ -371,6 +478,9 @@ export function ProviderList({
defaultModel={defaultModel}
models={models}
ts={testState[provider.name]}
health={health.results[provider.name]}
healthRunning={health.running}
healthRan={health.ran}
onEdit={onEdit}
onDelete={onDelete}
onDuplicate={onDuplicate}
+120
View File
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
@@ -30,6 +38,11 @@ export function SettingsModal({
}: SettingsModalProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
@@ -158,6 +171,47 @@ export function SettingsModal({
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -362,6 +416,72 @@ export function SettingsModal({
</div>
</section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
+126 -65
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. */
@@ -43,21 +52,11 @@ const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
persistence_minidb_readmodel: {
name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc",
},
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
@@ -165,13 +164,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) : [];
@@ -185,21 +236,6 @@ export function SubagentSettingsPage({
// off — upgrading with force would create an invalid force+pool-table combo.
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
const masterOn = isMasterEnvOn(env);
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag. Mirrors the kimi-code
* 0.40.1 priority: single-flag env > explicit [experimental] value >
* master env (force-on only) > upstream default. The master env no
* longer locks the toggle — only the flag's own env var does. */
const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: isExperimentalFlagSet(rawOther, secondaryFlag.id)
? flags["secondary-model"] === true
: masterOn
? true
: (secondaryFlag.defaultEnabled ?? false);
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
const aliasList = Object.keys(models).sort();
@@ -423,6 +459,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 ? (
@@ -573,41 +669,6 @@ export function SubagentSettingsPage({
)}
<div className="border-t border-border" />
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-content-primary">
{t(FLAG_LABELS[secondaryFlag.id].name)}
{!secondaryLocked &&
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
secondaryFlag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code>
</div>
</div>
{secondaryLocked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={secondaryEnabled}
disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) =>
onChange(
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
})
)
}
/>
</div>
{effectiveDefault !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
@@ -636,9 +697,9 @@ export function SubagentSettingsPage({
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Other experimental features (not subagent-related) */}
<Card title={t("otherExperimentalFlags")}>
{otherFlags.map(renderFlagRow)}
{/* Experimental feature flags — one unified list */}
<Card title={t("experimentalFlags")}>
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
+49 -6
View File
@@ -30,12 +30,13 @@ interface UsageConfigModalProps {
}
const TEMPLATES: ReadonlyArray<{
id: "auto" | "newapi";
id: "auto" | "newapi" | "sub2api";
labelKey: TranslationKey;
hintKey: TranslationKey;
}> = [
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
{ id: "sub2api", labelKey: "usageTemplateSub2api", hintKey: "usageSub2apiHint" },
];
function defaultConfig(provider: Provider): UsageConfig {
@@ -225,7 +226,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
<p className="text-xs text-content-muted">
{cfg.templateType === "newapi"
? t("usageNewapiHint")
: t("usageAutoDetectHint")}
: cfg.templateType === "sub2api"
? t("usageSub2apiHint")
: t("usageAutoDetectHint")}
</p>
</>
)}
@@ -237,9 +240,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
<span className="text-blue-500">{"{{baseUrl}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{cfg.templateType === "newapi"
? cfg.baseUrl || provider.base_url || "-"
: provider.base_url || "-"}
{cfg.templateType === "auto"
? provider.base_url || "-"
: cfg.baseUrl || provider.base_url || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono">
@@ -344,9 +347,24 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
</div>
</div>
)}
{/* sub2api: no extra credentials (reuses the inference key), optional gateway override */}
{!isManaged && cfg.templateType === "sub2api" && (
<div className="space-y-3">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.baseUrl ?? ""}
placeholder={provider.base_url ?? "https://your-sub2api-site.com"}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
/>
</div>
</div>
)}
</div>
{/* timeout + auto query interval */}
{/* timeout + auto query interval + alert threshold */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
@@ -376,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
/>
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
<input
type="number"
min={0}
max={100}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.threshold ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
// JSON 里省掉,不留冗余字段。
if (raw === "") {
setCfg({ ...cfg, threshold: undefined });
return;
}
const n = parseInt(raw, 10);
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
if (Number.isNaN(n)) return;
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
</div>
</div>
</>
)}
+38 -11
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import {
BOOSTER_WALLET_PLAN,
formatAmount,
localizeUsageError,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps {
@@ -14,6 +20,20 @@ interface UsageFooterProps {
variant?: "detail" | "compact";
}
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation();
const { status, data, error, updatedAt, supported, refresh } = usage;
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
// entries carry only remaining + unit — their plan_name is a currency /
// brand label and must render as an amount, not a percentage.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const isPlan = isPlanRow(d);
if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
}
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
{data.map((d, i) => {
// Same plan-vs-balance rule as compactSummary: balance rows have no
// quota fields (total/used), so they render as an amount.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const isPlan = isPlanRow(d);
const pct = isPlan ? percentOf(d) : null;
const color =
pct == null
// 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
// 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
const color = rowAlert
? "text-red-500 dark:text-red-400"
: pct == null
? "text-content-muted"
: pct < 70
? "text-green-600 dark:text-green-400"
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
>
{main}
</span>
{rowAlert && (
<span
className="shrink-0 text-red-500 dark:text-red-400"
title={t("usageThresholdAlert")}
aria-label={t("usageThresholdAlert")}
>
⚠
</span>
)}
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}
+25 -6
View File
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { DailyDetailModal } from "./DailyDetailModal";
import { Heatmap } from "./Heatmap";
import { ModelTotalsChart } from "./ModelTotalsChart";
import { TrendLineChart } from "./TrendLineChart";
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
/** Per-request cache hit rate: cached tokens / total input tokens. */
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
@@ -59,13 +60,14 @@ export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily");
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
const daily = data?.stats.daily ?? [];
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
yesterday: t("rangeYesterday"),
"7d": t("range7d"),
"30d": t("range30d"),
all: t("rangeAll"),
@@ -192,7 +194,7 @@ export function DashboardPage() {
</div>
{/* Range overview cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{RANGES.map((r) => {
const tot = rangeTotals[r];
if (!tot) return null;
@@ -272,13 +274,26 @@ export function DashboardPage() {
>
{t("tabProviderTrend")}
</button>
<button
type="button"
onClick={() => setTrendTab("totals")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "totals"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabModelTotals")}
</button>
</div>
<span className="pb-2 text-xs text-content-muted">
{trendTab === "daily"
? t("trendLineSub", { n: trendDaily.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
: trendTab === "totals"
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
</span>
</div>
{/* Active tab content */}
@@ -287,6 +302,10 @@ export function DashboardPage() {
<div className="flex-1 min-h-[240px]">
<TrendLineChart daily={trendDaily} />
</div>
) : trendTab === "totals" ? (
<div className="flex-1 min-h-[240px]">
<ModelTotalsChart rows={data.stats.modelsByName} />
</div>
) : (
<div className="flex-1 min-h-[240px]">
<DailyBars
@@ -0,0 +1,73 @@
import { useTranslation } from "../../i18n";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
import { modelColor } from "./DailyBars";
import type { ModelRow } from "../../types/dashboard";
interface ModelTotalsChartProps {
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
rows: ModelRow[];
}
/** Horizontal ranked bars: one row per bare model name, providers merged. */
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
const { t } = useTranslation();
const { money } = useCurrency();
if (!rows.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
}
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
return (
<div className="flex max-h-[360px] flex-col gap-1 overflow-y-auto">
{rows.map((m, i) => {
const color = i < 10 ? modelColor(i) : "#6b7280";
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
const tooltip = [
name,
`${t("kpiRequests")}: ${fmtInt(m.requests)}`,
`${t("totalTokens")}: ${fmtInt(m.totalTokens)}`,
`${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`,
`${t("cost")}: ${money(m.costUsd)}`,
].join("\n");
return (
<div
key={m.model}
title={tooltip}
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
>
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: color }}
/>
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
{name}
{m.costEstimated && (
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
)}
</span>
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
<div
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
style={{
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
{fmtTokens(m.totalTokens)}
</span>
</div>
);
})}
</div>
);
}
+189 -2
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n";
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
function fmtBytes(n: number): string {
const v = Number(n) || 0;
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
return `${row.workspaceId}/${row.id}`;
}
type ArchiveAllMode = "month" | "half" | "week" | "custom";
type ArchiveAllState = {
mode: ArchiveAllMode;
/** YYYY-MM-DD, used when mode is "custom". */
date: string;
busy: boolean;
result: BulkArchiveResult | null;
error: string | null;
};
function localDateInput(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
/** Today minus one month — the default value of the custom-date picker. */
function defaultCustomDate(): string {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return localDateInput(d);
}
/** Cutoff epoch ms for the selected option (custom dates start at local midnight). */
function cutoffFor(state: ArchiveAllState): number {
const now = new Date();
if (state.mode === "month" || state.mode === "half" || state.mode === "week") {
const d = new Date(now);
if (state.mode === "month") d.setMonth(d.getMonth() - 1);
if (state.mode === "half") d.setDate(d.getDate() - 15);
if (state.mode === "week") d.setDate(d.getDate() - 7);
return d.getTime();
}
const [y, m, day] = state.date.split("-").map(Number);
return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime();
}
type ConfirmState =
| { mode: "one"; row: SessionRow }
| { mode: "bulk"; rows: SessionRow[] }
@@ -52,6 +90,7 @@ export function SessionsPage() {
refresh,
archiveSession,
unarchiveSession,
archiveBefore,
deleteSession,
deleteWorkspace,
getPreview,
@@ -62,6 +101,7 @@ export function SessionsPage() {
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [preview, setPreview] = useState<PreviewState>(null);
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
const workspaces = data?.workspaces ?? [];
const sessions = data?.sessions ?? [];
@@ -199,6 +239,30 @@ export function SessionsPage() {
0
);
useEffect(() => {
if (!archiveAll) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [archiveAll]);
const runArchiveAll = async () => {
if (!archiveAll || archiveAll.busy) return;
const cutoffMs = cutoffFor(archiveAll);
setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s));
try {
const res = await archiveBefore(cutoffMs);
setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s));
setSelected(new Set());
} catch (e) {
setArchiveAll((s) =>
s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s
);
}
};
return (
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
@@ -376,6 +440,23 @@ export function SessionsPage() {
</button>
</>
)}
{status !== "archived" && (
<button
type="button"
onClick={() =>
setArchiveAll({
mode: "month",
date: defaultCustomDate(),
busy: false,
result: null,
error: null,
})
}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("sessionsArchiveAll")}
</button>
)}
<select
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
@@ -548,6 +629,112 @@ export function SessionsPage() {
{data?.home && <>{data.home}</>}
</div>
{/* Bulk archive dialog */}
{archiveAll && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-content-primary">
{t("archiveAllTitle")}
</h3>
<p className="text-sm text-content-muted">{t("archiveAllDesc")}</p>
<div className="space-y-0.5">
{(
[
["month", t("archiveOptionMonth")],
["half", t("archiveOptionHalfMonth")],
["week", t("archiveOptionWeek")],
["custom", t("archiveOptionCustom")],
] as const
).map(([mode, label]) => (
<label
key={mode}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-content-primary hover:bg-input"
>
<input
type="radio"
name="archiveAllMode"
className="h-4 w-4 accent-blue-600"
checked={archiveAll.mode === mode}
disabled={archiveAll.busy}
onChange={() =>
setArchiveAll((s) =>
s ? { ...s, mode, result: null, error: null } : s
)
}
/>
<span>{label}</span>
</label>
))}
</div>
{archiveAll.mode === "custom" && (
<input
type="date"
value={archiveAll.date}
disabled={archiveAll.busy}
onChange={(e) =>
setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s))
}
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
{t("archiveAllConfirm", {
date: new Date(cutoffFor(archiveAll)).toLocaleDateString(
locale === "zh" ? "zh-CN" : "en-US"
),
})}
</div>
{archiveAll.error && (
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{t("archiveAllFailed")}: {archiveAll.error}
</div>
)}
{archiveAll.result && archiveAll.result.archived === 0 && (
<div className="rounded-md border border-border bg-input px-3 py-2 text-sm text-content-muted">
{t("archiveAllEmpty")}
</div>
)}
{archiveAll.result && archiveAll.result.archived > 0 && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-900/20 px-3 py-2 text-sm text-emerald-300">
{t("archiveAllDone", {
archived: archiveAll.result.archived,
skipped: archiveAll.result.skipped,
})}
</div>
)}
{archiveAll.result && archiveAll.result.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-xs text-red-300">
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
<div key={i} className="break-all">
{err}
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
<button
type="button"
disabled={archiveAll.busy}
onClick={() => setArchiveAll(null)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{archiveAll.result ? t("close") : t("cancel")}
</button>
<button
type="button"
disabled={archiveAll.busy}
onClick={runArchiveAll}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{archiveAll.busy ? t("archiveAllRunning") : t("sessionsArchiveAll")}
</button>
</div>
</div>
</div>
)}
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
+2
View File
@@ -15,6 +15,7 @@ export type UsageKind =
| "balance:novita"
| "balance:kimi"
| "balance:newapi"
| "balance:sub2api"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax"
@@ -32,6 +33,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:novita",
"balance:kimi",
"balance:newapi",
"balance:sub2api",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "7d" | "30d" | "all";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
@@ -10,7 +10,7 @@ export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
return stored;
}
} catch {
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type CheckResult,
} from "./useHealthCheck";
/**
* 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。
*/
const t = ((key: string) => `<${key}>`) as unknown as Parameters<
typeof localizeHealthError
>[1];
/** 带插值的假 t:`<key|{a}=1>`,用于断言参数透传。 */
const tWithVars = ((key: string, vars?: Record<string, string | number>) =>
vars
? `<${key}|${Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",")}>`
: `<${key}>`) as unknown as Parameters<typeof localizeHealthError>[1];
describe("localizeHealthError", () => {
it("探活错误按前缀映射到对应文案", () => {
expect(localizeHealthError("missing base_url", t)).toBe(
"<healthErrNoBaseUrl>"
);
expect(localizeHealthError("no API key configured", t)).toBe(
"<healthErrNoKey>"
);
expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe(
"<healthErrAuth>"
);
expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe(
"<healthErrNotFound>"
);
expect(localizeHealthError("request timed out", t)).toBe(
"<healthErrTimeout>"
);
expect(
localizeHealthError("connection failed (DNS or refused)", t)
).toBe("<healthErrUnreachable>");
expect(localizeHealthError("response is not valid JSON", t)).toBe(
"<healthErrBadJson>"
);
});
it("未知错误原样透出(HTTP 状态码等诊断细节)", () => {
expect(localizeHealthError("API error (HTTP 502)", t)).toBe(
"API error (HTTP 502)"
);
});
it("探活 + 账单两段拼接时逐段映射", () => {
expect(
localizeHealthError(
"endpoint not found (HTTP 404); usage query disabled in config panel",
t
)
).toBe("<healthErrNotFound>; <usageErrDisabled>");
});
it("账单链路的报错复用用量错误映射", () => {
// commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。
expect(
localizeHealthError(
"Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)",
t
)
).toBe("<healthErrAuth>; <usageInvalidKey>");
});
});
describe("probeLine", () => {
it("通过时带延迟", () => {
expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe(
"<healthProbeOk|latency=123>"
);
});
it("中性(404)说明端点不支持模型列表,而不是报失败", () => {
expect(
probeLine(
{ state: "neutral", error: "endpoint not found (HTTP 404)" },
0,
tWithVars
)
).toBe("<healthProbeUnsupported>");
});
it("失败时透出本地化后的原因", () => {
expect(
probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars)
).toBe("<healthProbeFail|error=<healthErrTimeout>>");
});
});
describe("usageLine", () => {
it("通过 / 失败 / 兜底三态", () => {
const pass: CheckResult = { state: "pass" };
expect(usageLine(pass, tWithVars)).toBe("<healthUsageOk>");
expect(
usageLine(
{ state: "fail", error: "Authentication failed (HTTP 401)" },
tWithVars
)
).toBe("<healthUsageFail|error=<healthErrAuth>>");
// 账单链路不会产生中性结论,兜底按「未判定」展示
expect(usageLine({ state: "neutral" }, tWithVars)).toBe(
"<healthNotChecked>"
);
});
});
describe("healthDotClass", () => {
it("绿=通过、红=失败、灰=未判定(中性)", () => {
expect(healthDotClass("pass")).toBe("bg-green-500");
expect(healthDotClass("fail")).toBe("bg-red-500");
expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Agent } from "../types";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError } from "../lib/usage-display";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */
export type HealthCheck = "pass" | "fail" | "neutral";
/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */
export interface CheckResult {
state: HealthCheck;
/** 失败 / 中性原因的原始文案;通过为 null。 */
error?: string | null;
}
/** Mirrors the Rust `HealthResult` (camelCase via serde). */
export interface HealthResult {
providerName: string;
/** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */
verdict: HealthCheck;
/** verdict === "pass" 的便捷布尔。 */
ok: boolean;
/** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */
error?: string | null;
/** 账单/用量结论;null/undefined = 未执行。 */
usage?: CheckResult | null;
/** 推理端点探活结论;探活是必做项,恒为 Some。 */
probe?: CheckResult | null;
/** 探活耗时(毫秒)。 */
latencyMs: number;
}
/**
* 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断
* 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。
*/
function localizeHealthPart(error: string, t: TranslateFn): string {
if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl");
if (error.startsWith("no API key configured")) return t("healthErrNoKey");
if (error.startsWith("Authentication failed")) return t("healthErrAuth");
if (error.startsWith("endpoint not found")) return t("healthErrNotFound");
if (error.startsWith("request timed out")) return t("healthErrTimeout");
if (error.startsWith("connection failed")) return t("healthErrUnreachable");
if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson");
// 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。
return localizeUsageError(error, t);
}
/**
* 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; `
* 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。
*/
export function localizeHealthError(error: string, t: TranslateFn): string {
return error
.split("; ")
.map((part) => localizeHealthPart(part, t))
.join("; ");
}
/** 探活一项的 tooltip 文案。 */
export function probeLine(
probe: CheckResult,
latencyMs: number,
t: TranslateFn
): string {
if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs });
// 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。
if (probe.state === "neutral") return t("healthProbeUnsupported");
return t("healthProbeFail", {
error: probe.error ? localizeHealthError(probe.error, t) : "",
});
}
/** 账单一项的 tooltip 文案。 */
export function usageLine(usage: CheckResult, t: TranslateFn): string {
if (usage.state === "pass") return t("healthUsageOk");
if (usage.state === "fail") {
return t("healthUsageFail", {
error: usage.error ? localizeHealthError(usage.error, t) : "",
});
}
// 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。
return t("healthNotChecked");
}
/**
* 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。
* 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。
*/
export function healthDotClass(verdict: HealthCheck): string {
switch (verdict) {
case "pass":
return "bg-green-500";
case "fail":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-500";
}
}
export interface HealthCheckState {
/** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */
running: boolean;
/** 至少跑过一轮(决定是否渲染状态点)。 */
ran: boolean;
/** provider name → 结果。 */
results: Record<string, HealthResult>;
okCount: number;
failCount: number;
/** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */
error: string | null;
run: () => void;
}
/**
* 一键体检:invoke `health_check_all`,把结果按 provider 名索引。
* 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。
*/
export function useHealthCheck(agent: Agent): HealthCheckState {
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const [results, setResults] = useState<Record<string, HealthResult>>({});
const [error, setError] = useState<string | null>(null);
// Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。
const genRef = useRef(0);
useEffect(
() => () => {
genRef.current += 1;
},
[]
);
const run = useCallback(async () => {
const gen = ++genRef.current;
setRunning(true);
setError(null);
// 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。
setResults({});
try {
const list = await invoke<HealthResult[]>("health_check_all", { agent });
if (genRef.current !== gen) return;
const map: Record<string, HealthResult> = {};
for (const r of list) map[r.providerName] = r;
setResults(map);
} catch (e) {
if (genRef.current !== gen) return;
// invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。
setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
} finally {
if (genRef.current === gen) {
setRunning(false);
setRan(true);
}
}
}, [agent]);
const values = Object.values(results);
return {
running,
ran,
results,
// 中性(灰)既不算正常也不算失败,不计入汇总。
okCount: values.filter((r) => r.verdict === "pass").length,
failCount: values.filter((r) => r.verdict === "fail").length,
error,
run: () => void run(),
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isNewerVersion } from "./useKimiCodeVersion";
describe("isNewerVersion", () => {
it("同版本不算有更新", () => {
expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false);
expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false);
});
it("按 major/minor/patch 逐段比较", () => {
expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true);
expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true);
expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true);
expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false);
expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false);
});
it("忽略 v 前缀与预发布后缀", () => {
expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true);
expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false);
});
it("缺位补 0,无法解析时不显示更新", () => {
expect(isNewerVersion("2.1", "2.0.9")).toBe(true);
expect(isNewerVersion("abc", "2.0.1")).toBe(false);
expect(isNewerVersion("2.0.2", "abc")).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */
export type KimiCodeStatus =
| "current"
| "outdated"
| "partial"
| "unknown"
| "not_installed";
export interface KimiCodeVersionInfo {
/** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */
installed: string | null;
/** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */
latest: string | null;
status: KimiCodeStatus;
}
const THROTTLE_KEY = "kimi-switch-last-kc-version-check";
// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能
// 自动拿到新版本,同时避免短时间反复触发后端调用。
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */
function versionTriple(version: string): [number, number, number] | null {
const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0];
const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10));
if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
while (nums.length < 3) nums.push(0);
return [nums[0], nums[1], nums[2]];
}
/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */
export function isNewerVersion(latest: string, installed: string): boolean {
const l = versionTriple(latest);
const i = versionTriple(installed);
if (!l || !i) return false;
for (let k = 0; k < 3; k += 1) {
if (l[k] !== i[k]) return l[k] > i[k];
}
return false;
}
/**
* 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck
* 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。
*/
export function useKimiCodeVersion() {
const [info, setInfo] = useState<KimiCodeVersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<number | null>(() => {
try {
const v = localStorage.getItem(THROTTLE_KEY);
return v ? Number(v) : null;
} catch {
return null;
}
});
const doCheck = useCallback(
async (force: boolean) => {
if (!force) {
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
}
setChecking(true);
try {
const next = await invoke<KimiCodeVersionInfo>("get_kimi_code_version_info");
setInfo(next);
const now = Date.now();
setLastChecked(now);
try {
localStorage.setItem(THROTTLE_KEY, String(now));
} catch {
// ignore
}
} catch {
// 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。
} finally {
setChecking(false);
}
},
[lastChecked]
);
// 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。
const doCheckRef = useRef(doCheck);
useEffect(() => {
doCheckRef.current = doCheck;
}, [doCheck]);
useEffect(() => {
doCheckRef.current(false);
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
const checkNow = useCallback(() => doCheck(true), [doCheck]);
return { info, checking, checkNow, lastChecked };
}
+13
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type {
ActionResponse,
BulkArchiveResult,
PreviewResult,
SessionStatusFilter,
SessionsResult,
@@ -54,6 +55,17 @@ export function useSessions() {
[refresh]
);
const archiveBefore = useCallback(
async (cutoffMs: number) => {
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
cutoffMs,
});
await refresh();
return result;
},
[refresh]
);
const deleteSession = useCallback(
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
await invoke<ActionResponse>("delete_session", {
@@ -100,6 +112,7 @@ export function useSessions() {
refresh,
archiveSession,
unarchiveSession,
archiveBefore,
deleteSession,
deleteWorkspace,
getPreview,
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" };
}
describe("isAlertTier", () => {
it("阈值缺省或 0(关闭)时不告警", () => {
expect(isAlertTier(percentTier(100), undefined)).toBe(false);
expect(isAlertTier(percentTier(100), null)).toBe(false);
expect(isAlertTier(percentTier(100), 0)).toBe(false);
});
it("低于阈值不告警", () => {
expect(isAlertTier(percentTier(79), 80)).toBe(false);
expect(isAlertTier(percentTier(79.9), 80)).toBe(false);
});
it("等于或超过阈值告警", () => {
expect(isAlertTier(percentTier(80), 80)).toBe(true);
expect(isAlertTier(percentTier(80.5), 80)).toBe(true);
expect(isAlertTier(percentTier(100), 80)).toBe(true);
});
it("非 % unit 的行不告警(金额行的 used 是钱数)", () => {
// Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。
expect(
isAlertTier(
{ planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" },
5
)
).toBe(false);
// sub2api 的 USD 配额行同理,不参与百分比预警。
expect(
isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50)
).toBe(false);
});
it("used 缺失时不告警", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
+30 -1
View File
@@ -56,6 +56,23 @@ export interface UsageQueryState {
updatedAt: number | null;
refresh: (force: boolean) => void;
supported: boolean;
/** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */
threshold?: number;
/** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */
/** 所以失败时保留的上次数据不会被误高亮。 */
alert: boolean;
}
/**
* 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的
* used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。
*/
export function isAlertTier(
d: UsageData,
threshold?: number | null
): boolean {
if (threshold == null || threshold <= 0) return false;
return d.unit === "%" && d.used != null && d.used >= threshold;
}
export function useUsageQuery(
@@ -63,7 +80,8 @@ export function useUsageQuery(
providerName: string,
usageKinds?: string[],
autoIntervalMinutes?: number,
disabled?: boolean
disabled?: boolean,
threshold?: number
): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -175,6 +193,15 @@ export function useUsageQuery(
return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery, disabled]);
// 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。
// 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。
const alertThreshold =
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
? threshold
: undefined;
const alert =
status === "success" && data.some((d) => isAlertTier(d, alertThreshold));
return {
status,
data,
@@ -182,5 +209,7 @@ export function useUsageQuery(
updatedAt,
refresh: runQuery,
supported,
threshold: alertThreshold,
alert,
};
}
+86 -13
View File
@@ -43,6 +43,26 @@ export const enTranslations: Record<TranslationKey, string> = {
connectivityOk: "{name} reachable ({latency}ms)",
connectivitySlow: "{name} reachable but slow ({latency}ms)",
connectivityFail: "{name} unreachable: {error}",
// Health check (ProviderList toolbar button + per-row status dot)
healthCheck: "Health check",
healthCheckHint:
"Probe every enabled provider's inference endpoint and billing query in parallel",
healthCheckRunning: "Checking…",
healthSummary: "{ok} OK / {fail} failed",
healthProbeOk: "Endpoint OK ({latency}ms)",
healthProbeUnsupported: "Endpoint exposes no model list (404)",
healthProbeFail: "Endpoint failed: {error}",
healthUsageOk: "Billing OK",
healthUsageFail: "Billing failed: {error}",
healthNotChecked: "Not checked",
healthRequestFailed: "Health check request failed: {error}",
healthErrNoBaseUrl: "Missing base URL",
healthErrNoKey: "No API key configured",
healthErrAuth: "Authentication failed",
healthErrNotFound: "Endpoint not found",
healthErrTimeout: "Connection timed out",
healthErrUnreachable: "Cannot connect (DNS failure or connection refused)",
healthErrBadJson: "Response is not valid JSON",
copiedProvider: "Duplicated as {name}",
// Provider edit
@@ -78,6 +98,12 @@ export const enTranslations: Record<TranslationKey, string> = {
authField: "Auth Field",
apiKey: "API Key",
getApiKeyLink: "Get API Key",
apiKeySource: "Credential source",
apiKeySourceDirect: "Enter key",
apiKeySourceEnv: "Environment variable",
apiKeyEnvName: "Variable name",
apiKeyEnvHint:
"Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.",
show: "Show",
hide: "Hide",
requestUrl: "Request URL",
@@ -142,9 +168,16 @@ export const enTranslations: Record<TranslationKey, string> = {
loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size",
compactionMaxAttempts: "Max compaction attempts",
compactionMaxAttemptsHint:
"Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).",
backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks",
keepAliveOnExit: "Keep alive on exit",
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.",
permissionRules: "Permission Rules",
permissionDecision: "Decision",
permissionPattern: "Pattern",
@@ -190,6 +223,7 @@ export const enTranslations: Record<TranslationKey, string> = {
statusAll: "All",
selectAll: "Select all",
deselectAll: "Deselect all",
invertSelection: "Invert selection",
selectedCount: "{n} selected",
archive: "Archive",
unarchive: "Unarchive",
@@ -224,6 +258,19 @@ export const enTranslations: Record<TranslationKey, string> = {
roleUser: "User",
roleAssistant: "Assistant",
roleSystem: "System",
sessionsArchiveAll: "Archive old sessions",
archiveAllTitle: "Archive old sessions",
archiveAllDesc:
"Archives every active session whose last activity is older than the selected date. Files are kept and usage stats stay intact.",
archiveOptionMonth: "Older than 1 month",
archiveOptionHalfMonth: "Older than 15 days",
archiveOptionWeek: "Older than 1 week",
archiveOptionCustom: "Specific date",
archiveAllConfirm: "Archive every active session last active before {date}. Continue?",
archiveAllRunning: "Archiving…",
archiveAllDone: "Archived {archived} sessions, skipped {skipped}",
archiveAllFailed: "Archive failed",
archiveAllEmpty: "No sessions match",
// Heatmap
noData: "No data",
@@ -269,6 +316,18 @@ export const enTranslations: Record<TranslationKey, string> = {
install: "Install",
updateCheckFailed: "Update check failed",
lastChecked: "Last checked",
// Kimi Code version tracking
kimiCodeSection: "Kimi Code version",
kimiCodeInstalled: "Installed",
kimiCodeLatest: "Latest",
kimiCodeStatusLabel: "Compatibility",
kimiCodeStatusCurrent: "Supported",
kimiCodeStatusOutdated: "Update KimiSwitch recommended",
kimiCodeStatusPartial: "Partially compatible",
kimiCodeStatusUnknown: "Unknown version",
kimiCodeStatusNotInstalled: "Not installed",
kimiCodeCheck: "Check",
kimiCodeOpenReleases: "Open releases",
appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
@@ -288,6 +347,7 @@ export const enTranslations: Record<TranslationKey, string> = {
// Dashboard — ranges / status
rangeToday: "Today",
rangeYesterday: "Yesterday",
range7d: "7d",
range30d: "30d",
rangeAll: "All",
@@ -309,6 +369,7 @@ export const enTranslations: Record<TranslationKey, string> = {
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
tabModelTrend: "Model usage trend",
tabProviderTrend: "Provider–model usage trend",
tabModelTotals: "Model usage totals",
tabDailyTrend: "Daily usage trend",
providerUnknown: "Unknown provider",
cardDailyTrendSub: "{n} days",
@@ -406,8 +467,10 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTemplate: "Query template",
usageTemplateAuto: "Auto-detect",
usageTemplateNewapi: "NewAPI gateway",
usageTemplateSub2api: "Sub2API gateway",
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
usageSub2apiHint: "Query a Sub2API (Wei-Shaw/sub2api) gateway balance / quota (GET /v1/usage). Reuses the inference sk- API key — no extra credentials; the query URL is optionally overridable",
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
usageDetectedKinds: "Detected query kinds",
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
@@ -428,8 +491,14 @@ export const enTranslations: Record<TranslationKey, string> = {
usageSwitchToNewapi: "Switch to NewAPI template",
usageUnsupportedProvider: "This provider is not supported by auto query",
usageTier5h: "5-hour",
usageTierDaily: "Daily",
usageTierWeekly: "Weekly",
usageTierMonthly: "Monthly",
usageTierMonthCode: "Monthly (code)",
usageTierBoosterWallet: "Booster",
usageThreshold: "Alert threshold",
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
usageThresholdAlert: "Threshold reached",
usageErrNoKey: "No API key configured",
usageErrDisabled: "Usage query is disabled in the config panel",
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
@@ -461,34 +530,23 @@ export const enTranslations: Record<TranslationKey, string> = {
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "Advanced Settings",
experimentalFlags: "Experimental Features",
otherExperimentalFlags: "Other Experimental Features",
experimentalFlagsHint:
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
flagSecondaryModel: "Subagent Secondary Model",
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature",
flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
flagTower: "Tower Mode",
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
flagSubagentFork: "Subagent Fork Context",
flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)",
flagWaitFor: "WaitFor Tool",
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
flagRemoteControl: "Remote Control",
flagRemoteControlDesc: "Enable the experimental remote control feature",
flagSearchWorker: "Search Worker",
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
flagNotifyUser: "NotifyUser tool",
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
flagDefaultOn: "On by default",
lockedByEnv: "Locked by env var",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
secondaryModelLabel: "Secondary model",
secondaryModelUnset: "Not configured",
secondaryModelInheritedContext: "Inherited context",
@@ -536,6 +594,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:
+83 -13
View File
@@ -41,6 +41,25 @@ export const zhTranslations = {
connectivityOk: "{name} 连通正常({latency}ms)",
connectivitySlow: "{name} 连通较慢({latency}ms)",
connectivityFail: "{name} 无法连通:{error}",
// 一键体检(ProviderList 工具栏按钮 + 行内状态点)
healthCheck: "一键体检",
healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询",
healthCheckRunning: "体检中…",
healthSummary: "{ok} 正常 / {fail} 失败",
healthProbeOk: "端点正常({latency}ms)",
healthProbeUnsupported: "端点不支持模型列表(404)",
healthProbeFail: "端点失败:{error}",
healthUsageOk: "账单正常",
healthUsageFail: "账单失败:{error}",
healthNotChecked: "未检测",
healthRequestFailed: "体检请求失败:{error}",
healthErrNoBaseUrl: "缺少 base_url",
healthErrNoKey: "未配置 API Key",
healthErrAuth: "鉴权失败",
healthErrNotFound: "端点不存在",
healthErrTimeout: "连接超时",
healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)",
healthErrBadJson: "响应不是合法 JSON",
copiedProvider: "已复制为 {name}",
// Provider edit
@@ -76,6 +95,12 @@ export const zhTranslations = {
authField: "认证字段",
apiKey: "API Key",
getApiKeyLink: "获取 API Key",
apiKeySource: "凭证来源",
apiKeySourceDirect: "直接填写",
apiKeySourceEnv: "环境变量",
apiKeyEnvName: "环境变量名",
apiKeyEnvHint:
"只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。",
show: "显示",
hide: "隐藏",
requestUrl: "请求地址",
@@ -140,9 +165,15 @@ export const zhTranslations = {
loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小",
compactionMaxAttempts: "压缩最大尝试次数",
compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。",
backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数",
keepAliveOnExit: "退出时保持运行",
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
permissionRules: "权限规则",
permissionDecision: "处置",
permissionPattern: "模式",
@@ -187,6 +218,7 @@ export const zhTranslations = {
statusAll: "全部",
selectAll: "全选",
deselectAll: "取消全选",
invertSelection: "反选",
selectedCount: "已选 {n} 项",
archive: "归档",
unarchive: "取消归档",
@@ -219,6 +251,18 @@ export const zhTranslations = {
roleUser: "用户",
roleAssistant: "助手",
roleSystem: "系统",
sessionsArchiveAll: "一键归档",
archiveAllTitle: "一键归档会话",
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
archiveOptionMonth: "1 个月前",
archiveOptionHalfMonth: "半个月前",
archiveOptionWeek: "1 星期前",
archiveOptionCustom: "指定日期",
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
archiveAllRunning: "正在归档…",
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
archiveAllFailed: "归档失败",
archiveAllEmpty: "没有符合条件的会话",
// Heatmap
noData: "暂无数据",
@@ -264,6 +308,18 @@ export const zhTranslations = {
install: "安装",
updateCheckFailed: "检查更新失败",
lastChecked: "上次检查",
// Kimi Code 版本跟踪
kimiCodeSection: "Kimi Code 版本",
kimiCodeInstalled: "已安装版本",
kimiCodeLatest: "最新版本",
kimiCodeStatusLabel: "适配状态",
kimiCodeStatusCurrent: "已适配",
kimiCodeStatusOutdated: "建议升级 KimiSwitch",
kimiCodeStatusPartial: "部分兼容",
kimiCodeStatusUnknown: "未知版本",
kimiCodeStatusNotInstalled: "未安装",
kimiCodeCheck: "检查",
kimiCodeOpenReleases: "打开 Releases",
appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
@@ -283,6 +339,7 @@ export const zhTranslations = {
// Dashboard — ranges / status
rangeToday: "今天",
rangeYesterday: "昨天",
range7d: "7 天",
range30d: "30 天",
rangeAll: "全部",
@@ -304,6 +361,7 @@ export const zhTranslations = {
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
tabModelTrend: "模型用量趋势",
tabProviderTrend: "供应商模型用量趋势",
tabModelTotals: "模型用量统计",
tabDailyTrend: "每日用量趋势",
providerUnknown: "未知供应商",
cardDailyTrendSub: "共 {n} 天",
@@ -401,8 +459,10 @@ export const zhTranslations = {
usageTemplate: "查询模板",
usageTemplateAuto: "自动检测",
usageTemplateNewapi: "NewAPI 中转站",
usageTemplateSub2api: "Sub2API 中转站",
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
usageSub2apiHint: "查询 Sub2API (Wei-Shaw/sub2api) 面板的余额 / 额度(GET /v1/usage),自动复用推理 sk- API Key,无需额外凭据;可选覆盖查询地址",
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
usageDetectedKinds: "检测到的查询类型",
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
@@ -423,8 +483,14 @@ export const zhTranslations = {
usageSwitchToNewapi: "切换到 NewAPI 模板",
usageUnsupportedProvider: "此供应商暂不支持自动查询",
usageTier5h: "5小时",
usageTierDaily: "每日",
usageTierWeekly: "7天",
usageTierMonthly: "30天",
usageTierMonthCode: "30天(代码)",
usageTierBoosterWallet: "加力钱包",
usageThreshold: "预警阈值",
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
usageThresholdAlert: "已达预警阈值",
usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
@@ -456,34 +522,23 @@ export const zhTranslations = {
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "高级设置",
experimentalFlags: "实验功能开关",
otherExperimentalFlags: "其他实验功能",
experimentalFlagsHint:
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
flagSecondaryModel: "子代理次主力模型",
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能",
flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
flagTower: "Tower 模式",
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
flagSubagentFork: "子代理 Fork 上下文",
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
flagWaitFor: "WaitFor 工具",
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagRemoteControl: "远程控制",
flagRemoteControlDesc: "启用实验性远程控制功能",
flagSearchWorker: "搜索 Worker",
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
flagNotifyUser: "NotifyUser 工具",
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
flagDefaultOn: "默认开启",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
secondaryModelLabel: "次主力模型",
secondaryModelUnset: "未配置",
secondaryModelInheritedContext: "继承上下文长度",
@@ -528,6 +583,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:
+96 -2
View File
@@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => {
});
// ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
// upstream default ON; an explicit false must survive even with no rules
// loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop
// spread, written only when set, and 0/empty removes the key (upstream
// default 5 applies)
// ---------------------------------------------------------------------------
describe("loop_control.compaction_max_attempts", () => {
it("reads an existing value through the loop spread", () => {
expect(
getAgentSettings({ loop_control: { compaction_max_attempts: 7 } })
.loop_control?.compaction_max_attempts
).toBe(7);
});
it("reads as undefined when the key is absent (UI falls back to 5)", () => {
expect(
getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts
).toBeUndefined();
});
it("writes the value when the patch sets it", () => {
const next = setAgentSettings({}, {
loop_control: { compaction_max_attempts: 8 },
});
expect(loopOf(next).compaction_max_attempts).toBe(8);
});
it("drops the key for 0 and for a cleared (undefined) field", () => {
const raw = { loop_control: { compaction_max_attempts: 6 } };
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: 0 },
}))).not.toHaveProperty("compaction_max_attempts");
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: undefined },
}))).not.toHaveProperty("compaction_max_attempts");
});
it("does not materialize the upstream default on an unrelated save", () => {
// An absent key must stay absent — a plain save must not pin `5`.
const next = setAgentSettings({ thinking: { effort: "high" } }, {});
expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts");
// An existing value survives a save that does not touch it.
const kept = setAgentSettings(
{ loop_control: { compaction_max_attempts: 4 } },
{ thinking: { effort: "low" } }
);
expect(loopOf(kept).compaction_max_attempts).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
const w = (raw as { watch?: unknown })?.watch;
return w && typeof w === "object" && !Array.isArray(w)
? (w as 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 an explicit value", () => {
expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false);
});
it("writes the top-level [watch] section", () => {
const next = setAgentSettings({}, { watch: { enabled: true } }) as {
watch?: Record<string, unknown>;
};
expect(next.watch).toEqual({ enabled: true });
});
it("keeps the section on saves that do not touch it", () => {
const next = setAgentSettings(
{ watch: { enabled: true } },
{ thinking: { effort: "high" } }
) as { watch?: Record<string, unknown> };
expect(next.watch).toEqual({ enabled: true });
});
it("does not disturb the other managed sections", () => {
const next = setAgentSettings(
{ background: { max_running_tasks: 2 } },
{ watch: { enabled: true } }
) as { background?: Record<string, unknown> };
expect(next.background?.max_running_tasks).toBe(2);
expect(watchOf(next).enabled).toBe(true);
});
});
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
expect(
+17
View File
@@ -13,6 +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: false,
},
permission: { rules: [] },
hooks: [],
};
@@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"),
},
watch: {
...DEFAULT_SETTINGS.watch,
...getSection<AgentSettings["watch"]>(rawOther, "watch"),
},
permission: {
rules: sectionPermission?.rules ?? [],
// `dangerous_command_guard` stays undefined unless the config carries
@@ -111,6 +120,7 @@ export function setAgentSettings(
thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background },
watch: { ...current.watch, ...patch.watch },
permission,
hooks: patch.hooks ?? current.hooks ?? [],
};
@@ -129,9 +139,16 @@ export function setAgentSettings(
if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step;
}
// `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave
// the key out" — the config then falls back to the upstream default (5)
// instead of pinning a literal 0.
if (!next.loop_control?.compaction_max_attempts) {
delete next.loop_control?.compaction_max_attempts;
}
if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background;
if (next.watch) root.watch = next.watch;
// Keep `[permission]` when it carries rules *or* an explicit
// dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules
+103031 -94277
View File
File diff suppressed because it is too large. Load diff
+88654 -81165
View File
File diff suppressed because it is too large. Load diff
+60 -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;
@@ -49,32 +54,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();
+38 -17
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import {
EXPERIMENTAL_FLAGS,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
@@ -163,13 +165,13 @@ describe("setSecondaryModelOnly", () => {
it("preserves other top-level raw_other sections", () => {
const raw = {
experimental: { "secondary-model": true },
experimental: { wait_for: true },
secondary_model: { model: "kimi-k1" },
};
const next = setSecondaryModelOnly(raw, "kimi-k2") as {
experimental?: unknown;
};
expect(next.experimental).toEqual({ "secondary-model": true });
expect(next.experimental).toEqual({ wait_for: true });
});
});
@@ -455,31 +457,50 @@ describe("validateSubagentPool", () => {
});
// ---------------------------------------------------------------------------
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
// [experimental] — flag registry (kimi-code 0.42.0) + write semantics
// ---------------------------------------------------------------------------
describe("experimental flag registry", () => {
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
it("mirrors the 0.43.0 v2 registry (5 flags)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"secondary-model",
"wait_for",
"tool-select",
"persistence_minidb_readmodel",
"notify_user",
"tower",
"subagent_fork",
"wait_for",
]);
// Removed upstream: file_history (0.41.0, turn-level file history is
// always on), auto_session_title (0.43.0, graduated — AI session titles
// are always on, #3749), plus secondary-model /
// persistence_minidb_readmodel / remote-control / search_worker
// (0.42.0 — promoted or moved to dedicated config, the minidb read-model
// now lives under [database]), so the adapter must not mirror them either.
for (const id of [
"file_history",
"auto_session_title",
"secondary-model",
"persistence_minidb_readmodel",
"remote-control",
"search_worker",
]);
// Upstream removed the flag in 0.41.0 (turn-level file history is
// always on), so the adapter must not mirror it either.
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
]) {
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === id)).toBe(false);
}
});
it("secondary-model is on by default since 0.40.1", () => {
expect(
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
).toBe(true);
it("notify_user is opt-in: no upstream default, own env var", () => {
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user");
expect(flag?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_NOTIFY_USER");
expect(flag?.defaultEnabled).toBeUndefined();
});
it("notify_user is locked by its env var and forced to the env value", () => {
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user")!;
expect(isFlagLockedByEnv({}, flag)).toBe(false);
// A set env var locks the toggle even when it is falsy — it can force
// the flag on *or* off.
expect(isFlagLockedByEnv({ [flag.envVar]: "0" }, flag)).toBe(true);
expect(forcedEnvValue({ [flag.envVar]: "0" }, flag)).toBe(false);
expect(forcedEnvValue({ [flag.envVar]: "true" }, flag)).toBe(true);
});
});
@@ -514,13 +535,13 @@ describe("setExperimentalFlag", () => {
it("an explicit false coexists with other flags and keeps the section", () => {
const next = setExperimentalFlag(
rawExp({ "secondary-model": false }),
rawExp({ wait_for: false }),
"tower",
true,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental).toEqual({
"secondary-model": false,
wait_for: false,
tower: true,
});
});
+16 -32
View File
@@ -24,7 +24,7 @@ function isTruthyEnv(value: string | undefined): boolean {
// ---------------------------------------------------------------------------
export interface ExperimentalFlagDef {
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
/** Flag id as stored in config.toml `[experimental]` (e.g. "wait_for"). */
id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string;
@@ -33,43 +33,27 @@ export interface ExperimentalFlagDef {
defaultEnabled?: boolean;
}
/** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title, remote-control,
* search_worker). `acp-v2` was removed upstream and is dropped here;
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
* history is always on, no flag). Keep the env-var list in sync with
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
/** Known experimental flags — mirrors the kimi-code 0.43.0 v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src):
* wait_for, tool-select, notify_user, tower, subagent_fork.
* Removed in 0.43.0: auto_session_title (graduated — AI session titles are
* always on, #3749). Removed in 0.42.0: secondary-model, remote-control
* and search_worker (promoted or dropped upstream — no flag left), and
* persistence_minidb_readmodel (now controlled by the `[database]` config
* section). `acp-v2` was removed upstream earlier; `file_history` was
* removed in kimi-code 0.41.0 (turn-level file history is always on, no
* flag). Keep the env-var list in sync with `get_experimental_env_status`
* in src-tauri/src/commands.rs. */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{
id: "secondary-model",
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
defaultEnabled: true, // on by default since kimi-code 0.40.1
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{
id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
defaultEnabled: true,
},
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{
id: "wait_for",
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
defaultEnabled: true,
},
{
id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
{
id: "search_worker",
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
defaultEnabled: true,
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
+1
View File
@@ -59,6 +59,7 @@ describe("planLabel", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥");
});
+17 -3
View File
@@ -5,24 +5,38 @@ type TranslateFn = (
vars?: Record<string, string | number>
) => string;
/**
* Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的
* PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与
* UsageFooter 的金额行判定共用此常量,避免字面量散落。
*/
export const BOOSTER_WALLET_PLAN = "booster_wallet";
/**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
*
* Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT — so this
* mapping covers every current and future plan provider automatically. If a
* new tier id is ever added there, add a case here too.
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
* TIER_MONTH_CODE — so this mapping covers every current and future plan
* provider automatically. If a new tier id is ever added there, add a case
* here too.
*/
export function planLabel(name: string, t: TranslateFn): string {
switch (name) {
case "five_hour":
return t("usageTier5h");
case "daily_limit":
return t("usageTierDaily");
case "weekly_limit":
return t("usageTierWeekly");
case "monthly_limit":
return t("usageTierMonthly");
case "month_code":
return t("usageTierMonthCode");
case BOOSTER_WALLET_PLAN:
return t("usageTierBoosterWallet");
default:
return name;
}
+4 -1
View File
@@ -50,7 +50,10 @@ export function validateProviders(
const reasons: string[] = [];
const apiKey = provider.api_key?.trim();
if (!apiKey && !provider.managed) {
// kimi-code 2.0.0+: the credential may come from an environment variable
// instead of a stored key — a provider configured that way is complete.
const apiKeyEnv = provider.api_key_env?.trim();
if (!apiKey && !apiKeyEnv && !provider.managed) {
reasons.push(t("validationNoApiKey"));
}
+2
View File
@@ -52,6 +52,8 @@ export interface RangeStats {
totals: TotalsRow;
daily: DailyRow[];
models: ModelRow[];
/** Model totals keyed by bare model name — the same model across providers merges into one row. */
modelsByName: ModelRow[];
recent: RecentRow[];
recentTotal: number;
recentLimit: number;
+24 -2
View File
@@ -15,6 +15,12 @@ export interface Provider {
provider_type: ProviderType;
base_url: string | null;
api_key: string | null;
/**
* Name of the process environment variable holding the credential
* (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the
* export writes only one of the two. Only the variable *name* is stored.
*/
api_key_env?: string | null;
env: Record<string, string>;
/** Optional note / remark for the provider. */
note?: string | null;
@@ -75,11 +81,12 @@ export interface Config {
/**
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
* NewAPI/OneAPI gateway with accessToken + userId.
* NewAPI/OneAPI gateway with accessToken + userId; "sub2api" = query a
* Sub2API (Wei-Shaw/sub2api) gateway, reusing the inference API key.
*/
export interface UsageConfig {
enabled: boolean;
templateType: "auto" | "newapi";
templateType: "auto" | "newapi" | "sub2api";
baseUrl?: string;
/** NewAPI web-console access token (NOT the sk- inference key). */
accessToken?: string;
@@ -89,6 +96,9 @@ export interface UsageConfig {
autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number;
/** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。
* 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */
threshold?: number;
}
/** A model discovered from a provider's API endpoint. */
@@ -124,6 +134,9 @@ export interface LoopControlConfig {
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number;
reserved_context_size?: number;
/** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the
* key is not written and upstream's default (5) applies. */
compaction_max_attempts?: number;
}
export interface BackgroundConfig {
@@ -131,6 +144,13 @@ export interface BackgroundConfig {
keep_alive_on_exit?: boolean;
}
/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in
* 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as
* disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */
export interface WatchConfig {
enabled?: boolean;
}
export interface PermissionRule {
decision?: "allow" | "deny" | "ask";
scope?: string;
@@ -149,6 +169,8 @@ export interface AgentSettings {
thinking?: ThinkingConfig;
loop_control?: LoopControlConfig;
background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
permission?: {
rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is
+6
View File
@@ -39,6 +39,12 @@ export interface ActionResponse {
deleted?: boolean | null;
}
export interface BulkArchiveResult {
archived: number;
skipped: number;
errors: string[];
}
export interface PreviewMessage {
role: string;
time: number | null;