Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e15657af | ||
|
|
11286419b1 | ||
|
|
26e783c2ba | ||
|
|
11f87fd9af | ||
|
|
fcd08eae32 | ||
|
|
35581103ed | ||
|
|
1b80d66239 | ||
|
|
0f421b4e44 |
No files matched your search
@@ -73,7 +73,8 @@ jobs:
|
||||
id: notes
|
||||
shell: bash
|
||||
run: |
|
||||
FILE="release-notes-${{ github.ref_name }}.md"
|
||||
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
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
**Kimi Switch** — Windows 桌面端的 Kimi Code CLI 配置管理器,统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。
|
||||
|
||||
本文件汇总各版本要点,按版本倒序排列;每个版本的**完整 release notes**(含安装说明、平台产物、已知问题)见 [`docs/release-notes/`](./docs/release-notes/)。
|
||||
|
||||
---
|
||||
|
||||
## v0.7.22 (2026-09-24)
|
||||
|
||||
- **修复 Sub2API / NewAPI 模板查询不显示**:为无预设识别类型的自定义供应商配置 Sub2API(或 NewAPI)模板后,弹窗测试可查但供应商列表始终不显示——前端列表的查询/显示门槛只看预设 `usageKinds`,显式模板被漏掉;现在与后端口径对齐(模板查询绕过 usage_kinds),列表正常显示余额 / 配额
|
||||
|
||||
## v0.7.21 (2026-09-24)
|
||||
|
||||
- **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
|
||||
- **修复构建链路数据漂移**:`fetch-models-dev.mjs` 现在同时写入 `src/lib/models-dev.json`(Rust 计价内嵌)与 `public/models-dev.json`(前端静态资源)——此前 public 副本需手工拷贝,已两次滞后于 src/lib
|
||||
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量/quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(tui.toml)不涉及配置管理面
|
||||
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
|
||||
|
||||
## v0.7.20 (2026-09-20)
|
||||
|
||||
- **套餐用量阈值预警**:用量配置新增「预警阈值 %」(0-100,0/空为关闭),任一用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除
|
||||
- **Kimi For Coding 加力钱包**:0.43.1 起解析 `boosterWallet` 并展示为独立余额行(💰 月度用量 / 总额 / 余额,含币种)
|
||||
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762,凭证来源可切换「直接填写 / 环境变量」,密钥不写入 `config.toml`,与 `api_key` 互斥;SQLite 新增列含自动迁移
|
||||
- **Kimi Code 版本跟踪器**:设置页新增版本卡片——本机已装版本、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
|
||||
- **供应商一键体检**:并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重查账单,三态判定避免误报(端点不支持模型列表计中性)
|
||||
- 新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5)与 `[watch] enabled` 开关(上游 2.0.1 #3892);已确认适配 kimi-code 2.0.2
|
||||
|
||||
## v0.7.19 (2026-09-14)
|
||||
|
||||
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title`(上游 #3749),上游注册表现剩 `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
|
||||
- 兼容性核查:0.43.0 的 wire 持久化重建(#3737)不影响用量统计,相关记录全部保留;`loop_control.compaction_max_attempts`(#3750)保存不丢失
|
||||
- 残留的 `auto_session_title` 旧键会被上游静默忽略,无需手动清理
|
||||
|
||||
## v0.7.18 (2026-09-14)
|
||||
|
||||
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期批量归档,替代逐个选择,并反馈已归档 / 跳过数量
|
||||
- **归档用量快照落库**:归档瞬间按「天 × 模型」粒度把 token 用量与费用写入 SQLite `archived_sessions` 表
|
||||
- 删除已归档会话释放磁盘后不再损失历史统计——趋势图、热力图、模型统计、区间合计自动合并快照
|
||||
- 快照与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
|
||||
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),pricing 断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
|
||||
|
||||
## v0.7.17 (2026-09-13)
|
||||
|
||||
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板余额查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,无需网页后台 Access Token
|
||||
- 解析三种模式:单 Key 总额度(quota)、5 小时 / 每日 / 7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
|
||||
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI,修正主域被误判导致查询 404 的问题
|
||||
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
|
||||
- **模型映射「获取模型列表」新增批量选择**:全选 / 取消全选 / 反选,配合已添加标记批量接入中转站模型
|
||||
|
||||
## v0.7.16 (2026-09-10)
|
||||
|
||||
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除 4 个已转正/删除的开关(子代理次主力模型、MiniDB 读模型、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭)
|
||||
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
|
||||
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 静态目录同步
|
||||
|
||||
## v0.7.15 (2026-09-09)
|
||||
|
||||
- **用量统计新增「昨天」选项卡**:时间范围为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点,概览卡扩展为 5 张
|
||||
- **新增「模型用量统计」趋势选项卡**:跨供应商按模型名汇总(同一模型合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用
|
||||
- **修复 opencode Go 套餐子代理请求 400**:Go 网关强制要求 `x-opencode-session` 头,导出时凡指向 Go 端点的供应商自动补写(已有自定义值保留),按量计费的 `zen/v1` 不受影响
|
||||
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商);`kimi/k2.5` 官方条目下架后暂解析到 302ai 转售价(0.66 / 3.3)
|
||||
|
||||
## v0.7.14 (2026-09-06)
|
||||
|
||||
- **修复关闭「启用用量查询」后仍残留查询行为**:此前关闭后前端仍发起查询并持续显示「查询失败 · 用量查询已关闭」红色错误行,现在关闭即停止查询并隐藏整行用量信息,重开后自动恢复
|
||||
- 在途查询结果在开关切换瞬间作废且不再写入共享缓存,避免过期失败状态在 5 分钟内复活
|
||||
|
||||
## v0.7.13 (2026-09-05)
|
||||
|
||||
- **适配 kimi-code 0.41.0**:上游移除 `file_history` flag(轮级文件历史转为恒开),镜像全链路同步删除;Auto(永不询问)模式不再拦截危险命令,设置页文案(中英)同步修正
|
||||
- **账单查询改进**:`codingplan.site` 系供应商自动识别为 NewAPI 余额查询;凭据缺失不再误报「网络异常」而给出本地化配置提示;金额显示统一为两位小数并去掉 `¤` 占位符
|
||||
- 新增 usage 错误本地化单元测试
|
||||
|
||||
## v0.7.12 (2026-09-03)
|
||||
|
||||
- **适配 kimi-code 0.40.1(flag 9→10)**:新增 `file_history` / `search_worker`;`secondary-model` 默认开启并显示徽章;新增危险命令守卫开关 `[permission] dangerous_command_guard`(默认开启)
|
||||
- **flag 优先级语义修正**:单 flag 环境变量 > `[experimental]` 显式配置 > 总开关环境变量 > 默认值,总开关不再锁定全部开关
|
||||
- **修复 WebUI 打开竞态**:并发调用串行化 + 后到点击自动聚焦已有窗口,失败路径不再误杀正在使用的 server,前端增加「打开中...」防重入态
|
||||
- models-dev 快照更新至 7495 模型 / 212 供应商
|
||||
|
||||
## v0.7.11 (2026-08-29)
|
||||
|
||||
- **适配 kimi-code 0.39.1**:`[thinking]` 保留开关改写 `keep = "off"`(修复布尔值导致整个节被 v2 校验丢弃);`loop_control` 默认只写新键 `max_attempts_per_step` 并清理旧键;思考强度移除 `max` 档(上游迁移为 `high`)
|
||||
- **会话归档与上游 v2 对齐**:不再搬动文件,改为在 `state.json` 写入 `archived` / `archivedAt` 元数据,CLI 侧与 Kimi Switch 侧归档状态互通;旧 `.kcd-archive/` 物理归档仍可识别恢复
|
||||
- **修复子代理模型池无法添加第二个条目**:默认模型自动物化进池,不再被校验拦截
|
||||
- models.dev 快照刷新至 7482 个模型 / 207 个供应商
|
||||
|
||||
## v0.7.10 (2026-08-29)
|
||||
|
||||
- **修复保存配置时误删 CLI 新增的顶层配置节**:以导入时顶层键为基线,CLI 后加的 `[task]` / `[swarm]` / `[cron]` / `[tools]` / `[identity]` / `[token_counting]` 等节原样保留,界面中显式删除的节仍会移除
|
||||
- **修复未知供应商类型被改写**:`config.toml` 中新增的供应商 `type` 往返读写后原样保留(此前会被改成 `kimi`),下拉框标注「未知类型」,SQLite 快照恢复路径同步修复
|
||||
- 新增 3 个配置往返回归测试,Rust 测试 106 项全绿,前端 tsc / vitest 通过
|
||||
|
||||
## v0.7.9 (2026-08-27)
|
||||
|
||||
- 模型库刷新至 **7343 个模型 / 203 个供应商**,内置快照与官网静态资源同步更新
|
||||
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare Workers AI、DeepInfra、HuggingFace、火山引擎等约 20 条渠道,GLM-5.3-Flash 定价 $0.075/$0.25(每百万 token)
|
||||
- 新增 Qwen3.8 系列、豆包 Seed 2.x 全系、DeepSeek-V4 GA、MiniMax-M2.7/M3 等共 74 个模型条目;价格更新 58 处、能力信息修正 56 处
|
||||
|
||||
## v0.7.8 (2026-08-25)
|
||||
|
||||
- **修复模型用量上下显示不同步**:上方用量摘要刷新后下方进度条仍停留旧数据(如 24% vs 8%),用量查询状态提升至卡片层统一持有,两处同帧更新
|
||||
- **实验功能开关同步 kimi-code 上游 7 项旗标**:新增 Tower 模式、子代理 Fork 上下文、WaitFor 工具、自动会话标题;移除已废弃的 ACP v2 开关(手写配置仍完整保留)
|
||||
- 自动刷新间隔改为每卡片恰好一份定时器,行为不变
|
||||
|
||||
## v0.7.7 (2026-08-22)
|
||||
|
||||
- **双区域 OAuth 支持(适配 kimi-code 0.38.0)**:适配 mainland-cn `auth.kimi.com` / global `auth.kimi.ai`(#2862),按 provider 的 oauth ref 推导凭据文件与刷新端点,global 账号(`credentials/kimi-code-env-<sha256>.json`)可正常查询用量
|
||||
- **内置登录区域选择**:应用内 Kimi 登录对话框新增「中国大陆 / 国际版 (kimi.ai)」,登录成功后按官方 CLI 行为 provision `[providers."managed:kimi-code"]`(global 写 `oauthHost`,cn 不写)
|
||||
- **修复**:手动添加模型后别名自动跟随为 `<provider>/<model-id>`;managed(OAuth)供应商自填 `api_key` 保存时不再被清空
|
||||
- models.dev 快照刷新至 **7246 模型 / 193 供应商**,新增 DeepSeek V4 Flash Vision Exp、Ox Alpha Free(opencode-go,免费)
|
||||
|
||||
## v0.7.6 (2026-08-18)
|
||||
|
||||
- **新增 OpenCode Go 套餐用量查询**:卡片用量页脚显示 **5 小时滚动 / 7 天 / 30 天** 三窗口的已用百分比与重置时间(数据源 `GET https://opencode.ai/zen/go/v1/usage`)
|
||||
- 已配置 OpenCode Go API Key 的老用户无需手动设置,程序按 `base_url` 自动识别并启用;已适配 opencode.ai 的 Cloudflare 拦截(携带浏览器 User-Agent)
|
||||
- 插件目录被运行中进程占用时的错误提示给出明确指引
|
||||
|
||||
## v0.7.5 (2026-08-15)
|
||||
|
||||
- **子代理模型池适配 kimi-code 0.36.0 新引擎**:适配 `[secondary_model]` 的 `default_model` + `[secondary_model.models]` 表 + `force`;写入时 `model` 与 `default_model` 双写同值,v1 legacy 与 v2 引擎均可识别
|
||||
- **高级设置页新增模型池管理**:升级旧配置、增删池条目、编辑路由描述(渲染进主代理 Agent/AgentSwarm 工具描述);支持 force 强制默认模型(带二次确认);保存前 6 类错误前置拦截
|
||||
- 新增 32 个单元测试覆盖池读写与校验逻辑(vitest)
|
||||
- **热力图详情改为按需加载**:双击方块调用后端 `get_day_detail` 聚合,不再受 7d / 30d 范围限制
|
||||
- models.dev 快照刷新至 2026-08-15(6583 模型 / 185 供应商),新增 GLM-5.3 等
|
||||
|
||||
## v0.7.4 (2026-08-09)
|
||||
|
||||
- **热力图方块双击弹窗**:查看当日模型用量分布,每种模型独立展示 Token 用量 / 请求次数 / 费用 / 缓存命中率(此前仅有 token 数)
|
||||
- 后端 `[dashboard] by_model` 从纯 token 数升级为结构化对象(含 requests / cost / cacheHitRate),弹窗与柱状图双击均展示全量指标
|
||||
- 范围外日期(不在当前 7d / 30d / all 内)不可双击;Escape / 遮罩 / 关闭按钮均可关闭弹窗
|
||||
|
||||
## v0.7.3 (2026-08-07)
|
||||
|
||||
- **Kimi Code WebUI 应用内嵌窗口**:新版 Web 界面以独立顶层窗口打开(1100×750、可缩放、居中),不再跳转外部浏览器
|
||||
- **单例 + 服务器自动管理**:窗口最多一个,重复点击自动聚焦;复用已运行的 `kimi web` 服务,自启动的服务器在窗口关闭或应用退出时自动清理
|
||||
- 「在应用内打开」与「在浏览器打开」两个入口并存(后者复用本地服务器,直接打开 `http://127.0.0.1:58627`)
|
||||
|
||||
## v0.7.2 (2026-08-07)
|
||||
|
||||
- 「子代理设置」升级为「**高级设置**」:聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
|
||||
- **新增 Kimi Code WebUI 快捷打开**:高级设置页可将新版 Web 界面以独立窗口在应用内打开,也可在系统浏览器打开(需 kimi-code 0.33+,kimi 命令在 PATH 中)
|
||||
- **子代理模型指定**:为子代理选择次主力模型后默认绑定该模型,不再继承主模型;实验开关改为滑动开关并修复开启态不可见问题
|
||||
- 兼容 kimi-code 0.33+(v2 引擎):`loop_control` 改用新键名 `max_attempts_per_step`(旧配置自动迁移,保留旧键兼容 legacy 引擎);Release 附带 `install-macos.sh` 一键安装脚本
|
||||
|
||||
## v0.7.1 (2026-08-06)
|
||||
|
||||
- **新增「子代理模型(次主力模型)」设置**:选择后子代理默认绑定该模型,不再继承主模型,仅保存模型引用即可继承上下文长度与思考模式
|
||||
- **仪表盘用量统计**:识别子代理请求(`__secondary__`)归为「子代理模型(估算)」独立展示,并缓存次主力模型定价,删除配置后历史记录仍稳定估算
|
||||
- 自定义网关模型(未收录 models.dev)按官方同名模型跨 provider 匹配价格,不再落入兜底估算
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,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 供应商)
|
||||
@@ -0,0 +1,9 @@
|
||||
# KimiSwitch v0.7.22
|
||||
|
||||
## 修复
|
||||
|
||||
- **Sub2API / NewAPI 模板查询在供应商列表不显示**:为没有预设识别类型的自定义供应商(如自家中转站)配置「Sub2API 中转站」或「NewAPI 中转站」模板后,配置弹窗里「测试查询」正常返回余额,但回到供应商列表却始终空白。根因:列表的查询与显示门槛只认预设注入的 `usageKinds`,而后端模板查询本就绕过 `usageKinds` 直接执行——前端口径与后端不一致。现在二者对齐,显式模板的供应商在列表正常显示余额 / 配额窗口、支持自动查询间隔与预警阈值。
|
||||
|
||||
## 备注
|
||||
|
||||
- 仅前端修复(`useUsageQuery` 支持判定 + 卡片渲染门槛),后端查询逻辑无变化;已补 3 个单测覆盖支持判定。
|
||||
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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+88829
-82134
File diff suppressed because it is too large.
Load diff
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# (git.codingplan.site/admin/KimiCodeSwitch).
|
||||
#
|
||||
# Usage: publish-gitea.sh <tag> <asset> [asset...]
|
||||
# tag : e.g. v0.7.15; notes read from release-notes-<tag>.md
|
||||
# tag : e.g. v0.7.15; notes read from docs/release-notes/<tag>.md
|
||||
# (legacy root release-notes-<tag>.md as fallback)
|
||||
# assets : files to attach (MSI, install-macos.sh, ...)
|
||||
#
|
||||
# Auth: reuses the stored git credential for git.codingplan.site
|
||||
@@ -15,7 +16,8 @@ REPO="admin/KimiCodeSwitch"
|
||||
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
|
||||
shift
|
||||
|
||||
NOTES_FILE="release-notes-${TAG}.md"
|
||||
NOTES_FILE="docs/release-notes/${TAG}.md"
|
||||
[ -f "$NOTES_FILE" ] || NOTES_FILE="release-notes-${TAG}.md"
|
||||
json_escape() {
|
||||
python - "$1" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.22"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.22"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+951
-19
File diff suppressed because it is too large.
Load diff
+666
-107
@@ -1,11 +1,13 @@
|
||||
use chrono::{DateTime, Datelike, Local, TimeZone};
|
||||
use crate::db::{ArchivedSessionSnapshot, DayModelStat, DayStat};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
use std::time::SystemTime;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -277,6 +279,15 @@ pub struct ActionResponse {
|
||||
pub deleted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Outcome of the bulk "archive everything older than X" command.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BulkArchiveResult {
|
||||
pub archived: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewResult {
|
||||
@@ -524,50 +535,62 @@ struct ModelsDevCost {
|
||||
cache_write: Option<f64>,
|
||||
}
|
||||
|
||||
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
|
||||
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
|
||||
/// Keyed by "<provider>/<model>", lowercased.
|
||||
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
||||
|
||||
fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
|
||||
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
|
||||
INDEX.get_or_init(|| {
|
||||
let mut map = HashMap::new();
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
|
||||
return map;
|
||||
};
|
||||
let Some(obj) = v.as_object() else {
|
||||
return map;
|
||||
};
|
||||
for (key, entry) in obj {
|
||||
// Skip the "last_updated" metadata key and entries without cost.
|
||||
if !entry.is_object() || entry.get("cost").is_none() {
|
||||
continue;
|
||||
/// models.dev price index over the effective snapshot (runtime-synced copy
|
||||
/// when present, else the compiled-in `src/lib/models-dev.json` generated by
|
||||
/// scripts/fetch-models-dev.mjs). Keyed by "<provider>/<model>", lowercased.
|
||||
/// Cached and rebuilt only when the synced file's mtime changes, so an online
|
||||
/// sync (models_dev::sync_models_dev) refreshes prices without a restart.
|
||||
fn models_dev_cost_index() -> Arc<HashMap<String, ModelsDevCost>> {
|
||||
static CACHE: OnceLock<RwLock<Option<(Option<SystemTime>, Arc<HashMap<String, ModelsDevCost>>)>>>=
|
||||
OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| RwLock::new(None));
|
||||
let stamp = fs::metadata(crate::models_dev::synced_path())
|
||||
.and_then(|m| m.modified())
|
||||
.ok();
|
||||
{
|
||||
let cached = cache.read().expect("price index lock");
|
||||
if let Some((cached_stamp, idx)) = cached.as_ref() {
|
||||
if *cached_stamp == stamp {
|
||||
return idx.clone();
|
||||
}
|
||||
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
|
||||
let (Some(input), Some(output)) = (num("input"), num("output")) else {
|
||||
continue;
|
||||
};
|
||||
map.insert(
|
||||
key.to_ascii_lowercase(),
|
||||
ModelsDevCost {
|
||||
input,
|
||||
output,
|
||||
cache_read: num("cache_read").unwrap_or(0.0),
|
||||
cache_write: num("cache_write"),
|
||||
},
|
||||
);
|
||||
}
|
||||
map
|
||||
})
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
let v = serde_json::from_str::<serde_json::Value>(&crate::models_dev::effective_snapshot());
|
||||
if let Ok(v) = v {
|
||||
if let Some(obj) = v.as_object() {
|
||||
for (key, entry) in obj {
|
||||
// Skip the "last_updated" metadata key and entries without cost.
|
||||
if !entry.is_object() || entry.get("cost").is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
|
||||
let (Some(input), Some(output)) = (num("input"), num("output")) else {
|
||||
continue;
|
||||
};
|
||||
map.insert(
|
||||
key.to_ascii_lowercase(),
|
||||
ModelsDevCost {
|
||||
input,
|
||||
output,
|
||||
cache_read: num("cache_read").unwrap_or(0.0),
|
||||
cache_write: num("cache_write"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let idx = Arc::new(map);
|
||||
*cache.write().expect("price index lock") = Some((stamp, idx.clone()));
|
||||
idx
|
||||
}
|
||||
|
||||
/// Warm the compiled-in models.dev price index off the first dashboard open:
|
||||
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
|
||||
/// background thread at startup instead of lazily on the first get_summary.
|
||||
/// Warm the models.dev price index off the first dashboard open: parsing the
|
||||
/// ~1.3 MB snapshot costs tens of milliseconds, so build it on a background
|
||||
/// thread at startup instead of lazily on the first get_summary.
|
||||
pub fn warm_price_index() {
|
||||
std::thread::spawn(|| {
|
||||
let _ = models_dev_cost_index();
|
||||
@@ -598,14 +621,15 @@ fn provider_rank(key: &str) -> usize {
|
||||
/// (deterministic tie-break). Returns None when nothing matches.
|
||||
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||
let lower = model_name.to_ascii_lowercase();
|
||||
if let Some(cost) = models_dev_cost_index().get(&lower) {
|
||||
let index = models_dev_cost_index();
|
||||
if let Some(cost) = index.get(&lower) {
|
||||
return Some((lower, *cost));
|
||||
}
|
||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||
let bare_l = bare.to_ascii_lowercase();
|
||||
// Match on the model part after the last '/', not the full key: aliases
|
||||
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.rsplit_once('/')
|
||||
@@ -685,7 +709,8 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||
let bare_l = bare.to_ascii_lowercase();
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
|
||||
let index = models_dev_cost_index();
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||
.iter()
|
||||
.filter(|(key, cost)| {
|
||||
(cost.input > 0.0 || cost.output > 0.0)
|
||||
@@ -853,67 +878,8 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for line in content.lines() {
|
||||
lines_seen += 1;
|
||||
if !line.contains("\"usage.record\"") { continue; }
|
||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
|
||||
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
|
||||
if scope != "turn" { continue; }
|
||||
|
||||
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
// Kimi Code writes the internal marker `__secondary__` as the model
|
||||
// key for usage records emitted by subagents bound to the
|
||||
// configured secondary model. The record stays keyed on that
|
||||
// stable marker so historical records keep their semantics if the
|
||||
// secondary config changes later; cost billing follows the
|
||||
// currently configured secondary model, and the record is flagged
|
||||
// is_secondary so the UI shows it as its own "subagent model"
|
||||
// entry.
|
||||
let is_secondary = model_raw == "__secondary__";
|
||||
let from_env = model_raw == "__kimi_env_model__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
|
||||
} else {
|
||||
model_raw.clone()
|
||||
};
|
||||
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let usage = &obj["usage"];
|
||||
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
// Resolve model display
|
||||
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
|
||||
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
|
||||
// Subagent records are inherently estimates: the `__secondary__`
|
||||
// marker carries no model identity, so the cost is flagged as
|
||||
// estimated even when the current secondary config resolves to a
|
||||
// priced listing.
|
||||
let est = est || is_secondary;
|
||||
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
|
||||
|
||||
records.push(UsageRecord {
|
||||
time,
|
||||
model: model_raw.clone(),
|
||||
model_resolved: bare.to_string(),
|
||||
model_display: model_raw.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env,
|
||||
is_secondary,
|
||||
input_other,
|
||||
output,
|
||||
input_cache_read: cache_read,
|
||||
input_cache_creation: cache_create,
|
||||
cost_usd: cost,
|
||||
cost_estimated: est,
|
||||
price_id: pid,
|
||||
});
|
||||
}
|
||||
lines_seen += content.lines().count();
|
||||
records.extend(parse_wire_usage_records(&content, &secondary_alias, &alias2prov));
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
@@ -926,6 +892,180 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one `wire.jsonl` body into usage records. Extracted from
|
||||
/// `scan_usage` so a single session can be snapshotted without walking the
|
||||
/// whole sessions tree; the record filter chain is unchanged.
|
||||
fn parse_wire_usage_records(
|
||||
content: &str,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in content.lines() {
|
||||
if !line.contains("\"usage.record\"") { continue; }
|
||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
|
||||
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
|
||||
if scope != "turn" { continue; }
|
||||
|
||||
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
// Kimi Code writes the internal marker `__secondary__` as the model
|
||||
// key for usage records emitted by subagents bound to the
|
||||
// configured secondary model. The record stays keyed on that
|
||||
// stable marker so historical records keep their semantics if the
|
||||
// secondary config changes later; cost billing follows the
|
||||
// currently configured secondary model, and the record is flagged
|
||||
// is_secondary so the UI shows it as its own "subagent model"
|
||||
// entry.
|
||||
let is_secondary = model_raw == "__secondary__";
|
||||
let from_env = model_raw == "__kimi_env_model__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
|
||||
} else {
|
||||
model_raw.clone()
|
||||
};
|
||||
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let usage = &obj["usage"];
|
||||
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
// Resolve model display
|
||||
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
|
||||
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
|
||||
// Subagent records are inherently estimates: the `__secondary__`
|
||||
// marker carries no model identity, so the cost is flagged as
|
||||
// estimated even when the current secondary config resolves to a
|
||||
// priced listing.
|
||||
let est = est || is_secondary;
|
||||
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
|
||||
|
||||
records.push(UsageRecord {
|
||||
time,
|
||||
model: model_raw.clone(),
|
||||
model_resolved: bare.to_string(),
|
||||
model_display: model_raw.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env,
|
||||
is_secondary,
|
||||
input_other,
|
||||
output,
|
||||
input_cache_read: cache_read,
|
||||
input_cache_creation: cache_create,
|
||||
cost_usd: cost,
|
||||
cost_estimated: est,
|
||||
price_id: pid,
|
||||
});
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Aggregate raw usage records into the per-day, per-model snapshot stored
|
||||
/// for an archived session.
|
||||
fn snapshot_from_records(records: &[UsageRecord]) -> Vec<DayStat> {
|
||||
let mut by_day: HashMap<String, (u64, HashMap<String, DayModelStat>)> = HashMap::new();
|
||||
for r in records {
|
||||
let entry = by_day
|
||||
.entry(day_key(r.time))
|
||||
.or_insert_with(|| (local_midnight_ms(r.time, 0), HashMap::new()));
|
||||
let m = entry.1.entry(r.model.clone()).or_insert_with(|| DayModelStat {
|
||||
model: r.model.clone(),
|
||||
requests: 0,
|
||||
input_other: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 0.0,
|
||||
});
|
||||
m.requests += 1;
|
||||
m.input_other += r.input_other;
|
||||
m.output += r.output;
|
||||
m.input_cache_read += r.input_cache_read;
|
||||
m.input_cache_creation += r.input_cache_creation;
|
||||
m.cost_usd += r.cost_usd;
|
||||
}
|
||||
let mut days: Vec<DayStat> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, (day_start_ms, models))| {
|
||||
let mut models: Vec<DayModelStat> = models.into_values().collect();
|
||||
models.sort_by(|a, b| a.model.cmp(&b.model));
|
||||
DayStat { day, day_start_ms, models }
|
||||
})
|
||||
.collect();
|
||||
days.sort_by(|a, b| a.day.cmp(&b.day));
|
||||
days
|
||||
}
|
||||
|
||||
/// Synthesize usage records from stored archive snapshots. Only snapshots
|
||||
/// whose session directory is gone are synthesized: sessions still on disk
|
||||
/// are covered by the live wire.jsonl scan, and emitting both would double
|
||||
/// count their usage.
|
||||
fn synthesize_snapshot_records(rows: &[ArchivedSessionSnapshot], home: &Path) -> Vec<UsageRecord> {
|
||||
let root = sessions_root(home);
|
||||
let legacy_root = root.join(".kcd-archive");
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
if root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
if legacy_root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
for day in &row.day_stats {
|
||||
for m in &day.models {
|
||||
let is_secondary = m.model == "__secondary__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| m.model.clone())
|
||||
} else {
|
||||
m.model.clone()
|
||||
};
|
||||
let bare = m.model.rsplit_once('/').map(|x| x.1).unwrap_or(&m.model).to_string();
|
||||
out.push(UsageRecord {
|
||||
time: day.day_start_ms,
|
||||
model: m.model.clone(),
|
||||
model_resolved: bare,
|
||||
model_display: m.model.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env: m.model == "__kimi_env_model__",
|
||||
is_secondary,
|
||||
input_other: m.input_other,
|
||||
output: m.output,
|
||||
input_cache_read: m.input_cache_read,
|
||||
input_cache_creation: m.input_cache_creation,
|
||||
cost_usd: m.cost_usd,
|
||||
cost_estimated: true,
|
||||
price_id: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append the synthesized records for `rows` to a live scan result and
|
||||
/// restore the newest-first ordering `scan_usage` produces.
|
||||
fn merge_snapshot_records(
|
||||
mut records: Vec<UsageRecord>,
|
||||
rows: &[ArchivedSessionSnapshot],
|
||||
home: &Path,
|
||||
) -> Vec<UsageRecord> {
|
||||
// Nothing archived yet: keep the scan result untouched (and skip the
|
||||
// config reads the synthesis would do).
|
||||
if rows.is_empty() { return records; }
|
||||
records.extend(synthesize_snapshot_records(rows, home));
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
records
|
||||
}
|
||||
|
||||
/// Live scan records plus the stored usage of archived sessions whose files
|
||||
/// have been deleted, so dashboard stats survive session deletion.
|
||||
fn records_with_archive_merge(home: &Path, records: Vec<UsageRecord>) -> Vec<UsageRecord> {
|
||||
let rows = crate::db::list_archived_sessions().unwrap_or_default();
|
||||
merge_snapshot_records(records, &rows, home)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan cache
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1332,6 +1472,147 @@ fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a `state.json` timestamp into epoch ms. kimi-code writes epoch-ms
|
||||
/// numbers, other builds write ISO-8601 strings — accept both, plus numeric
|
||||
/// strings.
|
||||
fn parse_state_time_ms(value: &serde_json::Value) -> Option<u64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
|
||||
serde_json::Value::String(s) => parse_time_string_ms(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time_string_ms(s: &str) -> Option<u64> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() { return None; }
|
||||
if let Ok(n) = t.parse::<u64>() {
|
||||
// Numeric string: epoch ms when it is already millisecond-sized.
|
||||
return Some(if n >= 1e12 as u64 { n } else { n * 1000 });
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(t) {
|
||||
return Some(dt.timestamp_millis().max(0) as u64);
|
||||
}
|
||||
for fmt in [
|
||||
"%Y-%m-%dT%H:%M:%S%.f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S%.f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
] {
|
||||
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(t, fmt) {
|
||||
return Some(naive.and_utc().timestamp_millis().max(0) as u64);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a session's `state.json` as a JSON object (None when missing or
|
||||
/// unparseable).
|
||||
fn read_state_obj(session_dir: &Path) -> Option<serde_json::Value> {
|
||||
let content = fs::read_to_string(session_dir.join("state.json")).ok()?;
|
||||
let obj: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
if obj.is_object() { Some(obj) } else { None }
|
||||
}
|
||||
|
||||
/// Session directory mtime in epoch ms — the fallback when `state.json` has
|
||||
/// no parseable `updatedAt`.
|
||||
fn dir_mtime_ms(dir: &Path) -> Option<u64> {
|
||||
dir.metadata().ok().and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// Usage records of one session: every `wire.jsonl` below its directory
|
||||
/// (agent subdirectories included), with the same blob/task filtering the
|
||||
/// full-tree scan applies.
|
||||
fn parse_session_wire_usage(
|
||||
dir: &Path,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if entry.file_name() != "wire.jsonl" || !entry.file_type().is_file() { continue; }
|
||||
let p = entry.path();
|
||||
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { continue; }
|
||||
let Ok(content) = fs::read_to_string(p) else { continue };
|
||||
records.extend(parse_wire_usage_records(&content, secondary_alias, alias2prov));
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) and store a usage snapshot per session in SQLite, so dashboard
|
||||
/// stats keep counting their history after the directories are deleted.
|
||||
/// Sessions already archived, in `.kcd-archive`, or without a readable
|
||||
/// `state.json` are left alone.
|
||||
fn archive_sessions_before_cmd(home: &Path, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let root = sessions_root(home);
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
|
||||
let mut result = BulkArchiveResult { archived: 0, skipped: 0, errors: Vec::new() };
|
||||
|
||||
for wid in list_workspace_dirs(home) {
|
||||
for entry in safe_read_dir(&root.join(&wid)) {
|
||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||
let sid = entry.file_name().to_string_lossy().to_string();
|
||||
if !session_re().is_match(&sid) { continue; }
|
||||
let dir = entry.path();
|
||||
let Some(state) = read_state_obj(&dir) else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if state.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
|
||||
let Some(updated_ms) = state
|
||||
.get("updatedAt")
|
||||
.and_then(parse_state_time_ms)
|
||||
.or_else(|| dir_mtime_ms(&dir))
|
||||
else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if updated_ms >= cutoff_ms {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = set_session_archived_meta(&dir, true) {
|
||||
result.errors.push(format!("{wid}/{sid}: {e}"));
|
||||
continue;
|
||||
}
|
||||
let day_stats = snapshot_from_records(&parse_session_wire_usage(
|
||||
&dir, &secondary_alias, &alias2prov,
|
||||
));
|
||||
let mut total_tokens = 0u64;
|
||||
let mut total_cost_usd = 0.0f64;
|
||||
for day in &day_stats {
|
||||
for m in &day.models {
|
||||
total_tokens += m.input_other + m.output + m.input_cache_read + m.input_cache_creation;
|
||||
total_cost_usd += m.cost_usd;
|
||||
}
|
||||
}
|
||||
let snapshot = ArchivedSessionSnapshot {
|
||||
session_id: sid.clone(),
|
||||
workspace_id: wid.clone(),
|
||||
title: state.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
archived_at_ms: now_ms,
|
||||
updated_at_ms: Some(updated_ms),
|
||||
created_at_ms: state.get("createdAt").and_then(parse_state_time_ms),
|
||||
total_tokens,
|
||||
total_cost_usd,
|
||||
day_stats,
|
||||
};
|
||||
result.archived += 1;
|
||||
if let Err(e) = crate::db::upsert_archived_session(&snapshot) {
|
||||
result.errors.push(format!("{wid}/{sid}: snapshot: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn humanize_workspace(id: &str) -> String {
|
||||
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
||||
if let Some(caps) = re.captures(id) {
|
||||
@@ -1876,6 +2157,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
let r = range.unwrap_or_else(|| "30d".into());
|
||||
|
||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
let t1 = std::time::Instant::now();
|
||||
let stats = aggregate(&records, &r, now_ms);
|
||||
let all_stats = aggregate(&records, "all", now_ms);
|
||||
@@ -1983,6 +2265,7 @@ fn build_day_detail(date: &str, records: &[UsageRecord]) -> Option<DailyRow> {
|
||||
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
let (records, _) = scan_usage_cached(&home, false);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
build_day_detail(&date, &records)
|
||||
}
|
||||
|
||||
@@ -1998,6 +2281,14 @@ pub fn archive_session(home_override: Option<String>, workspace_id: String, sess
|
||||
archive_session_cmd(&home, &workspace_id, &session_id)
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) across all workspaces, storing a usage snapshot per session.
|
||||
#[tauri::command]
|
||||
pub fn archive_sessions_before(home_override: Option<String>, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
archive_sessions_before_cmd(&home, cutoff_ms)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
@@ -2073,8 +2364,8 @@ mod pricing_tests {
|
||||
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
|
||||
assert!(!est, "resolved via models.dev, not estimated");
|
||||
assert_eq!(id, "deepseek/deepseek-v4-flash");
|
||||
assert_eq!(input, 0.14);
|
||||
assert_eq!(output, 0.28);
|
||||
assert_eq!(input, 0.15);
|
||||
assert_eq!(output, 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2432,3 +2723,271 @@ mod range_and_model_totals_tests {
|
||||
assert_eq!(total, 3 * 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod archive_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
const WID: &str = "wd_proj_a1b2c3d4e5f6";
|
||||
const LIVE_SID: &str = "session_11111111-2222-3333-4444-555555555555";
|
||||
const GONE_SID: &str = "session_99999999-8888-7777-6666-555555555555";
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
|
||||
fn rec(time: u64, model: &str, tokens: (u64, u64, u64, u64), cost: f64) -> UsageRecord {
|
||||
UsageRecord {
|
||||
time,
|
||||
model: model.to_string(),
|
||||
input_other: tokens.0,
|
||||
output: tokens.1,
|
||||
input_cache_read: tokens.2,
|
||||
input_cache_creation: tokens.3,
|
||||
cost_usd: cost,
|
||||
cost_estimated: false,
|
||||
price_id: String::new(),
|
||||
model_resolved: model.rsplit_once('/').map(|x| x.1).unwrap_or(model).to_string(),
|
||||
model_display: model.to_string(),
|
||||
provider: None,
|
||||
from_env: false,
|
||||
is_secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_row(
|
||||
sid: &str,
|
||||
day_start_ms: u64,
|
||||
model: &str,
|
||||
tokens: u64,
|
||||
) -> ArchivedSessionSnapshot {
|
||||
ArchivedSessionSnapshot {
|
||||
session_id: sid.to_string(),
|
||||
workspace_id: WID.to_string(),
|
||||
title: None,
|
||||
archived_at_ms: 0,
|
||||
updated_at_ms: None,
|
||||
created_at_ms: None,
|
||||
total_tokens: tokens,
|
||||
total_cost_usd: 0.0,
|
||||
day_stats: vec![DayStat {
|
||||
day: day_key(day_start_ms),
|
||||
day_start_ms,
|
||||
models: vec![DayModelStat {
|
||||
model: model.to_string(),
|
||||
requests: 2,
|
||||
input_other: tokens,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 1.5,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(dir: &Path, updated_ms: u64, archived: bool) {
|
||||
let state = serde_json::json!({
|
||||
"id": dir.file_name().unwrap().to_string_lossy(),
|
||||
"createdAt": updated_ms,
|
||||
"updatedAt": updated_ms,
|
||||
"archived": archived,
|
||||
"title": "archived candidate",
|
||||
});
|
||||
fs::write(dir.join("state.json"), state.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn read_state(dir: &Path) -> serde_json::Value {
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("state.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_groups_records_by_local_day_and_model() {
|
||||
let now = now_ms();
|
||||
let d1 = local_midnight_ms(now, 2);
|
||||
let d2 = local_midnight_ms(now, 1);
|
||||
let records = vec![
|
||||
rec(d1 + 3_600_000, "openai/gpt-x", (100, 10, 5, 1), 0.5),
|
||||
rec(d1 + 7_200_000, "openai/gpt-x", (200, 20, 0, 0), 0.25),
|
||||
rec(d1 + 60_000, "moonshotai/kimi-k3", (1, 2, 3, 4), 0.01),
|
||||
rec(d2 + 1_000, "openai/gpt-x", (7, 7, 7, 7), 0.07),
|
||||
];
|
||||
let days = snapshot_from_records(&records);
|
||||
assert_eq!(days.len(), 2, "one bucket per local day");
|
||||
|
||||
let first = &days[0];
|
||||
assert_eq!(first.day, day_key(d1));
|
||||
assert_eq!(first.day_start_ms, d1, "day bucket starts at local midnight");
|
||||
assert_eq!(first.models.len(), 2);
|
||||
let gpt = first.models.iter().find(|m| m.model == "openai/gpt-x").unwrap();
|
||||
assert_eq!(gpt.requests, 2);
|
||||
assert_eq!(gpt.input_other, 300);
|
||||
assert_eq!(gpt.output, 30);
|
||||
assert_eq!(gpt.input_cache_read, 5);
|
||||
assert_eq!(gpt.input_cache_creation, 1);
|
||||
assert_eq!(gpt.cost_usd, 0.75);
|
||||
// Deterministic ordering: models sorted by raw model key.
|
||||
assert_eq!(first.models[0].model, "moonshotai/kimi-k3");
|
||||
|
||||
assert_eq!(days[1].day, day_key(d2));
|
||||
assert_eq!(days[1].day_start_ms, d2);
|
||||
assert_eq!(days[1].models.len(), 1);
|
||||
assert_eq!(days[1].models[0].input_other, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_only_covers_sessions_whose_files_are_gone() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let day_start = 1_700_000_000_000u64;
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, day_start, "openai/gpt-x", 100),
|
||||
snapshot_row(GONE_SID, day_start, "openai/gpt-x", 200),
|
||||
];
|
||||
let out = synthesize_snapshot_records(&rows, &home);
|
||||
assert_eq!(out.len(), 1, "live session dirs stay with the live scan");
|
||||
|
||||
let r = &out[0];
|
||||
assert_eq!(r.time, day_start);
|
||||
assert_eq!(r.model, "openai/gpt-x");
|
||||
assert_eq!(r.model_resolved, "gpt-x");
|
||||
assert_eq!(r.model_display, "openai/gpt-x");
|
||||
assert_eq!(r.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(r.input_other, 200);
|
||||
assert_eq!(r.cost_usd, 1.5);
|
||||
assert!(r.cost_estimated, "snapshot costs are estimates");
|
||||
assert_eq!(r.price_id, "");
|
||||
assert!(!r.is_secondary);
|
||||
assert!(!r.from_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_missing_sessions_newest_first_without_double_counting() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let now = now_ms();
|
||||
let old_day = local_midnight_ms(now, 5);
|
||||
let new_day = local_midnight_ms(now, 1);
|
||||
let gone_old = "session_22222222-2222-3333-4444-555555555555";
|
||||
let gone_new = "session_33333333-2222-3333-4444-555555555555";
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, new_day, "openai/gpt-x", 999),
|
||||
snapshot_row(gone_old, old_day, "openai/gpt-x", 10),
|
||||
snapshot_row(gone_new, new_day, "openai/gpt-x", 20),
|
||||
];
|
||||
let live = vec![rec(new_day + 5_000, "openai/gpt-x", (1, 1, 1, 1), 0.01)];
|
||||
let merged = merge_snapshot_records(live, &rows, &home);
|
||||
|
||||
assert_eq!(merged.len(), 3, "live record plus the two missing sessions");
|
||||
assert_eq!(merged[0].time, new_day + 5_000, "newest record first");
|
||||
assert_eq!(merged[1].time, new_day);
|
||||
assert_eq!(merged[2].time, old_day);
|
||||
assert_eq!(merged[1].input_other, 20);
|
||||
assert_eq!(merged[2].input_other, 10);
|
||||
assert!(
|
||||
merged.iter().all(|r| r.input_other != 999),
|
||||
"a live session's stored snapshot must never be synthesized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_times_parse_epoch_numbers_and_iso_strings() {
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!(1_700_000_000_000u64)),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("1700000000000")),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05.000Z")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02 03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!(null)), None);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!("not-a-date")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_archive_flags_old_sessions_and_snapshots_their_usage() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
// Redirect the SQLite store so the test never touches the user's DB.
|
||||
std::env::set_var("KIMI_SWITCH_DB_PATH", td.path().join("test.db"));
|
||||
|
||||
let ws = home.join("sessions").join(WID);
|
||||
let now = now_ms();
|
||||
let old_ms = now - 60 * 24 * 3600 * 1000;
|
||||
|
||||
let old_dir = ws.join(LIVE_SID);
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
write_state(&old_dir, old_ms, false);
|
||||
let wire = serde_json::json!({
|
||||
"type": "usage.record",
|
||||
"usageScope": "turn",
|
||||
"time": old_ms,
|
||||
"model": "openai/gpt-x",
|
||||
"usage": {
|
||||
"inputOther": 100,
|
||||
"output": 50,
|
||||
"inputCacheRead": 10,
|
||||
"inputCacheCreation": 5,
|
||||
},
|
||||
});
|
||||
fs::write(old_dir.join("wire.jsonl"), format!("{wire}\n")).unwrap();
|
||||
|
||||
let new_sid = "session_44444444-2222-3333-4444-555555555555";
|
||||
let new_dir = ws.join(new_sid);
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
write_state(&new_dir, now - 3_600_000, false);
|
||||
|
||||
let cutoff = now - 30 * 24 * 3600 * 1000;
|
||||
let res = archive_sessions_before_cmd(&home, cutoff);
|
||||
assert_eq!(res.archived, 1, "only the stale session is archived");
|
||||
assert_eq!(res.skipped, 1, "the recent session is reported as skipped");
|
||||
assert!(res.errors.is_empty(), "{:?}", res.errors);
|
||||
|
||||
// Metadata archive happened in place, the recent session is untouched.
|
||||
assert_eq!(read_state(&old_dir)["archived"], true);
|
||||
assert!(read_state(&old_dir)["archivedAt"].as_u64().unwrap() > 0);
|
||||
assert_eq!(read_state(&new_dir)["archived"], false);
|
||||
|
||||
// Usage snapshot landed in SQLite with the session's totals.
|
||||
let rows = crate::db::list_archived_sessions().unwrap();
|
||||
let row = rows.iter().find(|r| r.session_id == LIVE_SID).expect("snapshot row");
|
||||
assert_eq!(row.workspace_id, WID);
|
||||
assert_eq!(row.title.as_deref(), Some("archived candidate"));
|
||||
assert_eq!(row.updated_at_ms, Some(old_ms));
|
||||
assert_eq!(row.total_tokens, 165);
|
||||
assert!(row.total_cost_usd > 0.0);
|
||||
assert_eq!(row.day_stats.len(), 1);
|
||||
assert_eq!(row.day_stats[0].day, day_key(old_ms));
|
||||
assert_eq!(row.day_stats[0].models[0].requests, 1);
|
||||
assert_eq!(row.day_stats[0].models[0].input_other, 100);
|
||||
|
||||
// While the files are still there, the live scan owns the usage.
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert!(merged.is_empty(), "no synthesized records while the dir exists");
|
||||
|
||||
// Once the archived directory is deleted, stats come from the snapshot.
|
||||
fs::remove_dir_all(&old_dir).unwrap();
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].input_other, 100);
|
||||
assert_eq!(merged[0].output, 50);
|
||||
assert_eq!(day_key(merged[0].time), day_key(old_ms));
|
||||
}
|
||||
}
|
||||
+146
-12
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -289,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()));
|
||||
@@ -328,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.
|
||||
@@ -562,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,
|
||||
@@ -623,6 +655,7 @@ api_key = ""
|
||||
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,
|
||||
@@ -760,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,
|
||||
@@ -868,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,
|
||||
@@ -888,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,
|
||||
@@ -929,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,
|
||||
@@ -974,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,
|
||||
@@ -1153,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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +293,11 @@ 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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod balance;
|
||||
pub mod coding_plan;
|
||||
pub mod probe;
|
||||
pub mod usage_types;
|
||||
|
||||
pub use usage_types::{UsageData, UsageResult};
|
||||
@@ -336,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);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.22",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
+136
-24
@@ -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,10 @@ function ProviderCard({
|
||||
provider.name,
|
||||
provider.usageKinds,
|
||||
provider.usageConfig?.autoQueryIntervalMinutes,
|
||||
provider.usageConfig?.enabled === false
|
||||
provider.usageConfig?.enabled === false,
|
||||
provider.usageConfig?.threshold,
|
||||
provider.usageConfig?.templateType === "newapi" ||
|
||||
provider.usageConfig?.templateType === "sub2api"
|
||||
);
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
@@ -80,6 +108,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 +141,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")}
|
||||
@@ -156,7 +223,7 @@ function ProviderCard({
|
||||
)}
|
||||
{/* compact usage summary — sits left of the switch button,
|
||||
mirroring cc-switch's card layout (usage → action buttons) */}
|
||||
{(provider.usageKinds?.length ?? 0) > 0 && (
|
||||
{usage.supported && (
|
||||
<UsageFooter usage={usage} variant="compact" />
|
||||
)}
|
||||
<button
|
||||
@@ -273,6 +340,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 +380,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 +480,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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
@@ -48,10 +57,6 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
|
||||
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
|
||||
tower: { name: "flagTower", desc: "flagTowerDesc" },
|
||||
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
|
||||
auto_session_title: {
|
||||
name: "flagAutoSessionTitle",
|
||||
desc: "flagAutoSessionTitleDesc",
|
||||
},
|
||||
};
|
||||
|
||||
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
||||
@@ -159,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) : [];
|
||||
@@ -402,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 ? (
|
||||
|
||||
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
)}
|
||||
</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>
|
||||
@@ -394,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAlertTier, usageQuerySupported, 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usageQuerySupported", () => {
|
||||
it("有识别类型即可查询", () => {
|
||||
expect(usageQuerySupported(["balance:deepseek"])).toBe(true);
|
||||
expect(usageQuerySupported([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("显式模板(newapi/sub2api)绕过 usage_kinds——自定义供应商也该查", () => {
|
||||
expect(usageQuerySupported(undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("无识别类型且非显式模板时不查询", () => {
|
||||
expect(usageQuerySupported(undefined)).toBe(false);
|
||||
expect(usageQuerySupported([])).toBe(false);
|
||||
// auto 模板没有识别到类型 = 不支持(与弹窗短路口径一致)
|
||||
expect(usageQuerySupported([], false)).toBe(false);
|
||||
expect(usageQuerySupported(undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,34 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否值得为该供应商发起用量查询:有预设识别类型,或后端按显式模板
|
||||
* (newapi / sub2api,二者绕过 usage_kinds)查询。
|
||||
*/
|
||||
export function usageQuerySupported(
|
||||
usageKinds?: string[],
|
||||
explicitTemplate?: boolean
|
||||
): boolean {
|
||||
return (usageKinds?.length ?? 0) > 0 || explicitTemplate === true;
|
||||
}
|
||||
|
||||
export function useUsageQuery(
|
||||
@@ -63,9 +91,14 @@ export function useUsageQuery(
|
||||
providerName: string,
|
||||
usageKinds?: string[],
|
||||
autoIntervalMinutes?: number,
|
||||
disabled?: boolean
|
||||
disabled?: boolean,
|
||||
threshold?: number,
|
||||
/** Backend resolves the query from an explicit template (newapi / sub2api)
|
||||
* regardless of usageKinds — those bypass kinds entirely, so the row is
|
||||
* supported even when the provider has no preset-detected kinds. */
|
||||
explicitTemplate?: boolean
|
||||
): UsageQueryState {
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
const supported = usageQuerySupported(usageKinds, explicitTemplate);
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
// with the same name do not clobber each other's cached result.
|
||||
const cacheKey = `${agent}:${providerName}`;
|
||||
@@ -175,6 +208,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 +224,7 @@ export function useUsageQuery(
|
||||
updatedAt,
|
||||
refresh: runQuery,
|
||||
supported,
|
||||
threshold: alertThreshold,
|
||||
alert,
|
||||
};
|
||||
}
|
||||
+78
-2
@@ -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",
|
||||
@@ -225,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",
|
||||
@@ -270,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",
|
||||
@@ -436,6 +494,11 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
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",
|
||||
@@ -477,8 +540,6 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
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",
|
||||
flagNotifyUser: "NotifyUser tool",
|
||||
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
|
||||
flagDefaultOn: "On by default",
|
||||
@@ -533,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:
|
||||
|
||||
+75
-2
@@ -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: "模式",
|
||||
@@ -220,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: "暂无数据",
|
||||
@@ -265,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 插件站",
|
||||
@@ -431,6 +486,11 @@ export const zhTranslations = {
|
||||
usageTierDaily: "每日",
|
||||
usageTierWeekly: "7天",
|
||||
usageTierMonthly: "30天",
|
||||
usageTierMonthCode: "30天(代码)",
|
||||
usageTierBoosterWallet: "加力钱包",
|
||||
usageThreshold: "预警阈值",
|
||||
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
|
||||
usageThresholdAlert: "已达预警阈值",
|
||||
usageErrNoKey: "未配置 API Key",
|
||||
usageErrDisabled: "用量查询已在配置面板中停用",
|
||||
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
|
||||
@@ -472,8 +532,6 @@ export const zhTranslations = {
|
||||
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
|
||||
flagWaitFor: "WaitFor 工具",
|
||||
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
||||
flagAutoSessionTitle: "自动会话标题",
|
||||
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
||||
flagNotifyUser: "NotifyUser 工具",
|
||||
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
|
||||
flagDefaultOn: "默认开启",
|
||||
@@ -525,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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+103115
-95277
File diff suppressed because it is too large.
Load diff
+88829
-82134
File diff suppressed because it is too large.
Load diff
+60
-23
@@ -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();
|
||||
|
||||
@@ -461,22 +461,23 @@ describe("validateSubagentPool", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experimental flag registry", () => {
|
||||
it("mirrors the 0.42.0 v2 registry (6 flags)", () => {
|
||||
it("mirrors the 0.43.0 v2 registry (5 flags)", () => {
|
||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||
"wait_for",
|
||||
"tool-select",
|
||||
"notify_user",
|
||||
"tower",
|
||||
"subagent_fork",
|
||||
"auto_session_title",
|
||||
]);
|
||||
// Removed upstream: file_history (0.41.0, turn-level file history is
|
||||
// always on) 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.
|
||||
// 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",
|
||||
|
||||
@@ -33,10 +33,11 @@ export interface ExperimentalFlagDef {
|
||||
defaultEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry
|
||||
/** 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,
|
||||
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control
|
||||
* 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
|
||||
@@ -53,10 +54,6 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
{ 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" },
|
||||
{
|
||||
id: "auto_session_title",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||
},
|
||||
];
|
||||
|
||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||
|
||||
@@ -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("¥");
|
||||
});
|
||||
|
||||
@@ -5,15 +5,23 @@ 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) {
|
||||
@@ -25,6 +33,10 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -90,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. */
|
||||
@@ -125,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 {
|
||||
@@ -132,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;
|
||||
@@ -150,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user