Compare commits

...
20 Commits
Author SHA1 Message Date
954801926@qq.com 419c73dfe1 fix(ci): 显式透传 GITHUB_TOKEN 给 tauri-action 上传用
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
- tauri-action 报 'GITHUB_TOKEN is required' 实际是模板替换失败
- 在 step env 显式给 ${{ secrets.GITHUB_TOKEN }} 解决
2026-07-30 00:48:20 +08:00
954801926@qq.com 30d51e0e4a fix(ci): 加 npm ci 步骤 + 移除冲突的 libappindicator3-dev
- macOS/Windows 失败: 'tauri' 命令找不到 → 实际是 npm 依赖没装
  tauri-action 不会自动 npm install, 需显式 npm ci
- Linux apt 冲突: libappindicator3-dev 与 libayatana-appindicator3-dev 互斥
  保留后者 (Tauri 2 推荐)
2026-07-30 00:39:14 +08:00
954801926@qq.com ea54cda116 fix(ci): setup-rust 改用 dtolnay/rust-toolchain (上游 @v1 解析失败) 2026-07-30 00:35:37 +08:00
954801926@qq.com ce8501cf06 fix(ci): check-version.sh 兼容预发布 tag (v0.6.0-rc1 / beta / alpha)
- 预发布标签在比较时也剥离 -rc1 / -beta.2 / -alpha.3 等后缀
- 允许用户复用已发布的版本文件来打预发布
- 显式不匹配仍正常工作
2026-07-30 00:33:16 +08:00
954801926@qq.com 19f58c3a2b docs(readme): multi-platform downloads + workflow badge
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
- 中文/英文 README 各加一段"下载"/"Downloads"段落
- 三平台链接: Windows .msi / macOS .dmg (注明未签名) / Linux 多格式
- 历史版本指向 git.codingplan.site
- 加 GH Actions Release workflow 徽章
2026-07-30 00:30:34 +08:00
954801926@qq.com 8f0729ddfc docs: build instructions for all platforms + Docker + CI 2026-07-30 00:29:48 +08:00
954801926@qq.com 0dbe2ba88c ci: add Linux Docker build environment
- Ubuntu 22.04 + webkit2gtk-4.1 + librsvg2 + nodejs + rustup + tauri-cli
- docker-compose: 绑定源码到 /workspace, 持久化 cargo/target 缓存
- 与 GH Actions ubuntu-latest job 依赖保持一致
2026-07-30 00:29:31 +08:00
954801926@qq.com 6a8b09ef35 ci: add multi-platform release workflow on tag push
- version-check job: tag == package.json / Cargo.toml / tauri.conf.json
- 3 平台矩阵: ubuntu (deb/AppImage/rpm), macos (app/dmg), windows (msi)
- tauri-action 自动建 Release + 上传 assets
- prerelease 自动识别 (含 -rc / -beta / -alpha 的 tag)
- macOS unsigned: release body 注明用户首次启动需右键 → Open
2026-07-30 00:23:51 +08:00
954801926@qq.com 4adf5cafbe ci: add version-consistency check script (tag vs package.json vs Cargo.toml vs tauri.conf.json) 2026-07-30 00:23:13 +08:00
954801926@qq.com 90ab9f8a97 feat(update): use platform-aware asset and filename in update flow
- check_for_update: download_url = pick_asset_for_current_os(assets)
- download_update: temp file = update_temp_filename() (按 OS: msi/dmg/AppImage)
2026-07-30 00:22:49 +08:00
954801926@qq.com 06aad52e0f feat(update): TDD platform-aware asset picker + tests
- 抽取 pick_asset_for_os(assets, os) 纯函数(可测)
- pick_asset_for_current_os 包裹运行时 OS
- update_temp_filename 按 OS 决定后缀
- 6 个单元测覆盖 win/mac/linux + missing/empty/unknown
2026-07-30 00:21:54 +08:00
954801926@qq.com 548cdabe99 fix(build): only set windows_subsystem on Windows targets 2026-07-30 00:17:39 +08:00
954801926@qq.com 7b41518d6e feat(bundle): enable all-platform targets + macOS/Linux config 2026-07-30 00:17:00 +08:00
954801926@qq.com 63ae664e72 docs(plan): 多平台打包实施计划
10 个任务覆盖:
- tauri.conf.json 改造 + main.rs 平台条件
- TDD 平台资产选择器 + 单元测
- check_for_update / download_update 接入
- 版本一致性脚本
- GitHub Actions release workflow
- Docker Linux 构建
- BUILD.md
- README 三平台下载 + workflow 徽章
- E2E 验证 (pre-release tag)
2026-07-30 00:15:12 +08:00
954801926@qq.com 00322fb503 docs(spec): 多平台打包设计 (macOS + Linux + Windows 同时 GH Actions 发布)
- 构建策略: GH Actions + Docker 本地
- macOS: 不签名 / 跳过公证
- Linux: deb + AppImage + rpm
- Release 触发: tag 推送 + 4 处版本一致性
- update checker: 按 OS + 扩展名自动选 asset

完整内容: docs/superpowers/specs/2026-07-29-multi-platform-packaging-design.md
2026-07-30 00:12:19 +08:00
954801926@qq.com 084883a54a fix(update): 切换 check_for_update 至 GitHub Releases API
- URL: git.codingplan.site Gitea API -> api.github.com/repos/billowliu2/KimiSwitch
- 增加 User-Agent header (GitHub 403 without UA)
- User-Agent: KimiSwitch/<CARGO_PKG_VERSION>
- 包目标只跟随 v0.6.0+ 历史版本仍留在 git.codingplan.site
2026-07-29 23:57:03 +08:00
954801926@qq.com fb530c85ca docs: bump to v0.6.0, point historical versions to git.codingplan.site
- badge: v0.5.2 -> v0.6.0, link to GitHub releases
- release history: replace v0.5.1 'latest' with v0.6.0 notes
- history section: link git.codingplan.site for v0.5.x and earlier
- .gitignore: exclude .release* (private release-notes drafts)
- README_EN: same updates in English
2026-07-29 23:53:29 +08:00
954801926@qq.com 919b9e49f5 merge: integrate Dev_260708 into main 2026-07-29 23:36:42 +08:00
954801926@qq.com 549c8b5b13 chore: init main branch 2026-07-29 23:36:41 +08:00
KimiSwitch Dev 2614f80d0f v0.6.0: 预设供应商 + 供应商账单查询
## 新增

- 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic /
  DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun /
  SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio /
  火山方舟),通过 PresetPickerModal 一键填表
- 供应商余额/套餐查询(卡片底部 UsageFooter):余额类
  (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) +
  套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL +
  force_refresh + 并发 ≤3
- Rust 端 services/ 模块(balance / coding_plan / usage_types),
  参考 cc-switch(MIT,© Jason Young)实现
- query_provider_usage 命令:Rust 端从 config 加载 key,
  不经 IPC 序列化 API key
- usageKinds 持久化到 SQLite settings(JSON 数组),
  load 时合并回 config,导出 config.toml 时不写
- detect_provider 启发式:旧用户升级后自动获得账单支持
- save-time 校验:未完成供应商(缺 api_key / base_url / 模型)
  保存时弹 confirm 列出原因;新增未提交 back 时静默 drop
- docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档)
- docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单)

## 修复

- handleDuplicateProvider 的 alias.slice 漏洞:老数据
  非规范 alias 复制不再错位
- handleSelectPreset 不再 auto-save,半成品不会落盘
- handleSwitchProvider 校验取消时自动 refresh 回滚,
  不会出现内存改 / 盘没动的脏态

## 文档

- README / README_EN 致谢段补 cc-switch(预设 + 账单)
- README 已知限制与后续建议段(/reload 上游限制调研结论)
- i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key

详见 docs/PROPOSAL-presets-and-usage.md 与
docs/VERIFICATION-CHECKLIST.md
2026-07-29 20:59:53 +08:00
36 changed files with 5022 additions and 142 deletions

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
version-check:
name: Version consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Verify tag matches package.json / Cargo.toml / tauri.conf.json
run: |
TAG="${GITHUB_REF_NAME}"
chmod +x scripts/check-version.sh
./scripts/check-version.sh "$TAG"
build:
name: Build (${{ matrix.platform }})
needs: version-check
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-latest
args: --bundles deb,appimage,rpm
- platform: macos-latest
args: --bundles app,dmg
- platform: windows-latest
args: --bundles msi
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- uses: dtolnay/rust-toolchain@stable
with:
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Build and upload release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
releaseBody: |
## ${{ github.ref_name }}
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app`
- **Linux**: `.deb` / `.AppImage` / `.rpm`
Full changelog: see commit history.
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
args: ${{ matrix.args }}
+1
View File
@@ -13,3 +13,4 @@ Snipaste_*.png
# 内部设计规范与实施计划,仅供本地查阅,不入版本库
docs/superpowers/
.release*
+38
View File
@@ -0,0 +1,38 @@
# Build Kimi Switch Linux packages (deb / AppImage / rpm) inside a clean
# Ubuntu 22.04 container. Used both locally (`docker compose run`) and as a
# reference for GH Actions' ubuntu-latest job.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV CARGO_TERM_COLOR=always
ENV RUSTUP_HOME=/usr/local/rustup
ENV CARGO_HOME=/usr/local/cargo
ENV PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
build-essential \
pkg-config \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
patchelf \
file \
nodejs \
npm \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
RUN cargo install tauri-cli --version "^2.0" --locked
WORKDIR /workspace
CMD ["/bin/bash"]
+75 -32
View File
@@ -8,8 +8,9 @@
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
[![Version](https://img.shields.io/badge/release-v0.6.0-brightgreen)](https://github.com/billowliu2/KimiSwitch/releases/tag/v0.6.0)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Release](https://github.com/billowliu2/KimiSwitch/actions/workflows/release.yml/badge.svg)](https://github.com/billowliu2/KimiSwitch/releases/latest)
---
@@ -30,6 +31,7 @@
- [打包发布](#打包发布)
- [发布历史](#发布历史)
- [常见问题](#常见问题)
- [已知限制与后续建议](#已知限制与后续建议)
- [安全提示](#安全提示)
- [致谢](#致谢)
@@ -50,6 +52,16 @@
- **主题/语言切换**:设置面板中切换深色/浅色/跟随系统,简体中文/English
- **Windows 友好**:单实例;最小化保留任务栏按钮;关闭按钮隐藏到托盘
## 下载
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名** —— 首次启动请右键 → 打开绕过 Gatekeeper)
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
历史版本(v0.5.x 及之前)请前往 [git.codingplan.site 仓库](https://git.codingplan.site/admin/KimiCodeSwitch/releases)。
构建说明请见 [`docs/BUILD.md`](./docs/BUILD.md)。
## 核心特性
| 分类 | 功能 |
@@ -467,7 +479,7 @@ cargo test
- [ ] 切换语言后 UI 文案立即更新
- [ ] 启动检查更新 → 下载 → 引导安装
- [ ] 拉取模型时 `max_context_size` 自动填充
- [ ] 切换供应商后 Kimi Code 会话 `/reload` 立即生效
- [ ] 切换供应商后 Kimi Code 会话经 `/reload` + `/model` 选回默认(或 `/exit` 重开会话)后生效
## 打包发布
@@ -497,43 +509,30 @@ Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已
## 发布历史
### v0.5.1(最新)
### v0.6.0(最新)
- 窗口/托盘:最小化保留任务栏;关闭 X 隐藏到托盘;托盘左键始终显示并聚焦
- 模型用量表:模型列宽度自适应,长模型名完整显示不再截断
- 模型能力编辑:只保留"思考",其余能力仍由 models.dev 自动推导
- 模型映射表格:行分隔线改为主题感知,修复浅色模式突兀问题
- **15 条主流供应商预设**:Kimi Coding / Moonshot / Anthropic / DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun / SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio / 火山方舟,通过 `PresetPickerModal` 一键填表
- **供应商余额 / 套餐查询**(卡片底部 `UsageFooter`):余额类(DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita)+ 套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL + `force_refresh` + 并发 ≤3
- **Rust 端 `services/` 模块**(`balance` / `coding_plan` / `usage_types`),参考 cc-switch(MIT,© Jason Young)实现
- `query_provider_usage` 命令:Rust 端从 config 加载 key,不经 IPC 序列化 API key
- `usageKinds` 持久化到 SQLite settings(JSON 数组),load 时合并回 config,导出 `config.toml` 时不写
- `detect_provider` 启发式:旧用户升级后自动获得账单支持
- save-time 校验:未完成供应商(缺 api_key / base_url / 模型)保存时弹 confirm 列出原因;新增未提交 back 时静默 drop
- 修复:`handleDuplicateProvider` 的 `alias.slice` 漏洞;`handleSelectPreset` 不再 auto-save;`handleSwitchProvider` 校验取消时自动 refresh 回滚
- 新增文档:[`docs/PROPOSAL-presets-and-usage.md`](./docs/PROPOSAL-presets-and-usage.md)(v0.4-draft 实施档)、[`docs/VERIFICATION-CHECKLIST.md`](./docs/VERIFICATION-CHECKLIST.md)(70+ 项手动验证清单)
### v0.5.0
### 历史版本
- 供应商操作图标化(lucide-react):启用 / 编辑 / 复制 / 测试连通 / 删除
- 重复供应商(深拷贝供应商 + 全部模型)
- 连通性测试(绿/橙/红气泡 + 6 秒自动消失)
- 每日趋势柱状图锚定到面板底部
### v0.4.1
- 模型发现分页(OpenAI / Anthropic / Google),减少大列表命中限制
### v0.4.0
- 完善的图标系统(移植自 cc-switch 图标库)
- 设置面板(主题、语言)
- 启动版本检查
### v0.3.0
[移植自 kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard):
- 用量仪表盘:8 KPI、每日趋势、全年热力图、最近请求翻页
- 会话管理:按工作区浏览、预览、归档、批量删除
- 时区修复:`today` / 热力图 / `day_key` 改用本地日历日
- Pi 选项隐藏
v0.5.x 及之前版本(含 v0.5.1 / v0.5.0 / v0.4.1 / v0.4.0 / v0.3.0)请前往 [git.codingplan.site 仓库](https://git.codingplan.site/admin/KimiCodeSwitch/releases) 查看。GitHub 仓库仅作为 v0.6.0 及以后版本的发布渠道。
## 常见问题
**Q: 切换供应商后 Kimi Code 没生效?**
A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~/.kimi-code/config.toml`)。应用会在 UI 上提示。
A: 需要分两步:
1. 在 Kimi Code 会话里执行 `/reload`,让 CLI 重新读取 `~/.kimi-code/config.toml`(此时模型**下拉列表**会刷新);
2. **再执行一次 `/model` 选回新的默认模型**,或者直接 `/exit` 重开会话。
仅 `/reload` **不会**把新的 `default_model` 自动套用到当前会话——这是 Kimi Code 的已知行为,详见下方[已知限制与后续建议](#已知限制与后续建议)。应用会在 UI 上提示这两步。
**Q: 切换会覆盖其他供应商吗?**
A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。
@@ -565,6 +564,48 @@ A: 关闭按钮(X)已改为隐藏到托盘。点击托盘图标(菜单栏
**Q: 检查更新是怎么触发的?**
A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网络时静默失败,不弹错)。也可手动:设置弹窗 → 版本 → 检查更新。下载有进度条,下载完成后会有"打开安装包"按钮。
## 已知限制与后续建议
### `/reload` 不会切换当前会话的默认模型(Kimi Code 上游限制)
**现象**:在 Kimi Switch 切换供应商后,`config.toml` 顶层的 `default_model` 已经被正确更新;回到 Kimi Code CLI 执行 `/reload`,模型**下拉列表**能列出新的默认模型,但**当前会话实际仍在使用旧模型**(底部状态栏显示的模型、真正发出的请求都没变)。
**根因**(已对照 Kimi Code 源码确认,不是 Kimi Switch 的 bug):
- `/reload` 命令(`apps/kimi-code/src/tui/commands/reload.ts`)在刷新 `availableModels / availableProviders` 之后,**没有**把 `config.defaultModel` 重新套用到当前会话的 agent 上。
- 会话当前的模型(`agent.config.modelAlias`)来自会话创建时的 `options.model ?? config.defaultModel`(`packages/agent-core/src/rpc/core-impl.ts:438-440`),并通过 `records.logRecord` 持久化进会话日志;`Agent.resume()` 调 `records.replay()` 时会**回放这些历史记录**,从而把模型恢复成"上次手动选的",而不是 `config.defaultModel`。
- 单元测试(`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`)也只断言"模型列表刷新了",没有任何"当前会话切到新默认模型"的断言——因为实现层就没做这一步。
- 全仓检索 `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` 等关键字**零命中**,说明 Kimi Code 目前**没有**任何"reload 时跟随 default_model"的开关。
**Workaround(当前可用)**:
1. `/reload` 后再执行 `/model`,手动选一次新默认模型;或
2. `/exit` 退出会话再重开(重新走 `createSession` 路径,会把 `config.defaultModel` 套到新会话)。
**建议反馈给 Kimi Code 上游的修复点**:
- 文件:`apps/kimi-code/src/tui/commands/reload.ts` 的 `handleReloadCommand`。
- 位置:`applyRuntimeConfig(host, config)` 之后,补一段把 `config.defaultModel` 同步到当前会话 agent 的逻辑,例如:
```ts
const newDefault = config.defaultModel;
const current = /* host.session 当前 agent 的 modelAlias */;
if (newDefault && newDefault !== current) {
await host.session.mainAgent.config.update({ modelAlias: newDefault });
}
```
- 配套断言:在 `reload.test.ts` 增加对"reload 后会话当前模型 == 新 `defaultModel`"的测试,防回归。
- 可选增强:暴露一个会话级"follow default on reload"开关,或当会话旧 alias 已被删除时自动回退到 `default_model`。
> 注:以上建议**尚未提交给官方**,记录于此供后续跟进。Kimi Switch 侧只能保证 `config.toml` 写入正确,无法绕开 Kimi Code 自身的 `/reload` 语义。
### 其它后续建议(按优先级)
- **P1 — 模型别名规范**:早期裸名或 `-1/-2` 后缀的别名(如 `kimi-k3`、`glm-5-2-1`)已批量重命名为 `provider/model` 形式;新增供应商时建议**强制**采用规范命名,避免再出现补全后缀。可在保存前加一层 lint。
- **P1 — 用量趋势多维度**:用量趋势已支持"模型用量趋势 / 供应商模型用量趋势"两个 Tab;后续可考虑增加"按工作区"或"按 Token / 按费用"的第三维度切换。
- **P2 — 周期检查更新**:当前为启动 + 每 8 小时一次;后续可考虑做成可配置项(设置面板里设周期 / 关闭)。
- **P2 — 跨平台**:目前只产 MSI,代码不依赖 Windows 专属 API;待 Tauri v2 的 macOS/Linux bundle 配置后即可验证。
- **P3 — 配置校验增强**:`validators.rs` 当前偏基础,可补:`base_url` 合法性、`env` 与 `api_key` 互斥、`oauth` 段完整性。
## 安全提示
- API Key 明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存**
@@ -579,6 +620,8 @@ A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网
供应商品牌图标资源(`src/icons/extracted/`)与图标选择器(`src/components/IconPicker.tsx`)参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),感谢原作者的开源贡献。
供应商预设结构(`src/config/providerPresets.ts`)与余额/套餐查询实现(`src-tauri/src/services/`)同样参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young)。
---
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
+78 -32
View File
@@ -8,8 +8,9 @@
[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org)
[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org)
[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases)
[![Version](https://img.shields.io/badge/release-v0.6.0-brightgreen)](https://github.com/billowliu2/KimiSwitch/releases/tag/v0.6.0)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Release](https://github.com/billowliu2/KimiSwitch/actions/workflows/release.yml/badge.svg)](https://github.com/billowliu2/KimiSwitch/releases/latest)
---
@@ -30,6 +31,7 @@
- [Build & Release](#build--release)
- [Release History](#release-history)
- [FAQ](#faq)
- [Known Limitations & Next Steps](#known-limitations--next-steps)
- [Security Notes](#security-notes)
- [Credits](#credits)
@@ -50,6 +52,16 @@
- **Theme / language** — dark / light / follow-system themes; Simplified Chinese / English
- **Windows polish** — single instance; minimize keeps the taskbar button; close hides to tray
## Downloads
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg` (**unsigned** — first launch needs right-click → Open to bypass Gatekeeper)
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
Historical versions (v0.5.x and earlier) live on the [git.codingplan.site repository](https://git.codingplan.site/admin/KimiCodeSwitch/releases).
Build instructions: [`docs/BUILD.md`](./docs/BUILD.md).
## Key Features
| Category | Feature |
@@ -463,7 +475,7 @@ No automated tests yet. Suggested manual checklist:
- [ ] Language switch updates UI text in place
- [ ] Update check → download → guided install
- [ ] `max_context_size` auto-fills on model fetch
- [ ] Kimi Code `/reload` picks up new config after switching
- [ ] New default model takes effect after `/reload` + `/model` (or after `/exit` and restart)
## Build & Release
@@ -493,43 +505,33 @@ The first run downloads:
## Release History
### v0.5.1 (latest)
### v0.6.0 (latest)
- Window / tray: minimize keeps the taskbar button; close X hides to tray; tray left-click always shows + focuses
- Usage table: model column auto-width, long names no longer truncated
- Capability editor: only `thinking` exposed; other capabilities still auto-derived from models.dev
- Model mapping table: theme-aware row divider, no more harsh line in light mode
- **15 mainstream provider presets**: Kimi Coding / Moonshot / Anthropic / DeepSeek / Zhipu GLM / z.ai / Alibaba Bailian / MiniMax / StepFun / SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio / Volcengine Ark — one-click form fill via `PresetPickerModal`
- **Provider balance / plan usage query** (per-card `UsageFooter`):
- Balance: DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita
- Plan: Kimi For Coding / Zhipu GLM / MiniMax
- 5-min stale TTL + `force_refresh` + concurrency ≤ 3
- **Rust `services/` module** (`balance` / `coding_plan` / `usage_types`), adapted from cc-switch (MIT, © Jason Young)
- `query_provider_usage` command: loads API key from config in Rust, never serialized over IPC
- `usageKinds` persisted in SQLite `settings` (JSON array), merged back into config on load, omitted from `config.toml` export
- `detect_provider` heuristic: existing users automatically gain balance/plan support after upgrade
- Save-time validation: incomplete providers (missing `api_key` / `base_url` / models) trigger a confirm dialog listing the reasons; uncommitted `add` is silently dropped on back
- Fixes: `handleDuplicateProvider` `alias.slice` bug; `handleSelectPreset` no longer auto-saves; `handleSwitchProvider` auto-refresh rollback when validation is cancelled
- New docs: [`docs/PROPOSAL-presets-and-usage.md`](./docs/PROPOSAL-presets-and-usage.md) (v0.4-draft implementation), [`docs/VERIFICATION-CHECKLIST.md`](./docs/VERIFICATION-CHECKLIST.md) (70+ manual checks)
### v0.5.0
### Historical versions
- Iconified actions (lucide-react): activate / edit / duplicate / test / delete
- Duplicate provider (deep-copy a provider + all its models)
- Connectivity test (green / orange / red bubble + 6s auto-dismiss)
- Daily trend bars anchored to the panel bottom
### v0.4.1
- Model discovery pagination (OpenAI / Anthropic / Google), fewer large-list limits hit
### v0.4.0
- Polished icon system (library adapted from cc-switch)
- Settings modal (theme / language)
- Launch-time update check
### v0.3.0
Ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard):
- Dashboard: 8 KPIs, daily trend, full-year heatmap, paginated recent requests
- Session manager: per-workspace browsing, preview, archive, bulk delete
- Timezone fix: `today` / heatmap / `day_key` now use local calendar day
- Pi option hidden from the UI
Versions v0.5.x and earlier (v0.5.1 / v0.5.0 / v0.4.1 / v0.4.0 / v0.3.0) live on the [git.codingplan.site repository](https://git.codingplan.site/admin/KimiCodeSwitch/releases). The GitHub repo is the release channel from v0.6.0 onward.
## FAQ
**Q: Switching providers didn't take effect in Kimi Code?**
A: Run `/reload` inside the Kimi Code session (the CLI only re-reads `~/.kimi-code/config.toml` on reload). The app shows this hint in the UI.
A: Two steps are required:
1. Run `/reload` inside the Kimi Code session so the CLI re-reads `~/.kimi-code/config.toml` (this refreshes the model **dropdown list**);
2. **Then run `/model` to pick the new default**, or simply `/exit` and restart the session.
Running `/reload` alone **does not** apply the new `default_model` to the current session — this is a known Kimi Code behaviour, see [Known Limitations & Next Steps](#known-limitations--next-steps) below. The app surfaces both steps in its UI.
**Q: Does switching overwrite other providers?**
A: No. Kimi Code's `config.toml` is always written with all providers, only `default_model` decides which one is active. This matches the CLI's native `/provider` behaviour.
@@ -561,6 +563,48 @@ A: The close X hides to the tray. Click the tray icon (menu bar / system tray) t
**Q: How is the update check triggered?**
A: It silently checks once on launch, then automatically every 8 hours (silent failure with no network, no error popup). You can also trigger it manually: Settings modal → Version → Update check. Downloads have a progress bar; once done, an "Open installer" button appears.
## Known Limitations & Next Steps
### `/reload` does not switch the active session's default model (Kimi Code upstream limitation)
**Symptom**: After switching a provider in Kimi Switch, `default_model` at the top level of `config.toml` is correctly updated. Back in Kimi Code CLI you run `/reload`: the model **dropdown** now lists the new default, but the **current session still uses the old model** (status-bar model and the actual outgoing requests haven't changed).
**Root cause** (verified against the Kimi Code source — **not** a Kimi Switch bug):
- The `/reload` command (`apps/kimi-code/src/tui/commands/reload.ts`) refreshes `availableModels / availableProviders` but **never** re-applies `config.defaultModel` to the active session's agent.
- The session's current model (`agent.config.modelAlias`) comes from `options.model ?? config.defaultModel` at session-create time (`packages/agent-core/src/rpc/core-impl.ts:438-440`) and is persisted to the session log via `records.logRecord`. `Agent.resume()` then calls `records.replay()`, which **replays those records** — so the model snaps back to "what was last picked", not to `config.defaultModel`.
- The unit test (`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`) only asserts "the model list refreshed"; there is **no** assertion that "the active session switched to the new default" — because the implementation never does that step.
- A repo-wide search for `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` yields **zero** hits, so Kimi Code has **no** "follow default on reload" toggle today.
**Workaround (works today)**:
1. After `/reload`, run `/model` and pick the new default manually; or
2. `/exit` and restart the session (the `createSession` path re-applies `config.defaultModel` to the fresh session).
**Suggested upstream fix for Kimi Code**:
- File: `apps/kimi-code/src/tui/commands/reload.ts`, in `handleReloadCommand`.
- Spot: right after `applyRuntimeConfig(host, config)`, add a sync of `config.defaultModel` to the active session's agent, e.g.:
```ts
const newDefault = config.defaultModel;
const current = /* host.session's current agent modelAlias */;
if (newDefault && newDefault !== current) {
await host.session.mainAgent.config.update({ modelAlias: newDefault });
}
```
- Matching test: add an assertion in `reload.test.ts` that "after reload, the session's active model == new `defaultModel`" to guard against regressions.
- Optional enhancement: expose a session-level "follow default on reload" toggle, or auto-fall-back to `default_model` when the session's previous alias has been deleted.
> Note: this suggestion has **not** been filed upstream yet; it's recorded here for follow-up. Kimi Switch can only guarantee `config.toml` is written correctly — it cannot work around Kimi Code's own `/reload` semantics.
### Other next-step suggestions (by priority)
- **P1 — Model alias convention**: early bare-name or `-1/-2` suffixed aliases (e.g. `kimi-k3`, `glm-5-2-1`) have been bulk-renamed to the `provider/model` form; when adding a new provider, **force** this convention to prevent re-introducing suffixed fallbacks. Add a save-time lint.
- **P1 — Multi-dimensional usage trends**: the usage trend already supports two tabs ("model trend" / "provider×model trend"); a future third dimension (per-workspace, or switch the Y axis between tokens and cost) is worth considering.
- **P2 — Periodic update check**: currently launch + every 8h; consider making the cadence configurable (set period / disable in the settings modal).
- **P2 — Cross-platform**: only MSI is produced today, but the code has no Windows-only dependencies; once Tauri v2 macOS/Linux bundling is configured, it can be verified.
- **P3 — Stronger config validation**: `validators.rs` is fairly minimal today; worth adding: `base_url` validity, `env` vs `api_key` mutual-exclusivity, `oauth` block completeness.
## Security Notes
- API keys are stored in plaintext in local SQLite and agent native configs — **do not store them on shared computers**
@@ -575,6 +619,8 @@ The usage dashboard and session manager are ported from [kimicode-dashboard](htt
The provider brand icon library (`src/icons/extracted/`) and the icon picker (`src/components/IconPicker.tsx`) are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). Many thanks to the original author.
The provider presets structure (`src/config/providerPresets.ts`) and the balance/plan usage query implementation (`src-tauri/src/services/`) are likewise adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young).
---
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
+13
View File
@@ -0,0 +1,13 @@
services:
build-linux:
build: .
container_name: kimiswitch-build-linux
volumes:
- .:/workspace
- cargo-cache:/usr/local/cargo/registry
- target-cache:/workspace/src-tauri/target
working_dir: /workspace
volumes:
cargo-cache:
target-cache:
+86
View File
@@ -0,0 +1,86 @@
# Build
Docker (Linux), native (any platform), and CI (multi-platform) build instructions.
## Local builds by host
### Windows (current primary dev environment)
Prerequisites: Rust stable, Node 20, Microsoft C++ Build Tools, WebView2.
```bash
npm ci
npm run tauri build -- --bundles msi
```
Output: `src-tauri/target/release/bundle/msi/`.
### macOS
Prerequisites: Xcode command-line tools, Rust stable, Node 20.
```bash
npm ci
npm run tauri build -- --bundles app,dmg
```
Output: `src-tauri/target/release/bundle/{macos,dmg}/`.
Unsigned: first launch requires right-click → Open to bypass Gatekeeper.
### Linux (native)
Prerequisites: see `Dockerfile` for the full apt-get list (webkit2gtk-4.1, librsvg2, etc.).
```bash
npm ci
npm run tauri build -- --bundles deb,appimage,rpm
```
Output: `src-tauri/target/release/bundle/{deb,appimage,rpm}/`.
### Linux via Docker (works on Windows / macOS too)
```bash
docker compose build build-linux
docker compose run --rm build-linux bash -c '
npm ci &&
npm run tauri build -- --bundles deb,appimage,rpm
'
```
Output is mounted back to `src-tauri/target/release/bundle/` on the host.
## CI: GitHub Actions
`.github/workflows/release.yml` runs on `git tag v* && git push origin v*`.
1. `version-check` runs `scripts/check-version.sh` to ensure tag matches `package.json`, `Cargo.toml`, `tauri.conf.json`.
2. Three parallel jobs build for `ubuntu-latest`, `macos-latest`, `windows-latest` using `tauri-action`.
3. Artifacts are uploaded to the GitHub Release (auto-draft for non-prerelease tags).
To cut a release:
```bash
# 1. Bump version in three places
# package.json: "version": "0.7.0"
# src-tauri/Cargo.toml: version = "0.7.0"
# src-tauri/tauri.conf.json: "version": "0.7.0"
# 2. Commit
git commit -am "chore(release): bump to v0.7.0"
# 3. Tag and push
git tag v0.7.0
git push origin v0.7.0
```
## Update checker asset selection
`src-tauri/src/commands.rs::pick_asset_for_current_os` picks the right asset by file extension:
| OS | Extension |
|---------|------------|
| Windows | `.msi` |
| macOS | `.dmg` |
| Linux | `.AppImage` (preferred; deb / rpm downloadable manually from the release page) |
If the preferred extension is missing, the UI falls back to opening the release page.
+625
View File
@@ -0,0 +1,625 @@
# 提案:预设供应商 + 供应商账单查询
> **状态**:v0.4-draft(v0.3-draft 已实施;P0 全部落地,新增 v0.4 修订)
> **作者**:Kimi Switch 内部
> **目标版本**:0.6.0
> **参考**:cc-switch(MIT,© Jason Young)— 仅借鉴字段结构与 Rust 用量查询层模式
> **Kimi Code 官方文档**:[providers 配置](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)、[配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html)
---
## 一、背景与目标
当前 Kimi Switch 已有"添加供应商"入口(`src/App.tsx:141 handleAddProvider`)直接凭空造一个 `provider-N`,用户需要手填 base_url、provider_type、模型映射等十几个字段。新用户面对一份空白表单门槛很高。
同时,用量仪表盘(`src/components/dashboard/`)展示的是**本地会话日志**聚合出来的 token 消耗,与"供应商账户里还剩多少钱/套餐还剩多少"完全无关——后者才能告诉用户"今天是不是该充值了"。
**目标**:
1. **预设供应商**:用户点"添加"时弹出预设选择面板,选中后表单字段自动填好(保留任意编辑权);支持至少 12 条主流国内/海外供应商。
2. **供应商账单查询**:在 `ProviderList` 卡片底部展示余额/套餐剩余,调用供应商鉴权 API 实时拉取。
**非目标**:
- 不做 OAuth 流程(Claude/Codex 官方订阅)—— 我们不接管 CLI 登录态。
- 不做"用户自定义 JS 脚本"查询(rquickjs 依赖过重)。
- 不做用量费用看板(已有本地仪表盘)。
---
## 二、参考:cc-switch 关键事实
### 2.1 预设供应商
- **位置**:纯前端 TS 数组,`src/config/*ProviderPresets.ts`,按目标 CLI 工具拆 8+1 个文件,每文件 22–73 条。
- **数据契约**(`claudeProviderPresets.ts:25-74`):
- `name / nameKey(i18n) / websiteUrl / apiKeyUrl`
- `settingsConfig`(真正写进配置的对象,**结构因工具而异**:Claude 用 `ANTHROPIC_*` env、Codex 用 auth.json + TOML)
- `category`: `official | cn_official | third_party | aggregator`
- `isOfficial / isPartner / primePartner`
- `apiKeyField`(Claude 特有)、`endpointCandidates[]`、modelsUrl
- `theme / icon / iconColor / apiFormat`
- **触发**:`ProviderForm` 表单顶部内嵌 `ProviderPresetSelector`(grid 按钮 + 搜索 + 排序);选中即 `form.reset` 灌字段。
- **模型清单来源**:硬编码写进 `settingsConfig.env` 或 `modelCatalog[]`;**不**走 models.dev,**不**在预设里调 API。动态拉模型是另一个独立按钮。
- **许可证**:MIT,可直接借鉴字段结构与预设数据。
### 2.2 账单/用量查询
cc-switch 在 Rust 端实现了 6 套并行的查询层(详见 `src-tauri/src/services/`),与本项目相关的有 2 套:
| 机制 | 文件 | 覆盖 | 关键端点 |
|---|---|---|---|
| **A. 余额查询** | `balance.rs` | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita | `GET {base_url}/user/balance` 等 |
| **B. Token Plan 套餐** | `coding_plan.rs` | **Kimi For Coding** / 智谱 GLM / MiniMax / 火山 / ZenMux | `GET https://api.kimi.com/coding/v1/usages` |
**统一返回契约**(`balance.rs:1-10` 注释):
```rust
struct UsageResult {
success: bool,
data: Option<Vec<UsageData>>,
error: Option<String>,
}
struct UsageData {
plan_name: Option<String>,
remaining: Option<f64>,
total: Option<f64>,
used: Option<f64>,
unit: Option<String>,
is_valid: Option<bool>,
resets_at: Option<String>,
}
```
**错误通道语义**(这套设计很成熟,建议照搬):
- `Err(_)` = 瞬时失败(网络/超时/读体中断)→ 前端 retry + keep-last-good。
- `Ok(success:false)` = 确定性失败(空 key / 401 / 非 2xx / JSON 解析失败)→ 直接透出错误文案。
**鉴权**:统一 `Authorization: Bearer <api_key>`。
**路由**:`detect_provider(base_url)` 按子串匹配 → 调对应 `query_xxx`。
---
## 三、KimiSwitch 落点
| 现有结构 | 落点 |
|---|---|
| `src/App.tsx:141 handleAddProvider`(直接造空 `provider-N`)| 改为先打开 `PresetPickerModal` |
| `src/components/ProviderEdit.tsx` basic tab | 顶部加"切换预设"下拉(v1 不做) |
| `src-tauri/src/models.rs Provider` | 不动;预设转 Provider 由前端完成 |
| `src-tauri/src/commands.rs` | 新增 `query_provider_usage(base_url, api_key, usage_kind)` |
| `src-tauri/src/`(无 `services/`)| 新建 `services/{mod.rs, usage_types.rs, balance.rs, coding_plan.rs}` |
| `src/components/ProviderList.tsx` 卡片 | 卡片底部新增 `<UsageFooter>` |
| `src/i18n/{zh,en}.ts` | 加 i18n key(preset 名 / 账单相关) |
| `src/lib/models-dev.ts` | 复用:补全预设 model 的 `maxContextSize` / `capabilities` |
**已有依赖**(已满足):
- `Cargo.toml`: `reqwest = { version = "0.12", features = ["json","rustls-tls","stream"] }` ✅
- `Cargo.toml`: `chrono = { version = "0.4", features = ["serde"] }` ✅
**私有字段存储约定**(**重要变更:v0.3 修订**):
早前提议把 `usageKinds` 写进 `provider.raw_other["kimi-switch.usageKinds"]` 通过 `config.toml` 透传。**此方案否决**——`kimi-switch.` 前缀不是 Kimi Code 注册的 namespace,"安全"只是 Zod 非 strict + raw 透传的实现现状,无版本兼容承诺。
**v0.3 改为:**
- `usageKinds` 存进 **SQLite** 的 `settings` 表(已有 per-agent 设置表,key 形式 `usage_kinds:<provider_name>`,值为 JSON 数组字符串,如 `["balance:deepseek"]` 或 `["plan:kimi_coding","balance:custom"]`)
- `load_agent_config_command` 在返回 config 时**自动合并** SQLite settings 中的 usageKinds 到每个 provider(前端消费时是无感的)
- Rust 端导出 `config.toml` 时**完全不写** `usageKinds` 字段
- 卸载 / 退出登录 / 备份 `config.toml` 都不影响 `usageKinds`(独立存储)
**优势**:
1. 不污染用户的 Kimi Code 配置(终审 A8 解决)
2. 用户改 `provider_type` / `base_url` 后,Rust 端 `detect_provider(base_url)` 自动重新决定 `usageKinds`(B-D1 解决)
3. 旧用户升级 0.6.0 时,所有匹配 `detect_provider` host 列表的 provider **自动获得** `usageKinds`(B-D2 解决)
4. 数组形式让同一 provider 同时支持套餐 + 余额查询(如 SiliconFlow 未来加套餐)
---
## 四、方案 1:预设供应商
### 4.1 数据模型
新建 `src/config/providerPresets.ts`:
```ts
export interface ProviderPreset {
/** 唯一 key,作为默认 Provider name 使用,如 "deepseek" */
id: string;
/** 显示名(中文/英文) */
name: string;
/** i18n key,可选;存在时优先用 t(nameKey) */
nameKey?: string;
/** 官网/拿 key 的链接 */
websiteUrl?: string;
apiKeyUrl?: string;
/** 分类:排序 & 标签 */
category: "official" | "cn_official" | "third_party" | "aggregator" | "custom";
/** 真正写进 Provider 的字段 */
providerType: ProviderType;
/** 预设 base_url;`null` 表示无默认值(如 Anthropic、CodingPlan.site)。
* 当选中的 provider.type 对应的 `defaultBaseUrl()` 返回非空时,
* PresetPicker 会**用预设值覆盖它**,保存后以 `provider.base_url` 为唯一真相。 */
baseUrl: string | null;
/** 复用现有 IconPicker 命名(无品牌图标时按 base_url 走首字母兜底) */
icon?: string;
iconColor?: string;
/** 预填的模型映射(alias → model id)。
* 数组第一个 ⇒ 设 default_model。
* 数组为空时,default_model 留空,由用户通过「拉取模型」补齐。
*
* **alias 强制规范**:`provider/${modelId}` 形式 —— 例如 `deepseek/deepseek-chat`。
* 不能用裸 alias(如 `chat`),否则会破坏 `handleDuplicateProvider` 的
* `alias.slice(name.length)` 推导:若 provider=`copy`,会得到空串,
* 复制时所有模型都写到 `copy` 上,后者覆盖前者。 */
models: Array<{
alias: string;
model: string;
/** 可选:覆写显示名;缺省走 models.dev 推导 */
displayName?: string;
/** 可选:覆写 context;缺省走 models.dev [API→models.dev→正则] 三级 */
maxContextSize?: number;
/** 默认 ["thinking"] */
capabilities?: string[];
}>;
/** 关联方案 2:账单查询类型;缺省时不查。
* **v0.3 改为数组**:同一个供应商可能同时支持套餐 + 余额(如 SiliconFlow
* 既有平台余额又有套餐),数组形式让两端都查、UI 都展示。
* 注意:`plan:volcengine` **不在 v1 union** —— Volcengine 套餐需独立 AK/SK
* (不是推理 api_key),需要额外的 `access_key_id / secret_access_key` 参数,
* v1 不支持,留待 P2。 */
usageKinds?: ReadonlyArray<
| "balance:deepseek" | "balance:siliconflow" | "balance:openrouter"
| "balance:stepfun" | "balance:novita"
| "plan:kimi_coding" | "plan:zhipu" | "plan:minimax"
>;
}
export const providerPresets: ProviderPreset[] = [ /* … */ ];
```
**`presetToProviderAndModels()` 显式转换契约**(v0.3 重要补充):
```ts
export function presetToProviderAndModels(preset: ProviderPreset, options: {
existingProviderNames: Set<string>;
existingModelAliases: Set<string>;
}): {
provider: Provider;
models: Model[];
defaultModel: string; // 用于写入 config.default_model
usageKinds: ReadonlyArray<string> | undefined; // 用于写入 SQLite settings
} {
// Provider.name 冲突 → 追加 -2 / -3
let name = preset.id;
let n = 2;
while (options.existingProviderNames.has(name)) name = `${preset.id}-${n++}`;
// 模型 alias 冲突 → 追加 -2 / -3
const models: Model[] = preset.models.map((m, i) => {
let alias = `${name}/${m.model}`; // 强制规范
let k = 2;
while (options.existingModelAliases.has(alias)) alias = `${name}/${m.model}-${k++}`;
// max_context_size 4 级优先级
const ref = modelsDevRef(m.model);
const maxContextSize =
m.maxContextSize
?? ref?.maxContextSize
?? parseContextFromName(m.model)
?? DEFAULT_MAX_CONTEXT_SIZE; // 256000
return {
alias,
provider: name,
model: m.model,
max_context_size: maxContextSize,
display_name: m.displayName ?? ref?.displayName ?? alias,
capabilities: m.capabilities ?? ref?.capabilities ?? ["thinking"],
supports_1m: maxContextSize >= 1_000_000,
raw_other: {},
};
});
const provider: Provider = {
name,
provider_type: preset.providerType,
base_url: preset.baseUrl,
api_key: null, // 用户补
env: {},
note: null,
official_url: preset.websiteUrl ?? null,
managed: false,
enabled: true,
icon: preset.icon ?? null,
icon_color: preset.iconColor ?? null,
raw_other: {},
};
return {
provider,
models,
defaultModel: models[0]?.alias ?? "",
usageKinds: preset.usageKinds,
};
}
```
**字段映射表**(明确每条字段从哪来):
| Preset 字段 | Provider/Model 字段 | 备注 |
|---|---|---|
| `providerType` | `provider.provider_type` | 直透 |
| `baseUrl` | `provider.base_url` | `null` → 留空 |
| `websiteUrl` | `provider.official_url` | |
| `icon` | `provider.icon` | `null` → 走首字母兜底 |
| `iconColor` | `provider.icon_color` | |
| `models[].alias` | `models[].alias` | **强制 `${uniqueProviderName}/${modelId}`** |
| `models[].model` | `models[].model` | 直透 |
| `models[].displayName` | `models[].display_name` | 缺省走 models.dev |
| `models[].maxContextSize` | `models[].max_context_size` | 优先级链 |
| `models[].capabilities` | `models[].capabilities` | 缺省 `["thinking"]` |
| `id` | `provider.name` | 冲突 → `-2` |
| `usageKinds` | SQLite `settings` 表 | **不**写 `config.toml`;数组形式支持同一供应商多种查询 |
### 4.2 v1 预设清单(15 条)
| id | 供应商 | category | provider_type | base_url | icon | iconColor | usageKinds | 备注 |
|---|---|---|---|---|---|---|---|---|
| `anthropic` | Anthropic 官方 | official | anthropic | `null` | `anthropic` | — | — | 无默认 base_url;Kimi Code 自动走 `api.anthropic.com` |
| `kimi-coding` | Kimi Code 托管服务(会员订阅)| official | **kimi** | `https://api.kimi.com/coding/v1` | `kimi` | — | `[plan:kimi_coding]` | Kimi Code 托管服务走 `kimi` 类型(OpenAI 兼容 + Kimi identity headers + 视频上传)。Base URL 同时兼容 Anthropic 协议 `https://api.kimi.com/coding/`,但 preset 用 OpenAI 兼容路径 |
| `moonshot` | Moonshot Platform API(按量付费)| cn_official | **kimi** | `https://api.moonshot.ai/v1` | `kimi` | — | — | Moonshot 平台 API 密钥用户;官方文档明确 `kimi` 类型默认 base_url 即此(与 `.cn` 互为别名) |
| `deepseek` | DeepSeek | cn_official | openai | `https://api.deepseek.com/v1` | `deepseek` | — | `[balance:deepseek]` | — |
| `zhipu` | 智谱 GLM | cn_official | openai | `https://open.bigmodel.cn/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — |
| `zai` | z.ai(智谱海外)| third_party | openai | `https://api.z.ai/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — |
| `bailian` | 阿里百炼 | cn_official | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `bailian` | — | — | — |
| `minimax` | MiniMax | cn_official | openai | `https://api.minimaxi.com/v1` | `minimax` | — | `[plan:minimax]` | — |
| `stepfun` | StepFun(阶跃星辰)| cn_official | openai | `https://api.stepfun.com/v1` | `stepfun` | — | `[balance:stepfun]` | — |
| `siliconflow` | 硅基流动 | cn_official | openai | `https://api.siliconflow.cn/v1` | `siliconflow` | 显式补色(默认灰色)| `[balance:siliconflow]` | `siliconflow` 在 `src/icons/extracted/index.ts:93` 有 SVG,但 `metadata.ts` 没有;需显式 `iconColor` |
| `novita` | Novita AI | third_party | openai | `https://api.novita.ai/v3` | `novita` | — | `[balance:novita]` | 注意 `/v3`(非 `/v1`) |
| `openrouter` | OpenRouter | third_party | openai | `https://openrouter.ai/api/v1` | `openrouter` | — | `[balance:openrouter]` | — |
| `openai` | OpenAI 官方 | official | openai | `https://api.openai.com/v1` | `openai` | — | — | — |
| `google-genai` | Google AI Studio | official | google-genai | `https://generativelanguage.googleapis.com` | `google` | — | — | — |
| `volcengine` | 火山方舟(仅推理)| cn_official | openai | `https://ark.cn-beijing.volces.com/api/v3` | `huoshan` | — | — | **v1 不支持套餐查询**(需 AK/SK),仅作推理 fallback |
**`codingplan`(自定义中转)v1 不做 preset**——价值低(无 base_url、无 usageKinds),与预设"一键填表"目标冲突。改在 `PresetPickerModal` 底部保留独立的"**+ 自定义配置**"按钮(来自空表单),与 cc-switch `ProviderPresetSelector.tsx:392-404` 一致。
**预估 icon 字段值校核**:上面表的 `icon` 值均来自 `src/icons/extracted/index.ts` 真实存在的 key;不能使用 `brands.ts` 推断 key 作显式 icon(两者命名空间不同)。P0 实现时若发现某个 key 不存在,需在 `src/icons/extracted/index.ts` 补 entry。
**模型清单里的 `maxContextSize` / `capabilities` 优先级**(与拉取模型同一体系):
1. 预设内置值(`ProviderPreset.models[].maxContextSize / capabilities / displayName` — 若有)
2. `src/lib/models-dev.ts` 快照查找(`getModelRef`)
3. 正则兜底(`src/lib/model-defaults.ts`)
4. 全部 miss → **`DEFAULT_MAX_CONTEXT_SIZE`**(`src/lib/model-defaults.ts:1,83-89` 实际为 **256000**,不是 128000)
> v0.3 修正:早期文档误写 128000;统一用 `DEFAULT_MAX_CONTEXT_SIZE` 常量,避免双源真相。
**预设里绝大多数模型只需放 `alias` + `model id`**;仅对 models-dev 快照无覆盖的新发模型(如国产首发)才填覆写字段。
### 4.3 UI 流程
**入口 A(v1 必做)`PresetPickerModal`**:
```
┌────────────────────────────────────────────────┐
│ 选择预设供应商 [X] │
├────────────────────────────────────────────────┤
│ 🔍[搜索] [排序:原始│A-Z] │
├────────────────────────────────────────────────┤
│ [K] Kimi Coding [官方] │
│ [A] Anthropic [官方] │
│ [D] DeepSeek [国产] │
│ [Z] 智谱 GLM [国产] │
│ …(grid 150px×n) │
│ [+自定义配置] │
├────────────────────────────────────────────────┤
│ 预设只是一键填表,后续可继续修改所有字段。 │
└────────────────────────────────────────────────┘
```
- 触发:① "添加"按钮 ② 顶部"+ 添加供应商"
- 选中预设后:
1. 用预设 `id` 作为默认 `Provider.name`(冲突时尾部追加 `-2`、`-3`…)
2. 把 `models[]` 转成 `Model` 加进 `config.models`,**每个 alias 逐一检测冲突**:alias 已存在 → 尾部追加 `-2` / `-3`;**第一个 model** 设为 `default_model`(经过去重后的 alias)
3. 写入 `provider.raw_other["kimi-switch.usageKinds"] = usageKinds`(沿用 raw_other 私有字段约定)
4. 跳到 `ProviderEdit` 让用户填 `api_key`(其余字段已预填)
- 分类徽章:官方 = 蓝、国产 = 绿、第三方 = 灰、聚合 = 紫
- 网格自适应:`repeat(auto-fill, minmax(140px, 1fr))`,参考 cc-switch `ProviderPresetSelector.tsx:392`
**入口 B(v2,可选)`ProviderEdit` 顶部"切换预设"下拉**:对已存在但想换预设的供应商也能用。
### 4.4 持久化约定
- **name 由预设 id 派生**,用户可后续在表单里改名(不要锁)。
- **`usageKinds` 存进 SQLite settings 表**(key: `usage_kinds:<provider_name>`,值为 JSON 数组),不写进 `config.toml`。详见 §三"私有字段存储约定"。
- **不使用** `raw_other` 存 usageKinds(v0.3 修订:避免污染 Kimi Code 配置 + 支持 Rust 端自动 host detect)。
---
## 五、方案 2:供应商账单查询
### 5.1 Rust 端
```
src-tauri/src/
├── services/
│ ├── mod.rs // pub use + 统一入口
│ ├── usage_types.rs // UsageResult / UsageData
│ ├── balance.rs // 余额类(5 家)
│ └── coding_plan.rs // 套餐类(5 家)
└── commands.rs // +#[tauri::command] query_provider_usage
```
**usage_types.rs**(**注意必须加 camelCase** —— 缺它前端 `resetsAt/planName/isValid` 全部错位):
```rust
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageData {
pub plan_name: Option<String>,
pub remaining: Option<f64>,
pub total: Option<f64>,
pub used: Option<f64>,
pub unit: Option<String>,
pub is_valid: Option<bool>,
pub resets_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageResult {
pub success: bool,
pub data: Option<Vec<UsageData>>,
pub error: Option<String>,
}
```
> **与 cc-switch 真实结构的差异**(重要):cc-switch 的 `UsageData` 在 `provider.rs:282-314` 还含 `extra: Option<Value>` / `invalid_message: Option<String>`,**没有** `resets_at`(该字段在 `SubscriptionQuota.tiers[].resets_at`)。本方案**不"照抄"**,而是把套餐的 `tiers` 展平成数组 + 每项带 `resets_at` 字段,统一前端消费面。`SubscriptionQuota → UsageData[]` 的转换规则实现时需明确(每个 tier 一条 `UsageData`,`plan_name = tier.name`,`used/total/remaining/unit` 视情况映射)。
**balance.rs / coding_plan.rs**:直接 port cc-switch 实现的 5 家 + 5 家(~670 行 + ~2200 行)。重点实现:
- `detect_provider(base_url) -> Option<...>`:按子串匹配
- `query_xxx(api_key) -> Result<UsageResult, String>`:分别处理 401、读体失败、解析失败
- **读体失败和解析失败要区分**:先 `bytes().await` 拿到完整字节再 `serde_json::from_slice`(见 balance.rs:99-108 的注释,reqwest `.json()` 把读体错也包成 decode,会丢信息)
**command.rs 新增**(**v0.3 修订 + v0.4 落实**:传 `provider_name` 让 Rust 端从 config 加载 key 与 usageKinds,避免 IPC 序列化 key):
```rust
#[tauri::command]
pub async fn query_provider_usage(
agent: Agent, // v0.4 落实:项目 config 按 agent 隔离,必须传
provider_name: String,
force_refresh: Option<bool>, // true = 跳过 5min 缓存
) -> Result<UsageResult, String> {
// 1. 从 config / SQLite 加载该 provider
// 2. 如果 api_key 为空 → 自动 fallback 到 provider.env 里的
// expected_api_key_key(provider_type) 对应 key(v0.4 落实)
// 3. 读取 usageKinds: 优先 SQLite 显式设置(JSON 数组)→ fallback detect_provider(base_url) → success:false "unknown provider"
// 4. 对每个 kind 路由到对应 query_xxx,合并所有结果到一个 UsageData[]
// 5. 返回 UsageResult(data 含全部 kind 的条目)
}
```
**v0.4 重要**:Rust 端除 `provider.api_key` 外,**会兜底读取 `provider.env` 里的 `expected_api_key_key(provider_type)`**(如 Kimi 平台期望 `KIMI_API_KEY`)。这只读 env 用于实际查询,**不**写回 config.toml,给只配 env 不配 api_key 的用户兜底。
**重要**:前端**不**传 `api_key` / `usage_kind` / `base_url`。Rust 端统一从配置加载,杜绝:
- API key 经 IPC 序列化(终审 D4)
- 前端绕过 host 校验乱发请求(终审 D1)
- Rust detect 与前端规则矛盾(终审 D2)
**Tauri 配置文件注册**(**v0.3 修订**:Rust reqwest 不走 Tauri HTTP plugin,不需 capability):
1. `commands.rs` 加 `#[tauri::command]` ✅
2. `lib.rs` 的 `tauri::generate_handler![…]` 追加 `query_provider_usage`
3. `capabilities/default.json` **无需改动**(现有 `test_connectivity` 同样用 Rust reqwest,不需 HTTP scope)
**Tauri config**:`reqwest::get` 是 Rust 端发出,**不**走 WebView 网络栈,**不**需要改 CSP。
### 5.2 前端
**ProviderList 卡片底部的 `<UsageFooter>`**:
```
┌────────────────────────────────────────┐
│ Kimi For Coding [使用中] │
│ 默认: kimi-k2.7-code 3 models │
├────────────────────────────────────────┤
│ ⚡ 35% 已使用 · 5h 后重置 [刷新] │ ← 套餐类(green/orange/red)
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ DeepSeek [使用中] │
│ 默认: deepseek-chat 2 models │
├────────────────────────────────────────┤
│ 💰 余额 ¥12.34 [刷新] │ ← 余额类
└────────────────────────────────────────┘
```
- 配色:<70% 绿 / 70-89% 橙 / ≥90% 红(对齐 cc-switch)
- **多 kind 渲染**:同一 provider 的 `usageKinds` 数组可能含多种(如 `[plan:kimi_coding, balance:custom]`);`UsageData[]` 每条独立渲染为一行,余额类显金额、套餐类显百分比+倒计时
- 倒计时:前端 JS 算 `new Date(resetsAt) - Date.now()`,按区间显示"5h 后重置 / 3d 后重置"(绝对时间,Rust 端已转 ISO 8601,不依赖时区)
- 缓存:**5min stale TTL**(`force_refresh` 跳过)+ Manual 刷新(**v0.3 修订**:与 cc-switch `queries.ts:245-277` 对齐)。进入 ProviderList 时对有 usageKinds 的供应商并发查一次,**最多同时 3 个请求**(`Promise.allSettled` + 简易信号量),单请求超时 8 秒
- 错误:网络错"网络异常"、401"API Key 无效"、其他"查询失败" —— 不阻塞主界面
- 没 `usageKinds` 字段(undefined 或空数组)→ **完全不显示 footer,不发起任何请求**
**状态机**(每个 provider 独立维护):
| 当前状态 | 触发 | 行为 |
|---|---|---|
| `idle` | 首次进入 ProviderList | → `loading`(并发 ≤3) |
| `loading` | 响应到达 | `ok` → `success`;`err` → `error` |
| `success` | 用户点「刷新」 | → `loading`,保留上一次 `data` 做 ghost 展示 |
| `error` | 用户点「重试」 | → `loading` |
| `success`/`error` | 缓存超 30s,重新进入列表 | 自动 → `loading` |
| 任何 | 用户清空 `api_key` | → `idle`(不发起请求,见 §5.1 命令层检查) |
**渲染**:
- `loading`:骨架屏或上次数据的半透明 ghost
- `success`:余额/百分比 + 配色 + 重置倒计时
- `error`:红色错误提示 + 「重试」按钮
- `idle`:无(不渲染 UsageFooter)
**触发与权限**(**v0.3 修订**):
- 启动时**不**预查
- 进入 ProviderList 视图时并发查 1 次(≤3 并发,8s 超时)
- 用户点"刷新"按钮才再次查(5min stale TTL)
- 全局开关:设置面板加"启动账单查询"开关,关闭后 Rust 端拒绝查询(默认开启)
- **前端不做 usageKinds 决策**:单纯 `invoke("query_provider_usage", { provider_name: "deepseek" })`,Rust 端返回 `success: false` 时客户端仅展示错误,不做 host 校验
### 5.3 与现有"用量仪表盘"的关系
- **现有仪表盘**:本地 SQLite 聚合的 token 计数 + 费用估算(基于 `models.dev` 价格)
- **新增账单查询**:供应商侧的余额/套餐
- **不重复**:仪表盘 chip 区域不显示余额,余额只在 ProviderList 卡片底部
- **联动**:可选地(v2)在仪表盘 KPI 加一个"供应商侧配额"卡,但 v1 不做
---
## 六、里程碑
| 阶段 | 内容 | 优先级 |
|---|---|---|
| **P0** | 方案 1:15 条预设 + `PresetPickerModal` + App 入口接入 | P0 |
| **P0** | 方案 2 骨架:`services/{usage_types,mod}.rs` + `query_provider_usage` 命令 + `UsageResult` + **camelCase 序列化** | P0 |
| **P0** | `query_provider_usage` 两层注册(commands.rs + lib.rs;**无 capabilities 变更**) + Rust 端 mock 测试 | P0 |
| **P0** | SQLite 改造:`usage_kinds:<provider>` 存 settings 表(JSON 数组);`load_agent_config_command` 合并回 config;export 时不写 usageKinds | P0 |
| **P0** | `detect_provider(base_url)` 启发式:迁移期旧用户自动获得 usageKinds | P0 |
| **P0** | 方案 2 前端:`<UsageFooter>` + 卡片接入 + 错误处理 + 配色 + 状态机 + 5min stale TTL | P0 |
| **P0** | **套餐类 v1:Kimi For Coding(`api.kimi.com/coding/v1/usages`)**(项目主题,优先) | P0 |
| **P0** | 余额类 v1:DeepSeek / SiliconFlow / OpenRouter(3 家) | P0 |
| **P0** | 前端手工验证 checklist(见 §十一 #20) | P0 |
| **P1** | 套餐类补全:智谱 GLM / MiniMax | P1 |
| **P1** | 余额类补全:StepFun / Novita | P1 |
| **P2** | `ProviderEdit` 顶部"切换预设"下拉(入口 B) | P2 |
| **P2** | 设置面板"启动账单查询"开关 | P2 |
| **P2** | 倒计时 i18n 友好格式化("5小时后" / "in 5h") | P2 |
| **P2** | Volcengine 套餐查询(独立 AK/SK 支持 + UI) | P2 |
| **P3** | 自定义 JS 脚本兜底(usage_script.rs + rquickjs) | P3 |
| **P3** | 仪表盘 KPI 加"供应商侧配额"卡 | P3 |
---
## 七、许可证与引用
cc-switch 是 MIT(`D:/AIGC/cc-switch/LICENSE`),Rust 实现(`balance.rs` / `coding_plan.rs`)可直接移植;预设数据(base_url、模型 id 等公开信息)不构成版权客体。
**文件头添加**:
```rust
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
```
**README 致谢段补一条**:
> 供应商预设结构与余额/套餐查询实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT,© Jason Young)。
**配置文件同步**:preset 名 i18n key 同样放 `src/i18n/{zh,en}.ts`。
---
## 八、风险与待确认
| 风险 | 缓解措施 | 状态 |
|---|---|---|
| 预设的 `id` 与用户已存在的 `provider.name` 冲突 | `presetToProviderAndModels()` 内 `while` 循环追加 `-2` / `-3` | ✅ |
| 模型 alias 冲突 | 强制 alias = `${name}/${modelId}`,冲突追加 `-2` / `-3` | ✅ |
| 裸 alias 破坏 `handleDuplicateProvider` | **强制规范**(§4.1 转换契约);`src/App.tsx:271` `alias.slice` 改造为 `startsWith` 显式判断 + 完整 re-prefix fallback(v0.4 落实) | ✅ |
| `usageKinds` 漂移(TS 端枚举与 Rust 端 enum 各自维护) | TS 端 enum + Rust 端 enum 各自声明;TS 端在 dev 模式加载 `providerPresets.ts` 时对每个 preset 的 usageKinds 做运行时断言(v0.4 新增 `SUPPORTED_USAGE_KINDS` Set + `import.meta.env.DEV` guard) | ✅(dev-only 断言)|
| Zhipu/MiniMax 的套餐端点可能限频 | 5min stale TTL + 失败 keep-last-good;网络错误自动 retry | ✅ |
| Kimi For Coding 套餐鉴权:官方文档已确认 `kimi` 类型 + `Authorization: Bearer <sk-...>` 即可,与 cc-switch 实现一致 | ✅ 已核验([官方 providers 文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html))| ✅ |
| StepFun / Novita 的 base_url 匹配与现有 OpenAI 兼容克隆冲突 | `detect_provider` 在 `detect_provider_openai` 之前调用 | ✅ |
| `defaultBaseUrl` 与预设 base_url 双方有值 | 预设的 baseUrl **覆盖** `default_base_url()`;保存时以 `provider.base_url` 为唯一真相 | ✅ |
| provider 改 `base_url` / `provider_type` 后旧 usageKinds 错路由 | usageKinds 不显式存 provider 字段,Rust 端每次 `query_provider_usage` 重新 `detect_provider(base_url)` | ✅ |
| 旧用户升级 0.6.0 后无 usageKinds | Rust 端 `detect_provider(base_url)` 启发式作为 fallback,自动识别 DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita / Kimi Coding / 智谱 / MiniMax | ✅ |
| API key 暴露:当前 edit UI 已持有明文 key,IPC invoke 序列化 key 是"再次"暴露面 | command 改为 `provider_name`,Rust 端从 config 加载 key,**不**经 IPC 传 key。错误/调试日志中严禁输出 key | ✅ |
| Tauri capability 是否需要 | Rust reqwest 不走 Tauri HTTP plugin,无 capability 变更需求 | ✅ |
| `usageKinds` 写进 config.toml 污染 Kimi Code 配置 | 存 SQLite settings 表(JSON 数组),export 时不写 | ✅ |
| 限额与套餐 UI 区分 | 按 `plan_name` 判断:`five_hour` / `weekly_limit` → 百分比+倒计时;其他 → 余额数值 | ✅ |
| Pi 与 KimiCode 同名 provider 共享 UsageFooter 缓存造成串扰 | v0.4 落实:模块级 cache key 改为 `${agent}:${providerName}`(UsageFooter.tsx:36) | ✅ |
| 用户用 env 配 key 但 `api_key` 字段为空 | v0.4 落实:Rust 端 `query_provider_usage` 兜底读 `provider.env[expected_api_key_key(provider_type)]` | ✅ |
---
## 九、需要在开始前确认的决策点
**状态标签说明**:
- ✅ `代码已核验` —— 已对照源码确认可行
- 🟡 `产品决策` —— 已选,但落地细节随实现调整
- 🟠 `待真实 API 实测` —— 需真实 key 实测后才能标完成
- ⚪ `待用户确认` —— 文档中给了倾向,但需用户最终决定
1. ✅ **预设触发方式**:先做入口 A(独立弹窗)。入口 B 推迟到 P2。
2. 🟡 **预设的模型 maxContextSize / capabilities**:靠 models.dev 推导 + 预设可选覆写字段(§4.1 已明确 4 级优先级)。
3. 🟡 **账单查询触发**:首次进入自动查(≤3 并发)+ 5min stale TTL + 刷新/重试按钮。
4. 🟡 **Kimi For Coding 套餐**:P1(先 P0 跑通后再做)。
5. ✅ **自定义 JS 脚本**:不做(rquickjs 过重)。
6. 🟡 **预设 v1 清单**:15 条(不含 volcengine 套餐 + codingplan),够用。
7. ✅ **usageKinds 持久化**:SQLite settings 表(JSON 数组,v0.3 修订),不写 config.toml。
8. ✅ **kimi-coding preset 的 `provider_type`**:用 **`type: "kimi"`** + `base_url: "https://api.kimi.com/coding/v1"`。**官方文档已确认**:`kimi` 类型 = "对接 Moonshot AI 的 OpenAI 兼容接口,包括 Kimi Code 托管服务 + Kimi Platform API 密钥"。`moonshot` preset 同样用 `kimi` 类型(之前误写 `openai`,已修)。
9. ⚪ **Volcengine 套餐 v1 不做的取舍**:v1 仅保留 volcengine 推理预设,去掉套餐查询。是否可接受?
**用户实标 ✅ 决议后的项目**(待用户回复 8/9 后):
---
## 十、附录:cc-switch 借鉴到的关键代码位置
- `src/config/claudeProviderPresets.ts:25-74` — `ProviderPreset` 接口
- `src/config/codexProviderPresets.ts:13-46` — `CodexProviderPreset` 接口(带 modelCatalog)
- `src/components/providers/forms/ProviderPresetSelector.tsx:142-500` — Reference UI
- `src/components/providers/forms/ProviderForm.tsx:1794-1899` — `handlePresetChange` 字段注入逻辑
- `src-tauri/src/services/balance.rs:1-454` — 余额查询完整实现(5 家)
- `src-tauri/src/services/coding_plan.rs:100-206` — Kimi For Coding 单家实现
- `src-tauri/src/provider.rs` — `UsageResult` / `UsageData` 类型定义
- `docs/user-manual/en/2-providers/2.5-usage-query.md` — 设计语义文档(中文版同路径)
---
## 十一、自审发现的问题(v0.1 → v0.2 已修正)
> 本节是第一轮自审发现的问题。v0.2 已全部修正或记录到正文 / §八风险表 / 对应章节。
>
> 第三轮审核额外发现 20 项(6 阻塞 + 7 类型/语义 + 7 逻辑/边界),已直接修入正文,不在此重复。关键修正:
> - B1 「baseUrl: string → string | null」已修正(§4.1)
> - B2 「category 缺少 custom」已补(§4.1)
> - B3 「model alias 冲突检测」已补(§4.3 步骤 2)
> - B5 「三层注册清单」已补(§5.1)
> - B6 「并发控制 ≤3」已补(§5.2)
> - L1 「icon 补齐」已在 §4.2 表为预设加 `icon` 字段(值由 P0 实现时从现有 IconPicker 名映射表中读取)
> - L2 「models-dev 缺失回退 4 级优先级」已补(§4.2 底部)
> - L3 「状态机」已补(§5.2)
> - L5 「空 api_key 拒绝」已补(§5.1 命令层)
> - S4 「models 数组为空」已补(§4.1 注释)
| # | 问题 | 状态 |
|---|---|---|
| 1 | **预设 `id` 与已有 provider.name 冲突** | ✅ §4.4 已解决 |
| 2 | **usageKinds 写进 raw_other 会进 config.toml** | ✅ §三 私有字段持久化约定已明确 `kimi-switch.` 命名空间 |
| 3 | **默认 base_url 来源双源** | ✅ §八·风险 #7 + §4.1 baseUrl 注释 |
| 4 | **TS 端 usageKinds 与 Rust 端 enum 漂移** | ✅ §八·风险 #2 |
| 5 | **Kimi For Coding 鉴权格式未实测** | ✅ §八·风险 #5:P1 前 curl 验证 |
| 6 | **StepFun/Novita base_url 与 OpenAI clone 冲突** | ✅ §八·风险 #6 |
| 7 | **倒计时 i18n 友好格式化** | → §六 P2 |
| 8 | **账单查询全局开关** | → §六 P2 |
| 9 | **入口 B 切换预设** | → §六 P2 |
| 10 | **model 字段名 vs Kimi Code 识别** | 核验后确认:`[models.alias]` 块与 `[providers.name]` 块独立,无冲突风险 ✅ |
| 11 | **图标名 → IconPicker 映射表** | → P0 实现时补 |
| 12 | **设置面板账单查询开关分组** | → §六 P2:放"设置"Tab 同一分组 |
| 13 | **模型 alias 命名规范** | ✅ §4.1 已明确 `provider/model` 形式 |
| 14 | **Moonshot 无余额 API → 不显示 footer** | ✅ §5.2:无 usageKinds = 不渲染 |
| 15 | **OpenAI 协议选择 `openai`** | ✅ 不暴露 `openai_responses` 让用户困惑 |
| 16 | **多端点候选 v1 不做** | ✅ |
| 17 | **extra_usage 字段简化** | ✅ §5.1 只留 `UsageData[]` |
| 18 | **is_valid: false 透出** | ✅ §5.1 类型已含 |
| 19 | **跨会话缓存 v1 不做** | ✅ 30s TTL 够用 |
| 20 | **测试覆盖 P0 必须** | ✅ 已加回 §六里程碑 |
---
*文档结束 — v0.4-draft(v0.3 已实施 + v0.4 落实 4 项收尾:handleDuplicateProvider 兼容老 alias / UsageFooter cache 加 agent / dev 模式跨端断言 / env 兜底读 key)。配套人工回归清单见 [VERIFICATION-CHECKLIST.md](./VERIFICATION-CHECKLIST.md)。*
+154
View File
@@ -0,0 +1,154 @@
# v0.6.0 手动验证 Checklist
> 本文档是 [PROPOSAL-presets-and-usage.md](./PROPOSAL-presets-and-usage.md) P0 实施后的回归清单。
> 覆盖 v0.6.0 新增的「预设供应商 + 供应商账单查询」功能,附带核心旧功能的回归项。
> **不替代自动化测试**(项目尚无单测体系),仅作为每次发版前的人工冒烟与回归依据。
## 0. 测试环境准备
- [ ] 干净 config 目录(备份并删除 `%USERPROFILE%/.kimi-code/config.toml` 与 `%USERPROFILE%/.kimi-code/kimi-switch.db`)
- [ ] 至少 2 个真实 API Key:DeepSeek(余额)、Kimi For Coding(套餐,登录 kimi.com/code 拿 `sk-...` key);可选 OpenRouter(余额)
- [ ] 至少 1 个 OpenAI/Anthropic 兼容 key 作为非账单 provider 验证「不支持时不显示 footer」
---
## A. 预设供应商
### A1. 弹窗与入口
- [ ] 主页点「+」按钮 → 弹出 `PresetPickerModal`,含 15 条预设
- [ ] 搜索框输入「dee」/「kimi」/「glm」→ 实时过滤
- [ ] 排序切换「原始 / A-Z」→ 顺序变化
- [ ] 分类徽章配色:官方蓝 / 国产绿 / 第三方灰 / 聚合紫
- [ ] 底部「+ 自定义配置」按钮 → 关闭弹窗,走原空表单流(生成 `provider-N`)
- [ ] Esc 键关闭弹窗
- [ ] 点击遮罩关闭弹窗
- [ ] 弹窗打开时背景滚动被锁
### A2. 选中预设
- [ ] 选 kimi-coding → 卡片写入「kimi-coding」provider + `kimi-for-coding`、`kimi-k2.7-code` 模型,第一个设为 `default_model`
- [ ] provider 写入 `provider_type=kimi`,`base_url=https://api.kimi.com/coding/v1`,`api_key=null`
- [ ] usageKinds 持久化到 SQLite(验证方式:保存后重启 app,Kimi Coding 卡片底部仍出现账单区)
- [ ] 选 anthropic → `base_url` 为空(不写默认值)
- [ ] 选 google-genai → `provider_type=google-genai`,`base_url=https://generativelanguage.googleapis.com`
- [ ] 选 moonshot → `provider_type=kimi`,`base_url=https://api.moonshot.ai/v1`(Kimi 平台 API,按量付费)
- [ ] 选 zai → `provider_type=openai`,`base_url=https://api.z.ai/api/paas/v4`
- [ ] 选 volcengine → `provider_type=openai`,`base_url=https://ark.cn-beijing.volces.com/api/v3`,**无** usageKinds
- [ ] 选 bailian → 无 usageKinds(不显示账单区)
### A3. 冲突处理
- [ ] 已存在 `deepseek` provider → 选 deepseek preset 再次 → 新 provider 命名为 `deepseek-2`,模型 alias 全部带 `-2`
- [ ] 同 provider 内 alias 冲突 → 尾追加 `-2`/`-3`
- [ ] 切换不同预设(含同名模型 id)→ alias 强制 `${name}/${modelId}` 形式(**不**允许裸 alias)
### A4. 预填字段展示
- [ ] 选完预设后自动跳到 `ProviderEdit` 页
- [ ] `api_key` 留空、其它字段已预填
- [ ] 填入真实 key → Ctrl+S 保存
- [ ] 返回列表 → 新 provider 卡片可见,可点「使用中」/「切换使用」按钮激活
### A5. config.toml 污染检查
- [ ] 保存后打开 `%USERPROFILE%/.kimi-code/config.toml` → **不**含 `usageKinds` 字段
- [ ] 修改 provider 的 `base_url` 触发保存 → config.toml 仍是新 `base_url`,**无** `usage_kinds` 键
### A6. 旧数据迁移
- [ ] 用 v0.5.2 之前的 config(含裸 alias 如 `kimi-k3`、`glm-5-2-1`)启动 → app 正常加载
- [ ] 触发「复制 provider」→ 新 provider 的 alias 重命名正确(**不**再出现 `kimi-coding-k3` 这种缺 `/` 的形式)
---
## B. 供应商账单查询
### B1. 不支持时不渲染
- [ ] Moonshot / Bailian / OpenAI / Google / Anthropic 卡片底部**无** UsageFooter
- [ ] 自定义空表单(`provider-N`)卡片底部**无** UsageFooter
- [ ] 后端 `query_provider_usage` 不会为这些 provider 触发
### B2. 支持时自动查询
- [ ] 选 kimi-coding 预设、填 key、保存 → 卡片底部出现「⚡ N% 已使用 · 5h 后重置」
- [ ] 选 deepseek 预设、填 key、保存 → 卡片底部出现「💰 余额 ¥X.XX」
- [ ] 首次进入 ProviderList → 卡片并发查询,最多 3 个同时(DevTools network 可见)
- [ ] 单个 provider 查询超过 8 秒 → 卡片显示「网络异常 · 重试」,**不**阻塞其他 provider
### B3. 5min 缓存
- [ ] 查询成功后 5 分钟内重进 ProviderList → 不再发新请求(DevTools network 确认)
- [ ] 缓存过期后重进 → 自动重新查询(先 ghost 旧数据再后台拉新)
- [ ] 进程重启后 → 缓存清空(Rust 端内存 + 前端 module Map 都丢),首次进列表重新查
### B4. 手动刷新
- [ ] 点卡片底部「刷新」图标 → 跳过缓存直接查
- [ ] 刷新按钮 click 不会触发卡片「进入编辑」onClick(stopPropagation 验证)
- [ ] 刷新中按钮变转圈、禁用防重入
### B5. 错误处理
- [ ] api_key 为空 → 卡片显示「API Key 无效」红字 + 重试按钮
- [ ] api_key 错误(401)→ 卡片显示「API Key 无效」
- [ ] 网络断开 / DNS 失败 → 卡片显示「网络异常」 + 重试按钮
- [ ] 提供商返回非预期 JSON → 卡片显示「查询失败 · <错误文本>」
- [ ] 错误状态保留旧数据作 ghost(半透明)+ 缓存仍写入错误 entry,TTL 内不重发
### B6. 多 kind 渲染
- [ ] Kimi For Coding 有两层(`five_hour` + `weekly_limit`)→ 两行独立显示,前缀 `five_hour · ` / `weekly_limit · `
- [ ] 套餐行配色:< 70% 已用 绿 / 70-89 橙 / ≥ 90 红
- [ ] 倒计时:> 24h `Nd` / < 24h `Nh` / < 1h `Nm`;已过期显示「已重置」
- [ ] 余额行:CNY→¥ / USD→$ / 其他原样
- [ ] 同一 provider 同时有套餐 + 余额(v1 不存在这种组合,但 detect 启发式已支持)→ 两行都显示
### B7. SQLite 持久化
- [ ] 保存预设后查 SQLite `settings` 表 → 存在 `usage_kinds:kimi-coding` 键,值为 `["plan:kimi_coding"]`
- [ ] 删除 provider → 下次 save 时对应 key 被删(避免脏数据)
- [ ] 卸载 app → 卸载时同步删除(验证方式:卸载后 `%USERPROFILE%/.kimi-code/` 不残留 `kimi-switch.db`)
### B8. 真实 API 验证(**需真实 key**,按用户实有 key 勾选)
- [ ] DeepSeek `user/balance` → 返回 CNY 余额
- [ ] Kimi For Coding `coding/v1/usages` → 返回 `five_hour` + `weekly_limit` 两层
- [ ] OpenRouter `credits` → 返回 USD 余额
- [ ] SiliconFlow `user/info` → 返回余额
- [ ] StepFun `accounts` → 返回余额(已加在 P0 实施中,但任务里程碑归 P1)
- [ ] Novita `user/balance` → 注意 `/10000` 单位换算
- [ ] Zhipu `glm/bigmodel` 套餐 → 返回百分比 + 重置时间
- [ ] MiniMax 套餐 → 返回周桶用量
### B9. 跨 agent 隔离
- [ ] KimiCode 与 Pi(虽然 UI 屏蔽)同名 provider 各自独立缓存不串
---
## C. 核心旧功能回归(v0.6.0 没动但要确认没破)
- [ ] 切换使用按钮:点「使用中」→ 卡片高亮 + config.toml 写入 + `/reload` 提示
- [ ] 复制 provider:alias 重命名正确(包括老数据非规范 alias)
- [ ] 删除 provider:关联模型一并删除
- [ ] 编辑 provider:改名后关联模型 `m.provider` 同步更新
- [ ] 测速按钮:弹气泡,6 秒后自动消失
- [ ] 拉取模型:填入 base_url + key → 拉到的模型自动建 alias,max_context 走 models.dev
- [ ] 设置面板:主题切换、语言切换、检查更新、打开配置目录
- [ ] 检查更新:启动时静默 + 8 小时一次
- [ ] 用量仪表盘:模型用量趋势 / 供应商模型用量趋势 双 Tab、热力图、会话管理
- [ ] 关闭按钮(X)隐藏到托盘
- [ ] Ctrl+S / Ctrl+R / Ctrl+O 快捷键
---
## D. 构建与发布
- [ ] `npx tsc --noEmit` 0 error
- [ ] `cd src-tauri && cargo check` 0 error
- [ ] `cd src-tauri && cargo test` 全过(当前 26 用例)
- [ ] `npm run tauri-build` MSI 产物 `src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi`
- [ ] MSI 静默安装后启动正常、config 迁移正常
- [ ] 启动空白超时 5 秒有橙色提示
---
## E. 已知不验证项(设计层面注定做不到)
- ⚪ Volcengine 套餐查询(v1 不支持,需独立 AK/SK,留 P2)
- ⚪ ZenMux(v1 不做)
- ⚪ `ProviderEdit` 顶部「切换预设」下拉(P2)
- ⚪ 仪表盘 KPI「供应商侧配额」卡(P3)
- ⚪ `/reload` 不会自动套用新 `default_model`(Kimi Code 上游限制,详见 README「已知限制」段)
- ⚪ 切换预设后自动激活新 provider(产品决策未定)
---
*完成所有 ✓ 后再发版。任意一条 ✗ 都需定位并修复后重测。*
@@ -0,0 +1,867 @@
# Kimi Switch Multi-Platform Packaging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add macOS + Linux packaging support on top of existing Windows MSI; release driven by GitHub Actions tag-push workflow.
**Architecture:** Source-level changes are minimal (Tauri config tweaks + platform-conditional Rust + asset picker). Build infrastructure is the heavy lifting: one GH Actions workflow (version-check + 3-platform matrix using `tauri-action`) and a local Docker setup for Linux dev iteration.
**Tech Stack:** Tauri 2.x, Rust 2021, React 18 + TypeScript 5.5, GitHub Actions (`tauri-apps/tauri-action`), Docker (Ubuntu 22.04 + webkit2gtk-4.1), bash.
## Global Constraints
- macOS assets: `.app` + `.dmg`, **unsigned** (no Apple Developer ID provided)
- Linux assets: `.deb` + `.AppImage` + `.rpm` (all three)
- Windows assets: `.msi` (existing)
- Bundle target string in `tauri.conf.json`: `"all"` (allow per-platform build)
- macOS minimum system version: `10.15`
- Update checker picks asset by file extension: `.msi` / `.dmg` / `.AppImage`
- Temp filename for downloaded installer: `KimiSwitch_update.<ext>` (ext from OS)
- Version consistency: `git tag` must equal `package.json:version` = `Cargo.toml:version` = `tauri.conf.json:version`
- Cargo behavior: keep `tauri = "2.0.0"`, `tauri-plugin-opener = "2.0.0"`, `tauri-plugin-single-instance = "2.0.0"`
- No new lint failures; no new dependencies beyond what's already in `Cargo.toml`
- Spec location: `docs/superpowers/specs/2026-07-29-multi-platform-packaging-design.md`
---
## Task 1: Tauri config — bundle targets and macOS section
**Files:**
- Modify: `src-tauri/tauri.conf.json`
**Interfaces:**
- Consumes: existing Windows-only config
- Produces: `bundle.targets = "all"`, `bundle.macOS.minimumSystemVersion`, expanded icon list
- [ ] **Step 1: Update `tauri.conf.json`**
Replace the existing `bundle` block with:
```json
{
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.icns"
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": null
},
"macOS": {
"minimumSystemVersion": "10.15"
},
"linux": {
"deb": {
"depends": []
},
"appimage": {
"bundleMediaFramework": false
}
}
}
}
```
- [ ] **Step 2: Verify JSON is valid**
Run: `node -e "JSON.parse(require('fs').readFileSync('src-tauri/tauri.conf.json','utf8'))"`
Expected: no output (silent success)
- [ ] **Step 3: Commit**
```bash
git add src-tauri/tauri.conf.json
git commit -m "feat(bundle): enable all-platform targets + macOS/Linux config"
```
---
## Task 2: Platform-conditional `windows_subsystem` in main.rs
**Files:**
- Modify: `src-tauri/src/main.rs:1`
**Interfaces:**
- Consumes: existing `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]`
- Produces: Windows-only windows_subsystem; macOS/Linux keep console subsystem so logs are visible
- [ ] **Step 1: Replace the directive at line 1**
Replace:
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
```
With:
```rust
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
```
- [ ] **Step 2: Verify Windows build still compiles**
Run: `cd src-tauri && cargo check --message-format=short 2>&1 | tail -5`
Expected: `Finished \`dev\` profile [unoptimized + debuginfo] target(s)` with no errors
- [ ] **Step 3: Verify Linux target compiles (cross-check)**
Run: `cd src-tauri && cargo check --target x86_64-unknown-linux-gnu --message-format=short 2>&1 | tail -5`
Expected: finishes clean (may need `rustup target add x86_64-unknown-linux-gnu` first; if rustup target is missing, run that and retry)
- [ ] **Step 4: Commit**
```bash
git add src-tauri/src/main.rs
git commit -m "fix(build): only set windows_subsystem on Windows targets"
```
---
## Task 3: TDD — platform-aware asset picker
**Files:**
- Modify: `src-tauri/src/commands.rs` (append at end)
**Interfaces:**
- Produces (public to module): `fn pick_asset_for_current_os(assets: &[serde_json::Value]) -> Option<String>`
- Pure function, no side effects; testable in isolation
- [ ] **Step 1: Write failing tests**
Add to the end of `src-tauri/src/commands.rs`:
```rust
#[cfg(test)]
mod asset_picker_tests {
use super::*;
use serde_json::json;
fn assets(names: &[&str]) -> Vec<serde_json::Value> {
names
.iter()
.map(|n| {
json!({
"name": n,
"browser_download_url": format!("https://example.com/{n}"),
})
})
.collect()
}
#[test]
fn windows_picks_msi() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_current_os(&assets);
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_x64_en-US.msi"));
}
#[test]
fn macos_picks_dmg() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_current_os(&assets);
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_aarch64.dmg"));
}
#[test]
fn linux_picks_appimage() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_current_os(&assets);
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_amd64.AppImage"));
}
#[test]
fn missing_target_returns_none() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi"]);
let url = pick_asset_for_current_os(&assets);
assert_eq!(url, None);
}
#[test]
fn empty_assets_returns_none() {
let url = pick_asset_for_current_os(&[]);
assert_eq!(url, None);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd src-tauri && cargo test asset_picker_tests --message-format=short 2>&1 | tail -10`
Expected: compile error `function \`pick_asset_for_current_os\` not found`
- [ ] **Step 3: Implement the function**
Add to `src-tauri/src/commands.rs` (above the test module):
```rust
/// Pick the GitHub release asset matching the current OS by file extension.
/// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format.
fn pick_asset_for_current_os(assets: &[serde_json::Value]) -> Option<String> {
let target_ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => return None,
};
assets
.iter()
.find(|a| {
a.get("name")
.and_then(|n| n.as_str())
.map(|n| n.ends_with(target_ext))
.unwrap_or(false)
})
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Build the platform-specific temp filename for the downloaded installer.
fn update_temp_filename() -> String {
let ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => "bin",
};
format!("KimiSwitch_update.{ext}")
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd src-tauri && cargo test asset_picker_tests --message-format=short 2>&1 | tail -10`
Expected: `test result: ok. 5 passed; 0 failed`
- [ ] **Step 5: Commit**
```bash
git add src-tauri/src/commands.rs
git commit -m "feat(update): TDD platform-aware asset picker + tests"
```
---
## Task 4: Wire asset picker into `check_for_update` and `download_update`
**Files:**
- Modify: `src-tauri/src/commands.rs` (the `check_for_update` and `download_update` functions)
**Interfaces:**
- Consumes: `pick_asset_for_current_os` (Task 3) and `update_temp_filename` (Task 3)
- Produces: `check_for_update` returns `download_url` for the current OS only; `download_update` writes to a platform-specific filename
- [ ] **Step 1: Replace the asset-selection block in `check_for_update`**
In `check_for_update`, find the existing block:
```rust
let download_url = first
.get("assets")
.and_then(|a| a.as_array())
.and_then(|a| a.first())
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
```
Replace with:
```rust
let download_url = first
.get("assets")
.and_then(|a| a.as_array())
.and_then(|a| pick_asset_for_current_os(a));
```
- [ ] **Step 2: Replace the temp filename in `download_update`**
In `download_update`, find the existing line:
```rust
let file_path = temp_dir.join("KimiSwitch_update.msi");
```
Replace with:
```rust
let file_path = temp_dir.join(update_temp_filename());
```
- [ ] **Step 3: Verify on Windows**
Run: `cd src-tauri && cargo check --message-format=short 2>&1 | tail -5`
Expected: finishes clean
- [ ] **Step 4: Run the unit tests**
Run: `cd src-tauri && cargo test asset_picker_tests --message-format=short 2>&1 | tail -5`
Expected: `5 passed; 0 failed`
- [ ] **Step 5: Commit**
```bash
git add src-tauri/src/commands.rs
git commit -m "feat(update): use platform-aware asset and filename in update flow"
```
---
## Task 5: Version consistency check script
**Files:**
- Create: `scripts/check-version.sh`
**Interfaces:**
- Inputs: `expected_version` from CLI arg; reads `package.json`, `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json`
- Output: exit 0 if all match, exit 1 otherwise (with diff to stderr)
- [ ] **Step 1: Write the script**
Create `scripts/check-version.sh`:
```bash
#!/usr/bin/env bash
# Verify that the version across the project matches the expected version.
# Usage: check-version.sh <expected>
# Reads:
# package.json : root
# src-tauri/Cargo.toml : version field
# src-tauri/tauri.conf.json : version field
set -euo pipefail
EXPECTED="${1:-}"
if [ -z "$EXPECTED" ]; then
echo "usage: check-version.sh <expected>" >&2
exit 2
fi
# Strip leading 'v' for comparison (we expect semver without v)
EXPECTED_BARE="${EXPECTED#v}"
PKG_VERSION=$(node -p "require('./package.json').version")
CARGO_VERSION=$(grep -E '^version\s*=' src-tauri/Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
TAURI_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
fail=0
check() {
local label="$1"
local actual="$2"
if [ "$actual" != "$EXPECTED_BARE" ]; then
echo "FAIL: $label = $actual (expected $EXPECTED_BARE)" >&2
fail=1
fi
}
check "package.json" "$PKG_VERSION"
check "src-tauri/Cargo.toml" "$CARGO_VERSION"
check "src-tauri/tauri.conf.json" "$TAURI_VERSION"
if [ $fail -ne 0 ]; then
exit 1
fi
echo "OK: all versions = $EXPECTED_BARE"
```
- [ ] **Step 2: Make it executable**
Run: `chmod +x scripts/check-version.sh`
Expected: no output
- [ ] **Step 3: Test happy path**
Run: `./scripts/check-version.sh v0.6.0`
Expected: `OK: all versions = 0.6.0`
- [ ] **Step 4: Test mismatch**
Run: `./scripts/check-version.sh v9.9.9`
Expected: 3 lines starting with `FAIL:` and exit code 1
- [ ] **Step 5: Commit**
```bash
git add scripts/check-version.sh
git commit -m "ci: add version-consistency check script"
```
---
## Task 6: GitHub Actions release workflow
**Files:**
- Create: `.github/workflows/release.yml`
**Interfaces:**
- Trigger: `push: tags: ['v*']`
- Two jobs: `version-check` (sequential) and `build` (matrix ubuntu/macos/windows)
- [ ] **Step 1: Create the workflow file**
Create `.github/workflows/release.yml`:
```yaml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
version-check:
name: Version consistency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Verify tag matches package.json / Cargo.toml / tauri.conf.json
run: |
TAG="${GITHUB_REF_NAME}"
chmod +x scripts/check-version.sh
./scripts/check-version.sh "$TAG"
build:
name: Build (${{ matrix.platform }})
needs: version-check
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-latest
args: --bundles deb,appimage,rpm
- platform: macos-latest
args: --bundles app,dmg
- platform: windows-latest
args: --bundles msi
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- uses: actions/setup-rust@v1
with:
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Build and upload release
uses: tauri-apps/tauri-action@v0
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
releaseBody: |
## ${{ github.ref_name }}
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app`
- **Linux**: `.deb` / `.AppImage` / `.rpm`
Full changelog: see commit history.
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
args: ${{ matrix.args }}
```
- [ ] **Step 2: Sanity-check the file is in place**
Run: `ls -la .github/workflows/release.yml && head -5 .github/workflows/release.yml`
Expected: file exists and the first line is `name: Release` (GitHub Actions will catch any YAML syntax errors at runtime)
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/release.yml
git commit -m "ci: add multi-platform release workflow on tag push"
```
---
## Task 7: Local Docker for Linux builds
**Files:**
- Create: `Dockerfile`
- Create: `docker-compose.yml`
**Interfaces:**
- Input: source tree at `/workspace`
- Output: built packages at `/workspace/src-tauri/target/release/bundle/{deb,appimage,rpm}/`
- [ ] **Step 1: Create the Dockerfile**
Create `Dockerfile`:
```dockerfile
# Build Kimi Switch Linux packages (deb / AppImage / rpm) inside a clean
# Ubuntu 22.04 container. Used both locally (`docker compose run`) and as a
# reference for GH Actions' ubuntu-latest job.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV CARGO_TERM_COLOR=always
ENV RUSTUP_HOME=/usr/local/rustup
ENV CARGO_HOME=/usr/local/cargo
ENV PATH=/usr/local/cargo/bin:$PATH
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
build-essential \
pkg-config \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
patchelf \
file \
nodejs \
npm \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
RUN cargo install tauri-cli --version "^2.0" --locked
WORKDIR /workspace
CMD ["/bin/bash"]
```
- [ ] **Step 2: Create docker-compose.yml**
Create `docker-compose.yml`:
```yaml
services:
build-linux:
build: .
container_name: kimiswitch-build-linux
volumes:
- .:/workspace
- cargo-cache:/usr/local/cargo/registry
- target-cache:/workspace/src-tauri/target
working_dir: /workspace
volumes:
cargo-cache:
target-cache:
```
- [ ] **Step 3: Build the Docker image**
Run: `docker compose build build-linux 2>&1 | tail -10`
Expected: ends with `naming to docker.io/library/...build-linux done` (or similar successful build output)
- [ ] **Step 4: Commit**
```bash
git add Dockerfile docker-compose.yml
git commit -m "ci: add Linux Docker build environment"
```
---
## Task 8: Build documentation
**Files:**
- Create: `docs/BUILD.md`
**Interfaces:**
- Audience: contributors who want to build the project locally
- Output: instructions for Windows / macOS / Linux local builds + Docker Linux build
- [ ] **Step 1: Write the doc**
Create `docs/BUILD.md`:
````markdown
# Build
Docker (Linux), native (any platform), and CI (multi-platform) build instructions.
## Local builds by host
### Windows (current primary dev environment)
Prerequisites: Rust stable, Node 20, Microsoft C++ Build Tools, WebView2.
```bash
npm ci
npm run tauri build -- --bundles msi
```
Output: `src-tauri/target/release/bundle/msi/`.
### macOS
Prerequisites: Xcode command-line tools, Rust stable, Node 20.
```bash
npm ci
npm run tauri build -- --bundles app,dmg
```
Output: `src-tauri/target/release/bundle/{macos,dmg}/`.
Unsigned: first launch requires right-click → Open to bypass Gatekeeper.
### Linux (native)
Prerequisites: see `Dockerfile` for the full apt-get list (webkit2gtk-4.1, librsvg2, etc.).
```bash
npm ci
npm run tauri build -- --bundles deb,appimage,rpm
```
Output: `src-tauri/target/release/bundle/{deb,appimage,rpm}/`.
### Linux via Docker (works on Windows / macOS too)
```bash
docker compose build build-linux
docker compose run --rm build-linux bash -c '
npm ci &&
npm run tauri build -- --bundles deb,appimage,rpm
'
```
Output is mounted back to `src-tauri/target/release/bundle/` on the host.
## CI: GitHub Actions
`.github/workflows/release.yml` runs on `git tag v* && git push origin v*`.
1. `version-check` runs `scripts/check-version.sh` to ensure tag matches `package.json`, `Cargo.toml`, `tauri.conf.json`.
2. Three parallel jobs build for `ubuntu-latest`, `macos-latest`, `windows-latest` using `tauri-action`.
3. Artifacts are uploaded to the GitHub Release (auto-draft for non-prerelease tags).
To cut a release:
```bash
# 1. Bump version in three places
# package.json: "version": "0.7.0"
# src-tauri/Cargo.toml: version = "0.7.0"
# src-tauri/tauri.conf.json: "version": "0.7.0"
# 2. Commit
git commit -am "chore(release): bump to v0.7.0"
# 3. Tag and push
git tag v0.7.0
git push origin v0.7.0
```
## Update checker asset selection
`src-tauri/src/commands.rs::pick_asset_for_current_os` picks the right asset by file extension:
| OS | Extension |
|---------|------------|
| Windows | `.msi` |
| macOS | `.dmg` |
| Linux | `.AppImage` (preferred; deb / rpm downloadable manually from the release page) |
If the preferred extension is missing, the UI falls back to opening the release page.
````
- [ ] **Step 2: Commit**
```bash
git add docs/BUILD.md
git commit -m "docs: build instructions for all platforms + Docker + CI"
```
---
## Task 9: README — multi-platform download links + workflow badge
**Files:**
- Modify: `README.md`
- Modify: `README_EN.md`
**Interfaces:**
- Adds a "下载" / "Downloads" section with three platform bullets
- Adds a build-from-source link to `docs/BUILD.md`
- Adds a GitHub Actions badge
- [ ] **Step 1: Add the badges (after the existing license badge)**
In `README.md`, locate the line:
```markdown
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
```
Add two new badges below it:
```markdown
[![Release](https://github.com/billowliu2/KimiSwitch/actions/workflows/release.yml/badge.svg)](https://github.com/billowliu2/KimiSwitch/releases/latest)
```
(Do the same in `README_EN.md`.)
- [ ] **Step 2: Add a "下载" section right after "这是什么"**
In `README.md`, after the paragraph immediately above `## 核心特性`, insert:
```markdown
## 下载
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名** —— 首次启动请右键 → 打开绕过 Gatekeeper)
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
历史版本(v0.5.x 及之前)请前往 [git.codingplan.site 仓库](https://git.codingplan.site/admin/KimiCodeSwitch/releases)。
构建说明请见 [`docs/BUILD.md`](./docs/BUILD.md)。
```
In `README_EN.md`, insert the English equivalent:
```markdown
## Downloads
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg` (**unsigned** — first launch needs right-click → Open to bypass Gatekeeper)
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
Historical versions (v0.5.x and earlier) live on the [git.codingplan.site repository](https://git.codingplan.site/admin/KimiCodeSwitch/releases).
Build instructions: [`docs/BUILD.md`](./docs/BUILD.md).
```
- [ ] **Step 3: Commit**
```bash
git add README.md README_EN.md
git commit -m "docs(readme): multi-platform downloads + workflow badge"
```
---
## Task 10: End-to-end test with a pre-release tag
**Files:**
- (No file changes — pure verification)
**Goal:** Confirm the GH Actions workflow actually produces all 6 assets. Use a pre-release tag so it doesn't pollute the main `v0.6.0` release.
- [ ] **Step 1: Confirm current version is in sync**
Run: `./scripts/check-version.sh v0.6.0`
Expected: `OK: all versions = 0.6.0`
- [ ] **Step 2: Push all un-pushed commits**
```bash
git push origin main
git push origin --tags
```
- [ ] **Step 3: Tag a pre-release on the current commit**
```bash
git tag v0.6.0-rc1
git push origin v0.6.0-rc1
```
- [ ] **Step 4: Watch the workflow run**
Open: https://github.com/billowliu2/KimiSwitch/actions
Expected: `version-check` job passes, then 3 `build` jobs run in parallel. Total time roughly 10-15 minutes per platform.
- [ ] **Step 5: Verify the pre-release**
Open: https://github.com/billowliu2/KimiSwitch/releases/tag/v0.6.0-rc1
Expected: 6 assets attached (`.msi`, `.dmg`, `.app`, `.deb`, `.AppImage`, `.rpm`); release marked as "Pre-release".
- [ ] **Step 6: Verify version mismatch fails the workflow**
On a clean branch, change only `package.json` version to `0.9.0`, commit, then:
```bash
git tag v0.6.0-rc2
git push origin v0.6.0-rc2
```
Open the new workflow run. Expected: `version-check` job fails with `FAIL: package.json = 0.9.0 (expected 0.6.0)`. Build jobs are skipped.
Then revert the package.json change and delete the rc2 tag:
```bash
git checkout main -- package.json
git tag -d v0.6.0-rc2
git push origin :refs/tags/v0.6.0-rc2
```
- [ ] **Step 7: Clean up the rc1 tag**
After verifying the rc1 assets are good, delete the pre-release to keep the release list clean:
```bash
git tag -d v0.6.0-rc1
git push origin :refs/tags/v0.6.0-rc1
# Then delete the GitHub Release via the web UI (or leave it as a historical rc)
```
- [ ] **Step 8: Final commit — none if Tasks 1-9 are clean**
If no edits were needed, no commit. Otherwise, commit any fixups.
---
## Summary
After all 10 tasks, the project ships with:
- macOS + Linux + Windows builds produced from a single `git tag v0.7.0` push
- Local Docker for Linux dev iteration
- Per-platform asset picker in the update checker
- README + BUILD docs covering all three platforms
- Version-consistency gate preventing drift between tag and the three version files
@@ -0,0 +1,248 @@
# Kimi Switch 多平台打包 — 设计
**日期**:2026-07-29
**状态**:待用户审查
**关联 issue / 任务**:新增 macOS + Linux 打包支持(gh Actions 为主 + Docker 本地调 Linux)
---
## 1. 背景与目标
Kimi Switch 是 Tauri 2.x 桌面配置管理器,目前仅支持 Windows MSI(`tauri.conf.json` 中 `bundle.targets: ["msi"]`)。本次改动让同一份代码可同时构建 Windows / macOS / Linux 三平台,整理出能在 GitHub Releases 上统一发布的工作流。
成功标准:
- `git tag v0.7.0 && git push origin v0.7.0` → GitHub Actions 自动产出 6 个资产(`.msi` + `.dmg` + `.app` + `.deb` + `.AppImage` + `.rpm`),全部挂到 v0.7.0 Release
- 开发者本机 Windows + Docker 可本地调 Linux 三种格式
- 应用内"检查更新"按 OS 自动选对应 asset 下载
- 不回归现有 Windows 用户的体验
## 2. 决策(已与用户确认)
| 决策点 | 选定 |
|--------|------|
| 构建策略 | GH Actions 为主(tauri-action)+ Docker 本地调 Linux |
| macOS 签名 | 不签名 / 跳过公证(用户在 README 中被告知右键 → 打开绕过 Gatekeeper) |
| Linux 格式 | deb + AppImage + rpm(全部覆盖) |
| Release 触发 | tag 推送触发 + tag / package.json / Cargo.toml / tauri.conf.json 四处版本一致性检查 |
| Update checker | 按 OS + 扩展名自动选 asset(Windows: .msi,macOS: .dmg,Linux: .AppImage) |
## 3. 架构
### 3.1 发布流
```
git tag v0.7.0 && git push origin v0.7.0
│
▼
GH Actions: version-check job
│ (tag == package.json == Cargo.toml == tauri.conf.json ?)
▼
3 个并行 jobs 同时 build:
ubuntu-latest → .deb / .AppImage / .rpm
macos-latest → .app + .dmg(无签名)
windows-latest → .msi
│
▼
tauri-action 自动收集 artifacts → 上传到 GitHub Release v0.7.0
```
### 3.2 本地调试流
```
Developer on Windows
│
▼
docker compose run --rm build-linux
│
▼
ubuntu 22.04 容器内
apt-get install webkit2gtk-4.1 librsvg2-dev
cargo install tauri-cli
npm ci && npm run build
cargo tauri build --bundles deb,appimage,rpm
│
▼
src-tauri/target/release/bundle/
├── deb/KimiSwitch_0.7.0_amd64.deb
├── appimage/KimiSwitch_0.7.0_amd64.AppImage
└── rpm/KimiSwitch-0.7.0-1.x86_64.rpm
```
### 3.3 用户更新流
```
App 启动 / 每 8h / 手动
│
▼
GET https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1
│
▼
解析 JSON,按 OS 过滤 assets
│
▼
? update_available → footer 弹提示
│
▼
用户点"下载" → 后端流式下载到 temp_dir()/KimiSwitch_update.<ext>
│
▼
open_installer(path) → 系统默认 handler 打开
Windows: 启动 MSI 安装
macOS: 挂载 .dmg,用户拖入 Applications
Linux: AppImage 直接 chmod +x 跑;deb/rpm 走系统包管理器
```
## 4. 文件 / 代码变更
### 4.1 新增
| 路径 | 目的 |
|------|------|
| `.github/workflows/release.yml` | GH Actions 工作流(version-check + 3 平台矩阵) |
| `Dockerfile` | Ubuntu 22.04 + webkit2gtk-4.1 + librsvg2 + cargo + node,构建工具链 |
| `docker-compose.yml` | `docker compose run --rm build-linux` 一行起 |
| `scripts/check-version.sh` | 校验 tag / package.json / Cargo.toml / tauri.conf.json 四处版本号一致 |
| `docs/BUILD.md` | 本地 Docker 构建、平台差异、签名状态说明 |
### 4.2 修改
| 路径 | 改动 |
|------|------|
| `src-tauri/tauri.conf.json` | `bundle.targets: ["msi"]` → `"all"`;icon 列表追加 macOS 专用 `.icns`;加 `macOS.minimumSystemVersion: "10.15"` |
| `src-tauri/src/main.rs` | `windows_subsystem = "windows"` 改为仅 Windows 启用(macOS / Linux 仍 console subsystem) |
| `src-tauri/src/commands.rs` | `download_update` 临时文件名按 OS 走;`check_for_update` 按 OS 过滤 asset |
| `README.md` | 三平台下载链接;Docker 调试一节;GH Actions 状态徽章 |
| `README_EN.md` | 同上英文版 |
### 4.3 核心 Rust 实现
```rust
// src-tauri/src/commands.rs
/// Pick the GitHub release asset matching the current OS by file extension.
/// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format.
fn pick_asset_for_current_os(assets: &[serde_json::Value]) -> Option<String> {
let target_ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => return None,
};
assets.iter()
.find(|a| a.get("name").and_then(|n| n.as_str())
.map(|n| n.ends_with(target_ext)).unwrap_or(false))
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Build the platform-ext-specific temp filename for downloaded installer.
fn update_temp_filename() -> String {
let ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => "bin",
};
format!("KimiSwitch_update.{ext}")
}
```
`main.rs` 调整:
```rust
// 旧
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// 新
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
```
## 5. 错误处理
| 场景 | 处理 |
|------|------|
| tag 与 Cargo.toml 版本不一致 | workflow 立即 fail,输出 diff;不进入 build |
| GitHub Release 已有同名 asset | tauri-action 自动覆盖(默认行为) |
| Linux runner 缺 webkit2gtk 等系统包 | tauri-action 文档明确会自动 `apt-get install`;失败 bubble up |
| macOS 构建失败 | 失败 job 标红,其他平台不受影响;release 仍上传成功的资产 |
| 用户端 update check 网络失败 | 静默失败(`useUpdateCheck.ts` 已有 try/catch) |
| 本地 Docker 构建失败 | 多阶段 Dockerfile,失败 → 容器退出码 + 完整日志 |
| macOS 未签名 .dmg 被 Gatekeeper 拦 | README 明确"右键 → 打开"绕过;远期可加签名(独立设计) |
质量门:
- `cargo check --target x86_64-unknown-linux-gnu`(Docker 内)
- `npm run build`(前端)
- 不引入新的 lint 失败
- macOS unsigned 状态在 release body 里显式标注
## 6. 测试验证
### 6.1 本地构建验证
1. **单测 / lint**
- `npm run lint`
- `cargo check`
- `cargo check --target x86_64-unknown-linux-gnu`(Docker 内)
2. **本地 Docker Linux 构建**
- `docker compose build build-linux` → 容器构建成功
- `docker compose run --rm build-linux` → 产出 3 个 Linux 包
- `dpkg --info kimiswitch_0.7.0_amd64.deb` 确认 metadata
- `file kimiswitch_0.7.0_amd64.AppImage` 确认 ELF 可执行
3. **Rust 单元测**
- `pick_asset_for_current_os`:
- `windows` + assets `[msi, dmg, AppImage]` → 选 msi
- `macos` + assets `[msi, dmg, AppImage]` → 选 dmg
- `linux` + assets `[msi, dmg, AppImage]` → 选 AppImage
- 都不匹配 → 返回 None
### 6.2 GH Actions 端到端
- 推 test tag `v0.6.0-rc1`(prerelease,**不污染正式 v0.6.0**)
- 验证 3 个 jobs 都绿
- 验证 assets 上传到 GitHub Release
### 6.3 运行时手测(自动化跳过,仅文档化)
- macOS: 挂载 .dmg,拖入 Applications;启动确认(首次会被 Gatekeeper 拦,文档说明)
- Linux: AppImage `chmod +x && ./KimiSwitch_0.7.0_amd64.AppImage`;deb `sudo dpkg -i`;rpm `sudo rpm -i`
- Windows: 已有 MSI 流程,不回归
### 6.4 Update checker 联调
- App 内"检查更新" → 后端 fetch GH releases → 按 OS 选对 asset → 下载正确文件
- Linux 上 AppImage 装完后启动 app,update check 选 AppImage 而非其他格式
### 6.5 验收
- ✅ 三个平台都跑出可安装产物
- ✅ GH Actions 总耗时 < 30 分钟
- ✅ 版本不一致时 workflow 立即 fail
- ✅ Update checker 跨平台行为正确
- ✅ README 三平台下载链接齐全
## 7. 范围外(本次不做)
- macOS 代码签名 + notarization(用户已确认本期跳过;独立设计未来再加)
- 自动更新检查替换为 Tauri 官方 `tauri-plugin-updater`(本期仍用自定义 `check_for_update`)
- iOS / Android(已有 icon 资源,但本期不构建)
- 自动 changelog 生成(用 commit log + 手动整理)
- Linux 平台 snap / flatpak
- Linux 32 位 / ARM 构建(先 x86_64 就够了)
## 8. 风险与回退
| 风险 | 缓解 |
|------|------|
| macOS unsigned 限制分发 | README 引导用户绕过;后续可补签名 |
| GH Actions 30 分钟耗时不够 | 用 `tauri-action` 自带缓存;3 平台并行通常 10-15 分钟 |
| Docker 镜像构建慢 | 用 prebuilt `tauri-apps/tauri:2-jammy` 风格基础镜像(自维护一份);接下来 PR 可替换 |
| 单元测 build 时间拖慢 | `pick_asset_for_current_os` 是纯函数,单测几百 ms;不会显著拖慢 CI |
| 用户升级后旧版检查更新路径被破坏 | 新逻辑兼容旧 release(只有一个 msi asset 时返回 None,UI 显示"请手动下载") |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.5.2",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Verify that the version across the project matches the expected version.
# Usage: check-version.sh <expected>
# Reads:
# package.json : root
# src-tauri/Cargo.toml : version field
# src-tauri/tauri.conf.json : version field
set -euo pipefail
EXPECTED="${1:-}"
if [ -z "$EXPECTED" ]; then
echo "usage: check-version.sh <expected>" >&2
exit 2
fi
# Strip leading 'v' for comparison (we expect semver without v)
EXPECTED_BARE="${EXPECTED#v}"
# Strip common prerelease suffixes (-rc1, -beta.2, -alpha.3, etc.) so a
# v0.6.0-rc1 tag can reuse the existing 0.6.0 versions in the project files.
# If the project does want a separate prerelease version, it must be
# committed explicitly to the three version files.
EXPECTED_BARE="${EXPECTED_BARE%%-*}"
PKG_VERSION=$(node -p "require('./package.json').version")
CARGO_VERSION=$(grep -E '^version\s*=' src-tauri/Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
TAURI_VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
fail=0
check() {
local label="$1"
local actual="$2"
if [ "$actual" != "$EXPECTED_BARE" ]; then
echo "FAIL: $label = $actual (expected $EXPECTED_BARE)" >&2
fail=1
fi
}
check "package.json" "$PKG_VERSION"
check "src-tauri/Cargo.toml" "$CARGO_VERSION"
check "src-tauri/tauri.conf.json" "$TAURI_VERSION"
if [ $fail -ne 0 ]; then
exit 1
fi
echo "OK: all versions = $EXPECTED_BARE"
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.5.2"
version = "0.6.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.5.2"
version = "0.6.0"
description = "Kimi Switch - model config manager"
authors = ["you"]
edition = "2021"
+327 -30
View File
@@ -1,8 +1,12 @@
use crate::db;
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
use crate::pi_io;
use crate::services::{self, UsageKind, UsageResult};
use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
@@ -17,12 +21,60 @@ pub fn debug_log(message: String) {
eprintln!("[frontend] {}", message);
}
/// SQLite settings key for a provider's billing/usage query kinds
/// (JSON array of kind strings, e.g. `["balance:deepseek"]`).
fn usage_kinds_key(provider_name: &str) -> String {
format!("usage_kinds:{provider_name}")
}
/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite
/// settings first, host-based detection as fallback so existing installs get
/// billing support automatically. The field never enters config.toml.
fn merge_usage_kinds(config: &mut Config) {
for p in config.providers.values_mut() {
let from_settings = db::get_setting_pub(&usage_kinds_key(&p.name))
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.filter(|v| !v.is_empty());
p.usage_kinds = from_settings.or_else(|| {
let kinds = services::detect_provider(&resolve_base_url(p));
if kinds.is_empty() {
None
} else {
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
}
});
}
}
fn load_pi_native_config() -> Result<Config, String> {
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config
.models
.values()
.find(|m| m.provider == provider && m.model == model_id)
{
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
match agent {
let mut config = match agent {
Agent::KimiCode => {
// config.toml is the authoritative source for provider/model data
// because the user can add or edit providers at any time via the
@@ -69,33 +121,19 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
}
}
Ok(config)
config
}
Agent::Pi => {
// Pi: SQLite first, fall back to native config on first use.
if let Some(config) = db_config {
if !config.providers.is_empty() {
return Ok(config);
}
match db_config {
Some(config) if !config.providers.is_empty() => config,
_ => load_pi_native_config()?,
}
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config.models.values().find(|m| {
m.provider == provider && m.model == model_id
}) {
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
}
};
merge_usage_kinds(&mut config);
Ok(config)
}
#[tauri::command]
@@ -108,6 +146,19 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
if matches!(agent, Agent::KimiCode) {
crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?;
}
// Persist usage_kinds to the SQLite settings table (never config.toml;
// the field is skip_serializing and the TOML export is hand-built).
// None / empty array → delete the key.
for provider in config.providers.values() {
let key = usage_kinds_key(&provider.name);
match &provider.usage_kinds {
Some(kinds) if !kinds.is_empty() => {
let json = serde_json::to_string(kinds).map_err(|e| e.to_string())?;
db::set_setting_pub(&key, &json).map_err(fmt_anyhow)?;
}
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
}
}
Ok(())
}
@@ -511,6 +562,144 @@ pub fn set_app_setting(key: String, value: String) -> Result<(), String> {
db::set_setting_pub(&key, &value).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Provider billing / usage query (cc-switch semantics)
// ---------------------------------------------------------------------------
/// 5-minute in-memory cache keyed by (agent, provider_name).
/// Only successful results are cached; failures are always re-queryable.
const USAGE_CACHE_TTL: Duration = Duration::from_secs(300);
type UsageCache = Mutex<HashMap<(String, String), (Instant, UsageResult)>>;
fn usage_cache() -> &'static UsageCache {
static CACHE: OnceLock<UsageCache> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Query a provider's balance / plan quota. The frontend passes only the
/// provider name — base_url, api_key and usage kinds are all resolved here,
/// so the API key never crosses IPC and the host routing cannot be spoofed.
///
/// Error channel semantics (cc-switch):
/// - `Err(_)` = transient failure (network/timeout/body read) → frontend
/// retries and keeps the last good value.
/// - `Ok(success:false)` = deterministic failure (no key / auth / non-2xx /
/// bad JSON / unsupported provider) → show the error text directly.
#[tauri::command]
pub async fn query_provider_usage(
agent: Agent,
provider_name: String,
force_refresh: Option<bool>,
) -> Result<UsageResult, String> {
let cache_key = (agent.as_str().to_string(), provider_name.clone());
if !force_refresh.unwrap_or(false) {
let cached = usage_cache()
.lock()
.unwrap()
.get(&cache_key)
.and_then(|(ts, result)| (ts.elapsed() < USAGE_CACHE_TTL).then(|| result.clone()));
if let Some(result) = cached {
return Ok(result);
}
}
// Load via the same path as load_agent_config_command so usage_kinds
// (SQLite merge + host-detect fallback) is already resolved.
let config = load_agent_config_command(agent)?;
let Some(provider) = config.providers.get(&provider_name) else {
return Ok(UsageResult::failure(format!(
"provider '{provider_name}' not found"
)));
};
// The api_key only ever goes into request headers — never into logs,
// error messages, or the cache key.
let api_key = provider
.api_key
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
provider
.env
.get(expected_api_key_key(&provider.provider_type))
.cloned()
.filter(|s| !s.is_empty())
});
let Some(api_key) = api_key else {
return Ok(UsageResult::failure(if provider.managed {
"provider uses managed OAuth; usage query requires an API key".to_string()
} else {
"no API key configured".to_string()
}));
};
let base_url = resolve_base_url(provider);
let kinds: Vec<UsageKind> = provider
.usage_kinds
.as_ref()
.filter(|v| !v.is_empty())
.map(|v| {
v.iter()
.filter_map(|s| s.parse::<UsageKind>().ok())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| services::detect_provider(&base_url));
if kinds.is_empty() {
return Ok(UsageResult::failure(
"unsupported provider: no usage query available for this base URL".to_string(),
));
}
// A failing kind must not take down the others: collect successes,
// deterministic failures and transient failures separately.
let mut data: Vec<crate::services::UsageData> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut transient: Vec<String> = Vec::new();
let mut any_success = false;
for kind in kinds {
match services::query_kind(kind, &base_url, &api_key).await {
Ok(result) if result.success => {
any_success = true;
if let Some(d) = result.data {
data.extend(d);
}
}
Ok(result) => {
if let Some(e) = result.error {
errors.push(format!("{}: {e}", kind.as_str()));
}
}
Err(e) => transient.push(format!("{}: {e}", kind.as_str())),
}
}
if any_success {
let result = UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: if errors.is_empty() {
None
} else {
Some(errors.join("; "))
},
};
usage_cache()
.lock()
.unwrap()
.insert(cache_key, (Instant::now(), result.clone()));
Ok(result)
} else if !transient.is_empty() {
// All kinds failed transiently → propagate Err so the frontend
// rejects and retries (keep-last-good).
Err(transient.join("; "))
} else {
Ok(UsageResult::failure(errors.join("; ")))
}
}
// ---------------------------------------------------------------------------
// Version check (lightweight: GET Gitea releases API, compare tag_name)
// ---------------------------------------------------------------------------
@@ -551,9 +740,21 @@ fn version_lt(current: &str, latest: &str) -> bool {
#[tauri::command]
pub async fn check_for_update() -> Result<UpdateInfo, String> {
let current = env!("CARGO_PKG_VERSION").to_string();
let url = "https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1";
// GitHub Releases API for billowliu2/KimiSwitch. Historical versions
// (v0.5.x and earlier) live on git.codingplan.site and are not exposed
// here — only v0.6.0+ is published via GitHub.
let url = "https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1";
let resp = reqwest::get(url)
// GitHub returns 403 without a User-Agent header. Bundle the app version
// so the source is identifiable in any rate-limit / abuse reports.
let client = reqwest::Client::builder()
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| format!("client build failed: {e}"))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
@@ -582,10 +783,7 @@ pub async fn check_for_update() -> Result<UpdateInfo, String> {
let download_url = first
.get("assets")
.and_then(|a| a.as_array())
.and_then(|a| a.first())
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
.and_then(|a| pick_asset_for_current_os(a));
let update_available = version_lt(&current, &latest);
@@ -618,7 +816,7 @@ pub async fn download_update(
let total = resp.content_length().unwrap_or(0);
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("KimiSwitch_update.msi");
let file_path = temp_dir.join(update_temp_filename());
let mut file = std::fs::File::create(&file_path)
.map_err(|e| format!("create temp file failed: {e}"))?;
@@ -667,4 +865,103 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
app.opener()
.open_path(&path, None::<&str>)
.map_err(|e| e.to_string())
}
/// Pick the GitHub release asset matching the given OS by file extension.
/// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format.
fn pick_asset_for_os(assets: &[serde_json::Value], os: &str) -> Option<String> {
let target_ext = match os {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => return None,
};
assets
.iter()
.find(|a| {
a.get("name")
.and_then(|n| n.as_str())
.map(|n| n.ends_with(target_ext))
.unwrap_or(false)
})
.and_then(|a| a.get("browser_download_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Pick the GitHub release asset for the current OS (runtime target).
fn pick_asset_for_current_os(assets: &[serde_json::Value]) -> Option<String> {
pick_asset_for_os(assets, std::env::consts::OS)
}
/// Build the platform-specific temp filename for the downloaded installer.
fn update_temp_filename() -> String {
let ext = match std::env::consts::OS {
"macos" => "dmg",
"linux" => "AppImage",
"windows" => "msi",
_ => "bin",
};
format!("KimiSwitch_update.{ext}")
}
#[cfg(test)]
mod asset_picker_tests {
use super::*;
use serde_json::json;
fn assets(names: &[&str]) -> Vec<serde_json::Value> {
names
.iter()
.map(|n| {
json!({
"name": n,
"browser_download_url": format!("https://example.com/{n}"),
})
})
.collect()
}
#[test]
fn windows_picks_msi() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_os(&assets, "windows");
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_x64_en-US.msi"));
}
#[test]
fn macos_picks_dmg() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_os(&assets, "macos");
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_aarch64.dmg"));
}
#[test]
fn linux_picks_appimage() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
let url = pick_asset_for_os(&assets, "linux");
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_amd64.AppImage"));
}
#[test]
fn missing_target_returns_none() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi"]);
let url = pick_asset_for_os(&assets, "macos");
assert_eq!(url, None);
}
#[test]
fn empty_assets_returns_none() {
let url = pick_asset_for_os(&[], "linux");
assert_eq!(url, None);
}
#[test]
fn unknown_os_returns_none() {
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi"]);
let url = pick_asset_for_os(&assets, "freebsd");
assert_eq!(url, None);
}
}
+9
View File
@@ -157,6 +157,8 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
// Merged from the settings table by load_agent_config_command.
usage_kinds: None,
})
})?;
@@ -317,6 +319,13 @@ pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
Ok(())
}
/// Public helper: delete a single setting (no-op if the key does not exist).
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
let conn = init_db()?;
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
}
fn provider_type_for_str(s: &str) -> ProviderType {
match s {
"anthropic" => ProviderType::Anthropic,
+12
View File
@@ -209,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
icon,
icon_color,
raw_other,
usage_kinds: None,
},
);
}
@@ -506,6 +507,7 @@ api_key = ""
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let mut models = IndexMap::new();
@@ -567,6 +569,7 @@ api_key = ""
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let mut models = IndexMap::new();
@@ -669,7 +672,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
providers.insert(
@@ -685,7 +691,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let config = Config {
@@ -721,7 +730,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::json!({"default_model": "some-alias"}),
usage_kinds: None,
},
);
let config = Config {
+2
View File
@@ -5,6 +5,7 @@ pub mod db;
pub mod kimi_code_io;
pub mod models;
pub mod pi_io;
pub mod services;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder};
@@ -96,6 +97,7 @@ pub fn run() {
commands::get_app_version,
commands::list_provider_models,
commands::test_connectivity,
commands::query_provider_usage,
commands::debug_log,
commands::get_app_setting,
commands::set_app_setting,
+4 -1
View File
@@ -1,4 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
kimiswitch_lib::run();
+9
View File
@@ -91,6 +91,14 @@ pub struct Provider {
pub icon_color: Option<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
/// Billing/usage query kinds for this provider (e.g. "balance:deepseek",
/// "plan:kimi_coding"). Persisted in the SQLite settings table under
/// `usage_kinds:<provider_name>`, NOT in the agent's config.toml — both
/// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are
/// explicit and never serialize this field, while the IPC payload to the
/// frontend does carry it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_kinds: Option<Vec<String>>,
}
impl PartialEq for Provider {
@@ -108,6 +116,7 @@ impl PartialEq for Provider {
&& self.icon == other.icon
&& self.icon_color == other.icon_color
&& self.raw_other == other.raw_other
&& self.usage_kinds == other.usage_kinds
}
}
+1
View File
@@ -339,6 +339,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
icon: None,
icon_color: None,
raw_other: provider_raw,
usage_kinds: None,
};
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
+326
View File
@@ -0,0 +1,326 @@
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
//! 供应商余额查询服务
//!
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
//!
//! 错误通道语义(与 cc-switch 一致):
//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject,
//! 触发 retry 并保留上一次成功的 data(keep-last-good)。
//! - `Ok(success:false)` = 确定性失败(空 key/鉴权失败/非 2xx/响应体非法 JSON),
//! 直接透出错误文案。
//!
//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。
use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
/// 鉴权头形式:绝大多数供应商用 `Bearer <key>`;智谱套餐接口不加前缀(见 coding_plan)。
pub(crate) enum AuthStyle {
Bearer,
Raw,
}
/// GET 请求的归类结果。
pub(crate) enum Fetched {
/// 2xx 且响应体是合法 JSON。
Body(serde_json::Value),
/// 确定性失败(401/403/非 2xx/解析失败),调用方原样包成 Ok 返回。
Failed(UsageResult),
}
/// 统一的 GET + JSON 读取助手。瞬时失败(网络/超时/读体中断)返回 `Err`;
/// 确定性失败收进 `Fetched::Failed`。
///
/// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
/// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。
pub(crate) async fn get_json(
url: &str,
api_key: &str,
auth: AuthStyle,
) -> Result<Fetched, String> {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let req = client.get(url).header("Accept", "application/json");
let req = match auth {
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
AuthStyle::Raw => req.header("Authorization", api_key),
};
let resp = match req.send().await {
Ok(r) => r,
Err(e) => return Err(format!("Network error: {e}")),
};
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Ok(Fetched::Failed(UsageResult::failure(format!(
"Authentication failed (HTTP {status})"
))));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Ok(Fetched::Failed(UsageResult::failure(format!(
"API error (HTTP {status}): {body}"
))));
}
let raw = match resp.bytes().await {
Ok(b) => b,
Err(e) => return Err(format!("Failed to read response: {e}")),
};
match serde_json::from_slice(&raw) {
Ok(v) => Ok(Fetched::Body(v)),
Err(e) => Ok(Fetched::Failed(UsageResult::failure(format!(
"Failed to parse response: {e}"
)))),
}
}
/// 解析 JSON 字段为 f64,兼容数字和字符串格式。
pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
obj.get(field).and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
}
// ── DeepSeek ────────────────────────────────────────────────
// GET https://api.deepseek.com/user/balance
// Response: { balance_infos: [{ currency, total_balance, ... }], is_available }
pub async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? {
Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_deepseek(body: &serde_json::Value) -> Vec<UsageData> {
let is_available = body
.get("is_available")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let mut data = Vec::new();
if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) {
for info in infos {
let currency = info
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("CNY");
data.push(UsageData {
plan_name: Some(currency.to_string()),
remaining: parse_f64_field(info, "total_balance"),
is_valid: Some(is_available),
unit: Some(currency.to_string()),
..Default::default()
});
}
}
data
}
// ── StepFun ─────────────────────────────────────────────────
// GET https://api.stepfun.com/v1/accounts
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
pub async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? {
Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_stepfun(body: &serde_json::Value) -> Vec<UsageData> {
vec![UsageData {
plan_name: Some("StepFun".to_string()),
remaining: Some(parse_f64_field(body, "balance").unwrap_or(0.0)),
unit: Some("CNY".to_string()),
is_valid: Some(true),
..Default::default()
}]
}
// ── SiliconFlow ─────────────────────────────────────────────
// GET https://api.siliconflow.cn/v1/user/info (.cn;海外站 .com 单位 USD)
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
pub async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
let domain = if is_cn {
"api.siliconflow.cn"
} else {
"api.siliconflow.com"
};
let url = format!("https://{domain}/v1/user/info");
match get_json(&url, api_key, AuthStyle::Bearer).await? {
Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageData>, UsageResult> {
let data = match body.get("data") {
Some(d) => d,
None => {
return Err(UsageResult::failure(
"Missing 'data' field in response".to_string(),
))
}
};
let (plan_name, unit) = if is_cn {
("SiliconFlow", "CNY")
} else {
("SiliconFlow (EN)", "USD")
};
Ok(vec![UsageData {
plan_name: Some(plan_name.to_string()),
remaining: Some(parse_f64_field(data, "totalBalance").unwrap_or(0.0)),
unit: Some(unit.to_string()),
is_valid: Some(true),
..Default::default()
}])
}
// ── OpenRouter ──────────────────────────────────────────────
// GET https://openrouter.ai/api/v1/credits
// Response: { data: { total_credits, total_usage } }
pub async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
match get_json(
"https://openrouter.ai/api/v1/credits",
api_key,
AuthStyle::Bearer,
)
.await?
{
Fetched::Body(body) => Ok(UsageResult::ok(parse_openrouter(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_openrouter(body: &serde_json::Value) -> Vec<UsageData> {
let data = body.get("data").unwrap_or(body);
let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0);
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
let remaining = total_credits - total_usage;
vec![UsageData {
plan_name: Some("OpenRouter".to_string()),
remaining: Some(remaining),
total: Some(total_credits),
used: Some(total_usage),
unit: Some("USD".to_string()),
is_valid: Some(remaining > 0.0),
..Default::default()
}]
}
// ── Novita AI ───────────────────────────────────────────────
// GET https://api.novita.ai/v3/user/balance
// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。
pub async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
match get_json(
"https://api.novita.ai/v3/user/balance",
api_key,
AuthStyle::Bearer,
)
.await?
{
Fetched::Body(body) => Ok(UsageResult::ok(parse_novita(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_novita(body: &serde_json::Value) -> Vec<UsageData> {
let available = parse_f64_field(body, "availableBalance").unwrap_or(0.0) / 10000.0;
vec![UsageData {
plan_name: Some("Novita AI".to_string()),
remaining: Some(available),
unit: Some("USD".to_string()),
is_valid: Some(available > 0.0),
..Default::default()
}]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deepseek_maps_balance_infos() {
let body = json!({
"is_available": true,
"balance_infos": [
{ "currency": "CNY", "total_balance": "12.34" },
{ "currency": "USD", "total_balance": 5.0 }
]
});
let data = parse_deepseek(&body);
assert_eq!(data.len(), 2);
assert_eq!(data[0].plan_name.as_deref(), Some("CNY"));
assert_eq!(data[0].remaining, Some(12.34));
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
assert_eq!(data[0].is_valid, Some(true));
assert_eq!(data[1].remaining, Some(5.0));
}
#[test]
fn stepfun_reads_balance() {
let body = json!({ "balance": 88.5 });
let data = parse_stepfun(&body);
assert_eq!(data[0].remaining, Some(88.5));
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
}
#[test]
fn siliconflow_missing_data_is_deterministic_failure() {
let body = json!({ "code": 500 });
let err = parse_siliconflow(&body, true).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("Missing 'data'"));
}
#[test]
fn siliconflow_cn_and_en_units() {
let body = json!({ "data": { "totalBalance": "42.0" } });
let cn = parse_siliconflow(&body, true).unwrap();
let en = parse_siliconflow(&body, false).unwrap();
assert_eq!(cn[0].unit.as_deref(), Some("CNY"));
assert_eq!(en[0].unit.as_deref(), Some("USD"));
assert_eq!(cn[0].remaining, Some(42.0));
}
#[test]
fn openrouter_computes_remaining_credits() {
let body = json!({ "data": { "total_credits": 20.0, "total_usage": 7.5 } });
let data = parse_openrouter(&body);
assert_eq!(data[0].remaining, Some(12.5));
assert_eq!(data[0].total, Some(20.0));
assert_eq!(data[0].used, Some(7.5));
assert_eq!(data[0].is_valid, Some(true));
}
#[test]
fn novita_converts_ten_thousandth_usd() {
let body = json!({ "availableBalance": 123400 });
let data = parse_novita(&body);
assert_eq!(data[0].remaining, Some(12.34));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
let zero = parse_novita(&json!({ "availableBalance": 0 }));
assert_eq!(zero[0].is_valid, Some(false));
}
}
+414
View File
@@ -0,0 +1,414 @@
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
//! Token Plan 套餐额度查询服务
//!
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。
//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec<UsageData>`:
//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" /
//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, AuthStyle, Fetched};
use super::usage_types::{UsageData, UsageResult};
const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
UsageData {
plan_name: Some(name.to_string()),
remaining: Some((100.0 - used_percent).max(0.0)),
total: Some(100.0),
used: Some(used_percent),
unit: Some("%".to_string()),
is_valid: Some(true),
resets_at,
}
}
fn millis_to_iso8601(ms: i64) -> Option<String> {
let secs = ms / 1000;
let nsecs = ((ms % 1000) * 1_000_000) as u32;
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
}
/// 从 JSON 值提取重置时间,兼容字符串和数字格式:
/// - 字符串:直接返回(视为 ISO 8601)
/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
if let Some(n) = value.as_i64() {
if n <= 0 {
return None;
}
// 秒级时间戳 < 1e12,毫秒 >= 1e12
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
return millis_to_iso8601(ms);
}
None
}
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`)
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
}
// ── Kimi For Coding ─────────────────────────────────────────
// GET https://api.kimi.com/coding/v1/usages
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } }
pub async fn query_kimi_coding(api_key: &str) -> Result<UsageResult, String> {
match get_json(
"https://api.kimi.com/coding/v1/usages",
api_key,
AuthStyle::Bearer,
)
.await?
{
Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示)
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
if let Some(detail) = limit_item.get("detail") {
tiers.push(kimi_limit_tier(TIER_FIVE_HOUR, detail));
}
}
}
// 总体用量(周限额)
if let Some(usage) = body.get("usage") {
tiers.push(kimi_limit_tier(TIER_WEEKLY_LIMIT, usage));
}
tiers
}
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);
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
let used = (limit - remaining).max(0.0);
let utilization = if limit > 0.0 {
(used / limit) * 100.0
} else {
0.0
};
percent_tier(name, utilization, resets_at)
}
// ── 智谱 GLM ────────────────────────────────────────────────
// GET {open.bigmodel.cn | api.z.ai}/api/monitor/usage/quota/limit
// 注意:智谱鉴权不加 Bearer 前缀(cc-switch 实测行为,照搬)。
/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。
/// 实测:`unit: 3` → 5 小时滚动窗口;`unit: 6` → 每周窗口。
/// 缺失或不识别时走重置时间启发式兜底。
fn parse_zhipu(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
// 业务级别错误
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
let msg = body
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(UsageResult::failure(format!("API error: {msg}")));
}
let data = match body.get("data") {
Some(d) => d,
None => {
return Err(UsageResult::failure(
"Missing 'data' field in response".to_string(),
))
}
};
type Entry = (Option<i64>, f64, Option<String>);
let mut five_hour: Option<Entry> = None;
let mut weekly: Option<Entry> = None;
let mut unclassified: Vec<Entry> = Vec::new();
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
for limit_item in limits {
let limit_type = limit_item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") {
continue;
}
let percentage = limit_item
.get("percentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64());
let reset_iso = reset_ms.and_then(millis_to_iso8601);
let entry = (reset_ms, percentage, reset_iso);
match limit_item.get("unit").and_then(|v| v.as_i64()) {
Some(3) if five_hour.is_none() => five_hour = Some(entry),
Some(6) if weekly.is_none() => weekly = Some(entry),
_ => unclassified.push(entry),
}
}
}
// 兜底:无 nextResetTime 的优先归 five_hour,其余按 reset 升序填空槽。
unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
for entry in unclassified {
if five_hour.is_none() {
five_hour = Some(entry);
} else if weekly.is_none() {
weekly = Some(entry);
}
}
let mut tiers = Vec::new();
for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] {
if let Some((_, percentage, resets_at)) = slot {
tiers.push(percent_tier(name, percentage, resets_at));
}
}
Ok(tiers)
}
/// 额度接口与推理接口同 host:bigmodel.cn 与 z.ai 共用同一后端与 JSON shape。
fn zhipu_quota_base(base_url: &str) -> &'static str {
if base_url.to_lowercase().contains("bigmodel.cn") {
"https://open.bigmodel.cn"
} else {
"https://api.z.ai"
}
}
pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
let url = format!(
"{}/api/monitor/usage/quota/limit",
zhipu_quota_base(base_url)
);
match get_json(&url, api_key, AuthStyle::Raw).await? {
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
// ── MiniMax ─────────────────────────────────────────────────
// GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains
// (海外站 api.minimax.io)
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
let domain = if is_cn {
"api.minimaxi.com"
} else {
"api.minimax.io"
};
let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains");
match get_json(&url, api_key, AuthStyle::Bearer).await? {
Fetched::Body(body) => Ok(match parse_minimax(&body) {
Ok(data) => UsageResult::ok(data),
Err(err) => err,
}),
Fetched::Failed(err) => Ok(err),
}
}
fn parse_minimax(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
// 业务级别错误
if let Some(base_resp) = body.get("base_resp") {
let status_code = base_resp
.get("status_code")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
if status_code != 0 {
let msg = base_resp
.get("status_msg")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(UsageResult::failure(format!(
"API error (code {status_code}): {msg}"
)));
}
}
let mut tiers = Vec::new();
let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else {
return Ok(tiers);
};
// 只取 general(编程套餐),跳过 video 等其他模型
let Some(item) = model_remains.iter().find(|item| {
item.get("model_name")
.and_then(|v| v.as_str())
.map(|s| s == "general")
.unwrap_or(false)
}) else {
return Ok(tiers);
};
// 5h 桶:剩余百分比 → 已用百分比
if let Some(remain_pct) = item
.get("current_interval_remaining_percent")
.and_then(|v| v.as_f64())
{
let resets_at = item
.get("end_time")
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
tiers.push(percent_tier(TIER_FIVE_HOUR, 100.0 - remain_pct, resets_at));
}
// 周桶:仅 status=1 时激活;status=3 等表示该套餐无周限额,跳过
if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) {
if let Some(remain_pct) = item
.get("current_weekly_remaining_percent")
.and_then(|v| v.as_f64())
{
let resets_at = item
.get("weekly_end_time")
.and_then(|v| v.as_i64())
.and_then(millis_to_iso8601);
tiers.push(percent_tier(
TIER_WEEKLY_LIMIT,
100.0 - remain_pct,
resets_at,
));
}
}
Ok(tiers)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn kimi_coding_flattens_limits_and_usage() {
let body = json!({
"limits": [
{ "detail": { "limit": 100, "remaining": 40, "resetTime": 1_754_000_000_000i64 } }
],
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(tiers[0].remaining, Some(40.0));
assert!(tiers[0].resets_at.is_some());
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(10.0));
assert_eq!(
tiers[1].resets_at.as_deref(),
Some("2026-08-01T00:00:00Z")
);
}
#[test]
fn kimi_coding_reset_time_seconds_vs_millis() {
// 秒级时间戳自动 ×1000
let v = json!(1_754_000_000i64);
assert!(extract_reset_time(&v).is_some());
// 0 / 负值视为无重置时间
assert_eq!(extract_reset_time(&json!(0)), None);
assert_eq!(extract_reset_time(&json!(-1)), None);
}
#[test]
fn zhipu_classifies_by_unit_field() {
let body = json!({
"success": true,
"data": {
"limits": [
{ "type": "TOKENS_LIMIT", "percentage": 35.0, "unit": 3, "nextResetTime": 1_754_000_000_000i64 },
{ "type": "TOKENS_LIMIT", "percentage": 80.0, "unit": 6, "nextResetTime": 1_754_500_000_000i64 }
]
}
});
let tiers = parse_zhipu(&body).unwrap();
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(35.0));
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(80.0));
}
#[test]
fn zhipu_business_error_is_deterministic_failure() {
let body = json!({ "success": false, "msg": "token invalid" });
let err = parse_zhipu(&body).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("token invalid"));
}
#[test]
fn minimax_picks_general_and_inverts_remaining() {
let body = json!({
"base_resp": { "status_code": 0, "status_msg": "success" },
"model_remains": [
{ "model_name": "video", "current_interval_remaining_percent": 50.0 },
{
"model_name": "general",
"current_interval_remaining_percent": 70.0,
"end_time": 1_754_000_000_000i64,
"current_weekly_status": 1,
"current_weekly_remaining_percent": 95.0,
"weekly_end_time": 1_754_500_000_000i64
}
]
});
let tiers = parse_minimax(&body).unwrap();
assert_eq!(tiers.len(), 2);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(30.0));
assert_eq!(tiers[0].remaining, Some(70.0));
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(5.0));
}
#[test]
fn minimax_skips_inactive_weekly_bucket() {
let body = json!({
"model_remains": [
{
"model_name": "general",
"current_interval_remaining_percent": 70.0,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 100.0
}
]
});
let tiers = parse_minimax(&body).unwrap();
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
}
#[test]
fn minimax_business_error_is_deterministic_failure() {
let body = json!({ "base_resp": { "status_code": 1002, "status_msg": "invalid key" } });
let err = parse_minimax(&body).unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("invalid key"));
}
}
+182
View File
@@ -0,0 +1,182 @@
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
//! 供应商账单/用量查询统一入口。
//!
//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致
//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。
//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。
//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。
pub mod balance;
pub mod coding_plan;
pub mod usage_types;
pub use usage_types::{UsageData, UsageResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UsageKind {
BalanceDeepseek,
BalanceSiliconflow,
BalanceOpenrouter,
BalanceStepfun,
BalanceNovita,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
}
impl UsageKind {
/// 与前端 / SQLite settings(`usage_kinds:<provider_name>`)约定的字符串形式。
pub fn as_str(&self) -> &'static str {
match self {
UsageKind::BalanceDeepseek => "balance:deepseek",
UsageKind::BalanceSiliconflow => "balance:siliconflow",
UsageKind::BalanceOpenrouter => "balance:openrouter",
UsageKind::BalanceStepfun => "balance:stepfun",
UsageKind::BalanceNovita => "balance:novita",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
}
}
pub const ALL: [UsageKind; 8] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
UsageKind::BalanceStepfun,
UsageKind::BalanceNovita,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
];
}
impl std::str::FromStr for UsageKind {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"balance:deepseek" => UsageKind::BalanceDeepseek,
"balance:siliconflow" => UsageKind::BalanceSiliconflow,
"balance:openrouter" => UsageKind::BalanceOpenrouter,
"balance:stepfun" => UsageKind::BalanceStepfun,
"balance:novita" => UsageKind::BalanceNovita,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
_ => return Err(()),
})
}
}
/// 按 base_url 子串匹配可支持的查询类型;无匹配返回空 vec。
/// 一个 base_url 理论上可同时命中多种(套餐 + 余额),故返回 Vec。
pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
let url = base_url.to_lowercase();
let mut kinds = Vec::new();
if url.contains("api.deepseek.com") {
kinds.push(UsageKind::BalanceDeepseek);
}
if url.contains("api.siliconflow.cn") {
kinds.push(UsageKind::BalanceSiliconflow);
}
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
}
if url.contains("api.novita.ai") {
kinds.push(UsageKind::BalanceNovita);
}
if url.contains("api.kimi.com") && url.contains("/coding") {
kinds.push(UsageKind::PlanKimiCoding);
}
if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") {
kinds.push(UsageKind::PlanZhipu);
}
if url.contains("api.minimaxi.com") {
kinds.push(UsageKind::PlanMinimax);
}
kinds
}
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
pub async fn query_kind(
kind: UsageKind,
base_url: &str,
api_key: &str,
) -> Result<UsageResult, String> {
let lower = base_url.to_lowercase();
match kind {
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await,
UsageKind::BalanceSiliconflow => {
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await
}
UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key).await,
UsageKind::BalanceStepfun => balance::query_stepfun(api_key).await,
UsageKind::BalanceNovita => balance::query_novita(api_key).await,
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key).await,
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key).await,
UsageKind::PlanMinimax => {
coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_provider_maps_known_hosts() {
let cases: [(&str, UsageKind); 8] = [
("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek),
("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow),
("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter),
("https://api.stepfun.com/v1", UsageKind::BalanceStepfun),
("https://api.novita.ai/v3", UsageKind::BalanceNovita),
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
(
"https://open.bigmodel.cn/api/paas/v4",
UsageKind::PlanZhipu,
),
("https://api.minimaxi.com/v1", UsageKind::PlanMinimax),
];
for (url, expected) in cases {
assert_eq!(
detect_provider(url),
vec![expected],
"url: {url}"
);
}
// z.ai 也命中智谱
assert_eq!(
detect_provider("https://api.z.ai/api/paas/v4"),
vec![UsageKind::PlanZhipu]
);
}
#[test]
fn detect_provider_unknown_url_returns_empty() {
assert!(detect_provider("https://api.openai.com/v1").is_empty());
assert!(detect_provider("https://example.com").is_empty());
assert!(detect_provider("").is_empty());
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
}
#[test]
fn usage_kind_string_roundtrip() {
use std::str::FromStr;
for kind in UsageKind::ALL {
let s = kind.as_str();
assert_eq!(UsageKind::from_str(s), Ok(kind), "kind: {s}");
}
assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").is_err());
}
}
+76
View File
@@ -0,0 +1,76 @@
// Adapted from cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
//! Shared return contract for provider billing/usage queries.
//!
//! MUST stay camelCase: the frontend reads `planName` / `resetsAt` /
//! `isValid` — snake_case serialization would silently misalign every field.
use serde::Serialize;
#[derive(Serialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct UsageData {
pub plan_name: Option<String>,
pub remaining: Option<f64>,
pub total: Option<f64>,
pub used: Option<f64>,
pub unit: Option<String>,
pub is_valid: Option<bool>,
pub resets_at: Option<String>,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct UsageResult {
pub success: bool,
pub data: Option<Vec<UsageData>>,
pub error: Option<String>,
}
impl UsageResult {
pub fn ok(data: Vec<UsageData>) -> Self {
UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: None,
}
}
pub fn failure(msg: String) -> Self {
UsageResult {
success: false,
data: None,
error: Some(msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_result_serializes_camel_case() {
let result = UsageResult {
success: true,
data: Some(vec![UsageData {
plan_name: Some("five_hour".to_string()),
remaining: Some(65.0),
total: Some(100.0),
used: Some(35.0),
unit: Some("%".to_string()),
is_valid: Some(true),
resets_at: Some("2026-07-29T12:00:00+00:00".to_string()),
}]),
error: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"planName\""), "json: {json}");
assert!(json.contains("\"resetsAt\""), "json: {json}");
assert!(json.contains("\"isValid\""), "json: {json}");
assert!(json.contains("\"plan_name\"") == false, "json: {json}");
assert!(json.contains("\"resets_at\"") == false, "json: {json}");
assert!(json.contains("\"is_valid\"") == false, "json: {json}");
}
}
+20 -8
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.5.2",
"version": "0.6.0",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -26,18 +26,30 @@
},
"bundle": {
"active": true,
"targets": ["msi"],
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.icns"
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": null
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
"macOS": {
"minimumSystemVersion": "10.15"
},
"linux": {
"deb": {
"depends": []
},
"appimage": {
"bundleMediaFramework": false
}
}
}
}
+181 -35
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useConfig } from "./hooks/useConfig";
@@ -8,9 +8,15 @@ import { ProviderEdit } from "./components/ProviderEdit";
import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
import { getModelRef } from "./lib/models-dev";
import {
presetToProviderAndModels,
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import type { Agent, Model, Provider } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -68,6 +74,14 @@ export default function App() {
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showPresetPicker, setShowPresetPicker] = useState(false);
// Provider names that have been added to the in-memory config but not
// yet committed via save(). Pressing "back" in the edit form for one of
// these drops it silently (no confirm); all other dirty navigation
// keeps the existing unsavedConfirm flow.
const [pendingNewProviders, setPendingNewProviders] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
@@ -79,35 +93,6 @@ export default function App() {
return () => clearTimeout(timer);
}, [loading]);
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.metaKey) return;
switch (e.key) {
case "s":
case "S":
e.preventDefault();
save();
break;
case "r":
case "R":
e.preventDefault();
if (dirty && !confirm(t("unsavedConfirm"))) return;
refresh();
break;
case "o":
case "O":
e.preventDefault();
invoke("open_agent_config_dir", { agent }).catch((err) =>
alert(err instanceof Error ? err.message : String(err))
);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [save, refresh, dirty, t, agent]);
// Update window title to reflect unsaved changes.
useEffect(() => {
const app = getCurrentWindow();
@@ -138,8 +123,89 @@ export default function App() {
[config]
);
// trySave calls validateProviders, which takes a library-style
// `(key: string) => string`; useTranslation's `t` is strictly typed to
// the project's i18n key union, so we relax it locally for the call.
const tGeneric = t as unknown as (
key: string,
vars?: Record<string, unknown>,
) => string;
// Wraps `save` with a completeness check. Three flavours of caller:
// 1. Whole-config save (Ctrl+S, footer button, ProviderEdit onSave):
// no `target` → validate every enabled provider, show a confirm
// dialog listing the incomplete ones.
// 2. Switching a specific provider as active: `target` is the
// provider being switched to — only that one needs to be
// complete; sibling providers being merely listed in the config
// must not block the switch.
// 3. Programmatic auto-saves (duplicate) call `save` directly with
// no validation — duplicates inherit completeness from their
// source.
// Returns `true` when the config was actually persisted, `false` when
// the user cancelled the validation confirm (callers can react to this
// to roll back in-memory mutations made before the save).
const trySave = useCallback(
async (target?: string): Promise<boolean> => {
if (!config) return false;
if (dirty) {
const issues = validateProviders(config, config.models, tGeneric, target);
if (issues.length > 0) {
const summary = issues
.map((i) => `• ${i.name}: ${i.reasons.join("; ")}`)
.join("\n");
if (!confirm(t("saveValidationConfirm", { details: summary }))) {
return false;
}
}
}
await save();
// Successful save: clear the "in-memory only" markers — every
// provider is now on disk.
setPendingNewProviders(new Set());
return true;
},
[config, dirty, save, t],
);
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.metaKey) return;
switch (e.key) {
case "s":
case "S":
e.preventDefault();
void trySave();
break;
case "r":
case "R":
e.preventDefault();
if (dirty && !confirm(t("unsavedConfirm"))) return;
refresh();
break;
case "o":
case "O":
e.preventDefault();
invoke("open_agent_config_dir", { agent }).catch((err) =>
alert(err instanceof Error ? err.message : String(err))
);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [trySave, refresh, dirty, t, agent]);
// "Add provider" opens the preset picker; the empty form is reachable via
// the picker's "+ custom config" entry (handleAddCustomProvider).
const handleAddProvider = () => {
if (!config) return;
setShowPresetPicker(true);
};
const handleAddCustomProvider = () => {
if (!config) return;
setShowPresetPicker(false);
const name = `provider-${providers.length + 1}`;
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
updateConfig((cfg) => ({
@@ -159,10 +225,45 @@ export default function App() {
},
},
}));
// Mark as in-memory only — never persisted until the user explicitly
// saves a complete config. Pressing "back" in the edit form will
// silently drop this entry.
setPendingNewProviders((prev) => new Set([...prev, name]));
setEditingProvider(name);
setView("edit");
};
const handleSelectPreset = async (preset: ProviderPreset) => {
if (!config) return;
setShowPresetPicker(false);
const { provider, models, defaultModel } = presetToProviderAndModels(preset, {
existingProviderNames: new Set(Object.keys(config.providers)),
existingModelAliases: new Set(Object.keys(config.models)),
});
updateConfig((cfg) => {
const providers = { ...cfg.providers, [provider.name]: provider };
const updatedModels = { ...cfg.models };
for (const m of models) {
updatedModels[m.alias] = m;
}
return {
...cfg,
providers,
models: updatedModels,
default_model: defaultModel || cfg.default_model,
};
});
// Do NOT auto-save: a freshly-picked preset is intentionally
// incomplete (no api_key yet). The user lands in the edit form
// next; if they fill the key and save, validation passes and the
// config lands on disk. If they back out without saving, the
// in-memory additions are discarded on the next refresh — config.toml
// never receives a half-configured provider.
setPendingNewProviders((prev) => new Set([...prev, provider.name]));
setEditingProvider(provider.name);
setView("edit");
};
const handleUpdateProvider = (provider: Provider) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
@@ -227,7 +328,18 @@ export default function App() {
const models = { ...cfg.models };
for (const [alias, m] of Object.entries(cfg.models)) {
if (m.provider === name) {
const newAlias = newName + alias.slice(name.length);
// The model belongs to this provider by `m.provider === name`. To
// re-key it under the new provider, we want a `newName/...`
// alias. The original code assumed `alias` is exactly
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
// pre-v0.6 data) which would produce `newName-k3` — missing the
// `/` separator and pointing at the wrong model. Use an explicit
// prefix check and fall back to a full re-prefix.
const prefix = `${name}/`;
const newAlias = alias.startsWith(prefix)
? newName + alias.slice(name.length)
: `${newName}/${alias}`;
models[newAlias] = { ...m, alias: newAlias, provider: newName };
}
}
@@ -303,7 +415,13 @@ export default function App() {
});
// Persist the full config to Kimi Switch's SQLite and activate the selected provider.
await save();
const saved = await trySave(name);
if (!saved) {
// Validation was cancelled: undo the in-memory switch by reloading
// the on-disk state, and skip the activation + /reload nudge.
await refresh();
return;
}
await invoke("activate_agent_config_command", { agent });
// /reload is an interactive Kimi Code TUI command with no CLI equivalent,
@@ -317,6 +435,27 @@ export default function App() {
}
};
const handleEditBack = async () => {
const target = editingProvider;
if (target && pendingNewProviders.has(target)) {
// This provider was added in-memory (via preset or "+ custom config")
// but never committed. Drop it silently — no confirm — and reload
// from disk so the list reflects the on-disk truth.
setPendingNewProviders((prev) => {
const next = new Set(prev);
next.delete(target);
return next;
});
await refresh();
} else if (dirty) {
// Real edits to a previously-saved provider — keep the existing
// unsaved-changes confirm so the user doesn't lose work by accident.
if (!confirm(t("unsavedConfirm"))) return;
await refresh();
}
setView("list");
};
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
@@ -508,7 +647,7 @@ export default function App() {
onRawOtherChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
onBack={handleEditBack}
onChange={handleUpdateProvider}
onDelete={() => handleDeleteProvider(currentProvider.name)}
onModelChange={(model) => {
@@ -577,7 +716,7 @@ export default function App() {
}}
onSetDefault={handleSetDefaultModel}
onApplyJson={handleApplyProviderJson}
onSave={save}
onSave={trySave}
/>
) : (
<div className="p-8 text-center text-content-muted">
@@ -603,7 +742,7 @@ export default function App() {
<div className="flex items-center gap-2">
<button
type="button"
onClick={save}
onClick={() => { void trySave(); }}
disabled={!dirty || loading}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none disabled:opacity-50 ${
dirty
@@ -651,6 +790,13 @@ export default function App() {
onCheckUpdate={checkNow}
lastChecked={lastChecked}
/>
<PresetPickerModal
open={showPresetPicker}
onClose={() => setShowPresetPicker(false)}
onSelect={handleSelectPreset}
onCustom={handleAddCustomProvider}
/>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
import { ProviderIcon } from "./ProviderIcon";
interface PresetPickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (preset: ProviderPreset) => void;
onCustom: () => void;
}
const CATEGORY_BADGE: Record<Exclude<PresetCategory, "custom">, string> = {
official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400",
cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400",
third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400",
aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400",
};
const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey> = {
official: "presetCategoryOfficial",
cn_official: "presetCategoryCnOfficial",
third_party: "presetCategoryThirdParty",
aggregator: "presetCategoryAggregator",
};
type SortMode = "original" | "alpha";
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("original");
// Esc to close
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Scroll lock while open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Reset transient state each time the modal opens
useEffect(() => {
if (open) {
setQuery("");
setSort("original");
}
}, [open]);
const presetName = (p: ProviderPreset) =>
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let list = providerPresets;
if (q) {
list = list.filter(
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
);
}
if (sort === "alpha") {
list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b)));
}
return list;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, sort, t]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={t("presetPickerTitle")}
>
<div
className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<div className="text-lg font-semibold text-content-primary">
{t("presetPickerTitle")}
</div>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Search + sort */}
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("presetSearchPlaceholder")}
autoFocus
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex items-center rounded border border-border bg-input p-0.5">
<button
type="button"
onClick={() => setSort("original")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "original"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortOriginal")}
</button>
<button
type="button"
onClick={() => setSort("alpha")}
className={`px-2 py-1 text-xs rounded transition-colors ${
sort === "alpha"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("presetSortAlpha")}
</button>
</div>
</div>
{/* Preset grid */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="py-8 text-center text-sm text-content-muted">
{t("presetNoResults")}
</div>
) : (
<div
className="grid gap-2"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}
>
{visible.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onSelect(p)}
className="flex flex-col items-start gap-2 rounded-lg border border-border bg-input p-3 text-left transition-colors hover:border-blue-500 hover:bg-hover-2"
>
<ProviderIcon name={presetName(p)} icon={p.icon ?? null} color={p.iconColor ?? null} size={32} />
<div className="w-full truncate text-sm font-medium text-content-primary">
{presetName(p)}
</div>
{p.category !== "custom" && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
>
{t(CATEGORY_LABEL[p.category])}
</span>
)}
</button>
))}
</div>
)}
{/* Custom (empty form) entry */}
<button
type="button"
onClick={onCustom}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-content-muted transition-colors hover:border-blue-500 hover:text-content-primary"
>
{t("presetCustomConfig")}
</button>
</div>
{/* Footer hint */}
<div className="border-t border-border px-5 py-2.5 text-xs text-content-muted">
{t("presetFooterHint")}
</div>
</div>
</div>,
document.body
);
}
+9 -1
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult {
@@ -144,13 +145,14 @@ export function ProviderList({
return (
<div
key={provider.name}
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
isActive
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
: "bg-panel border-border hover:border-strong hover:bg-hover"
}`}
onClick={() => onEdit(provider.name)}
>
<div className="flex items-center gap-4 w-full">
<ProviderIcon
name={provider.name}
icon={provider.icon}
@@ -294,6 +296,12 @@ export function ProviderList({
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<UsageFooter
agent={agent}
providerName={provider.name}
usageKinds={provider.usageKinds}
/>
</div>
);
})}
+310
View File
@@ -0,0 +1,310 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import type { Agent } from "../types";
interface UsageData {
planName?: string | null;
remaining?: number | null;
total?: number | null;
used?: number | null;
unit?: string | null;
isValid?: boolean | null;
resetsAt?: string | null;
}
interface UsageResult {
success: boolean;
data?: UsageData[] | null;
error?: string | null;
}
type UsageStatus = "idle" | "loading" | "success" | "error";
interface CacheEntry {
status: "success" | "error";
data: UsageData[];
/** Raw error text from Rust; null = transient failure (invoke rejected). */
error: string | null;
updatedAt: number;
}
// Module-level cache shared across mounts: re-entering the list within the
// stale TTL shows the last result without firing new requests.
const STALE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
const MAX_CONCURRENT = 3;
let running = 0;
const waiters: Array<() => void> = [];
async function acquireSlot(): Promise<void> {
if (running >= MAX_CONCURRENT) {
await new Promise<void>((resolve) => waiters.push(resolve));
}
running += 1;
}
function releaseSlot(): void {
running -= 1;
waiters.shift()?.();
}
interface UsageFooterProps {
agent: Agent;
providerName: string;
usageKinds?: string[];
}
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
const { t } = useTranslation();
const supported = (usageKinds?.length ?? 0) > 0;
// 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}`;
const [status, setStatus] = useState<UsageStatus>("idle");
const [data, setData] = useState<UsageData[]>([]);
/** undefined = no error; null = network error; string = Rust error text. */
const [error, setError] = useState<string | null | undefined>(undefined);
const [now, setNow] = useState(() => Date.now());
// Generation counter: stale responses (unmounted / superseded query) are ignored.
const genRef = useRef(0);
const runQuery = useCallback(
async (forceRefresh: boolean) => {
const gen = ++genRef.current;
setStatus("loading");
setError(undefined);
const finish = (entry: CacheEntry) => {
cache.set(cacheKey, entry);
if (genRef.current !== gen) return;
setStatus(entry.status);
setData(entry.data);
setError(entry.error);
};
try {
await acquireSlot();
let result: UsageResult;
try {
result = await invoke<UsageResult>("query_provider_usage", {
agent,
providerName,
forceRefresh,
});
} finally {
releaseSlot();
}
if (genRef.current !== gen) return;
if (result.success) {
finish({
status: "success",
data: result.data ?? [],
error: null,
updatedAt: Date.now(),
});
} else {
// Deterministic failure: keep last good data for ghost display.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: result.error ?? "",
updatedAt: Date.now(),
});
}
} catch {
// Transient failure (network / timeout): invoke rejected.
finish({
status: "error",
data: cache.get(cacheKey)?.data ?? [],
error: null,
updatedAt: Date.now(),
});
}
},
[agent, providerName, cacheKey]
);
// On mount / provider change: serve fresh cache, otherwise query once.
useEffect(() => {
if (!supported) return;
const cached = cache.get(cacheKey);
if (cached) {
setStatus(cached.status);
setData(cached.data);
setError(cached.error);
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
}
void runQuery(false);
}, [supported, cacheKey, runQuery]);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
// Tick the reset countdown once a minute while showing data.
useEffect(() => {
if (status !== "success") return;
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, [status]);
if (!supported || status === "idle") return null;
const percentOf = (d: UsageData): number | null => {
if (d.used != null && d.total != null && d.total > 0)
return (d.used / d.total) * 100;
if (d.remaining != null && d.total != null && d.total > 0)
return (1 - d.remaining / d.total) * 100;
return null;
};
const formatAmount = (
value: number | null | undefined,
unit?: string | null
): string => {
if (value == null) return "—";
const symbol =
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
return `${symbol}${num}`;
};
const formatReset = (resetsAt?: string | null): string | null => {
if (!resetsAt) return null;
const ms = new Date(resetsAt).getTime() - now;
if (Number.isNaN(ms)) return null;
if (ms <= 0) return t("usageResetDone");
const mins = Math.floor(ms / 60_000);
if (mins < 60) return t("usageResetIn", { time: `${Math.max(mins, 1)}m` });
const hours = Math.floor(mins / 60);
if (hours < 24) return t("usageResetIn", { time: `${hours}h` });
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
};
const refreshBtn = (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void runQuery(true);
}}
disabled={status === "loading"}
title={t("usageRefresh")}
aria-label={t("usageRefresh")}
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{status === "loading" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
);
// Loading with no prior data: skeleton bar.
if (status === "loading" && data.length === 0) {
return (
<div
className="w-full border-t border-border pt-2 mt-1"
aria-label={t("usageLoading")}
>
<div className="h-3.5 w-2/3 rounded bg-hover-2 animate-pulse" />
</div>
);
}
if (status === "error" && data.length === 0) {
const text =
error == null
? t("usageNetworkError")
: /401|api[ -]?key|invalid key/i.test(error)
? t("usageInvalidKey")
: error
? `${t("usageQueryFailed")} · ${error}`
: t("usageQueryFailed");
return (
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
<span className="text-red-500 dark:text-red-400 truncate" title={text}>
{text}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void runQuery(true);
}}
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
{t("usageRetry")}
</button>
</div>
);
}
// success, or error/loading with last-good data (ghost).
const ghost = status !== "success";
return (
<div
className={`w-full border-t border-border pt-2 mt-1 flex flex-col gap-1 text-xs ${
ghost ? "opacity-50" : ""
}`}
>
{data.map((d, i) => {
const isPlan = !!d.planName;
const pct = isPlan ? percentOf(d) : null;
const color =
pct == null
? "text-content-muted"
: pct < 70
? "text-green-600 dark:text-green-400"
: pct < 90
? "text-orange-600 dark:text-orange-400"
: "text-red-500 dark:text-red-400";
const resetText = isPlan ? formatReset(d.resetsAt) : null;
let main: string;
if (d.isValid === false) {
main = t("usageInvalidKey");
} else if (isPlan) {
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
main =
pct != null
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
: d.remaining != null
? `${label}${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`
: label || "—";
} else {
main = `${t("usageBalance")} ${formatAmount(d.remaining, d.unit)}`;
}
return (
<div key={i} className="flex items-center gap-1.5 min-w-0">
<span aria-hidden="true">{isPlan ? "⚡" : "💰"}</span>
<span
className={`tabular-nums truncate ${
d.isValid === false ? "text-red-500 dark:text-red-400" : color
}`}
title={main}
>
{main}
</span>
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}
{i === data.length - 1 && refreshBtn}
</div>
);
})}
{data.length === 0 && (
<div className="flex items-center gap-1.5">
<span className="text-content-muted">—</span>
{refreshBtn}
</div>
)}
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
// Provider presets: one-click form filling for well-known providers.
// Preset data structure inspired by cc-switch (MIT, © Jason Young)
// https://github.com/farion1231/cc-switch
import type { Model, Provider, ProviderType } from "../types";
import { getModelRef, capabilitiesFromRef } from "../lib/models-dev";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
/** Usage query kinds supported by the Rust usage layer (v1 union). */
export type UsageKind =
| "balance:deepseek"
| "balance:siliconflow"
| "balance:openrouter"
| "balance:stepfun"
| "balance:novita"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax";
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:deepseek",
"balance:siliconflow",
"balance:openrouter",
"balance:stepfun",
"balance:novita",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
]);
export type PresetCategory =
| "official"
| "cn_official"
| "third_party"
| "aggregator"
| "custom";
export interface ProviderPreset {
/** Unique key, also used as the default Provider name, e.g. "deepseek". */
id: string;
/** Fallback display name (English). */
name: string;
/** i18n key; when present the UI prefers t(nameKey). */
nameKey?: string;
/** Official website / console URL. */
websiteUrl?: string;
apiKeyUrl?: string;
/** Category: sorting & badge. */
category: PresetCategory;
/** Written directly to provider.provider_type. */
providerType: ProviderType;
/** Preset base_url; `null` means no default (e.g. Anthropic). */
baseUrl: string | null;
/** Icon key from src/icons/extracted (falls back to initials when absent). */
icon?: string;
iconColor?: string;
/**
* Pre-filled model mappings. The first entry becomes the default model.
* `model` holds the real model id; the alias is NOT hand-written here —
* presetToProviderAndModels() forces `${providerName}/${modelId}` so that
* handleDuplicateProvider's `alias.slice(name.length)` keeps working.
*/
models: Array<{
/** Real model id sent to the provider API. */
model: string;
/** Optional display name override; defaults to models.dev derivation. */
displayName?: string;
/** Optional context override; defaults to the priority chain below. */
maxContextSize?: number;
/** Defaults to ["thinking"]. */
capabilities?: string[];
}>;
/**
* Billing/usage query kinds; omitted means "do not query".
* Persisted to SQLite settings by the Rust side on save, never to
* config.toml.
*/
usageKinds?: ReadonlyArray<UsageKind>;
}
export const providerPresets: ProviderPreset[] = [
{
id: "anthropic",
name: "Anthropic",
nameKey: "presetNameAnthropic",
websiteUrl: "https://www.anthropic.com",
category: "official",
providerType: "anthropic",
// No default base_url; Kimi Code automatically targets api.anthropic.com.
baseUrl: null,
icon: "anthropic",
models: [
{ model: "claude-opus-4-5" },
{ model: "claude-sonnet-4-5" },
{ model: "claude-haiku-4-5" },
],
},
{
id: "kimi-coding",
name: "Kimi For Coding",
nameKey: "presetNameKimiCoding",
websiteUrl: "https://www.kimi.com/code",
category: "official",
providerType: "kimi",
baseUrl: "https://api.kimi.com/coding/v1",
icon: "kimi",
models: [
// kimi-for-coding is not in the models.dev snapshot; context comes
// from the regex fallback in model-defaults (262144).
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
{ model: "kimi-k2.7-code" },
],
usageKinds: ["plan:kimi_coding"],
},
{
id: "moonshot",
name: "Moonshot AI",
nameKey: "presetNameMoonshot",
websiteUrl: "https://platform.moonshot.ai",
category: "cn_official",
providerType: "kimi",
baseUrl: "https://api.moonshot.ai/v1",
icon: "kimi",
models: [
{ model: "kimi-k2.7-code" },
{ model: "kimi-k2-thinking-turbo" },
{ model: "kimi-k2.6" },
],
},
{
id: "deepseek",
name: "DeepSeek",
nameKey: "presetNameDeepseek",
websiteUrl: "https://platform.deepseek.com",
category: "cn_official",
providerType: "openai",
baseUrl: "https://api.deepseek.com/v1",
icon: "deepseek",
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
usageKinds: ["balance:deepseek"],
},
{
id: "zhipu",
name: "Zhipu GLM",
nameKey: "presetNameZhipu",
websiteUrl: "https://open.bigmodel.cn",
category: "cn_official",
providerType: "openai",
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
icon: "zhipu",
models: [
{ model: "glm-4.7" },
{ model: "glm-4.6" },
{ model: "glm-4.5-air" },
],
usageKinds: ["plan:zhipu"],
},
{
id: "zai",
name: "z.ai",
nameKey: "presetNameZai",
websiteUrl: "https://z.ai",
category: "third_party",
providerType: "openai",
baseUrl: "https://api.z.ai/api/paas/v4",
icon: "zhipu",
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
usageKinds: ["plan:zhipu"],
},
{
id: "bailian",
name: "Alibaba Bailian",
nameKey: "presetNameBailian",
websiteUrl: "https://bailian.console.aliyun.com",
category: "cn_official",
providerType: "openai",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
icon: "bailian",
models: [
{ model: "qwen3-max" },
{ model: "qwen3-coder-plus" },
{ model: "qwen3.5-plus" },
],
},
{
id: "minimax",
name: "MiniMax",
nameKey: "presetNameMinimax",
websiteUrl: "https://platform.minimaxi.com",
category: "cn_official",
providerType: "openai",
baseUrl: "https://api.minimaxi.com/v1",
icon: "minimax",
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
usageKinds: ["plan:minimax"],
},
{
id: "stepfun",
name: "StepFun",
nameKey: "presetNameStepfun",
websiteUrl: "https://platform.stepfun.com",
category: "cn_official",
providerType: "openai",
baseUrl: "https://api.stepfun.com/v1",
icon: "stepfun",
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
usageKinds: ["balance:stepfun"],
},
{
id: "siliconflow",
name: "SiliconFlow",
nameKey: "presetNameSiliconflow",
websiteUrl: "https://siliconflow.cn",
category: "cn_official",
providerType: "openai",
baseUrl: "https://api.siliconflow.cn/v1",
icon: "siliconflow",
// The extracted icon has no metadata defaultColor; set it explicitly.
iconColor: "#6E29F6",
models: [
{ model: "Qwen/Qwen3-235B-A22B" },
{ model: "moonshotai/Kimi-K2.5" },
// Not covered by the snapshot; fill context/displayName explicitly.
{
model: "deepseek-ai/DeepSeek-V3.2",
displayName: "DeepSeek V3.2",
maxContextSize: 163840,
},
],
usageKinds: ["balance:siliconflow"],
},
{
id: "novita",
name: "Novita AI",
nameKey: "presetNameNovita",
websiteUrl: "https://novita.ai",
category: "third_party",
providerType: "openai",
// Note: /v3, not /v1.
baseUrl: "https://api.novita.ai/v3",
icon: "novita",
models: [
// Not covered by the snapshot; fill context/displayName explicitly.
{
model: "deepseek/deepseek-v3.2",
displayName: "DeepSeek V3.2",
maxContextSize: 163840,
},
{ model: "moonshotai/kimi-k2-thinking" },
{ model: "zai-org/glm-4.6" },
],
usageKinds: ["balance:novita"],
},
{
id: "openrouter",
name: "OpenRouter",
nameKey: "presetNameOpenrouter",
websiteUrl: "https://openrouter.ai",
category: "aggregator",
providerType: "openai",
baseUrl: "https://openrouter.ai/api/v1",
icon: "openrouter",
models: [
// OpenRouter uses dotted versions which the snapshot does not index;
// fill context/displayName explicitly for this one.
{
model: "anthropic/claude-opus-4.5",
displayName: "Claude Opus 4.5",
maxContextSize: 200000,
},
{ model: "openai/gpt-5.2" },
{ model: "google/gemini-3-pro-preview" },
],
usageKinds: ["balance:openrouter"],
},
{
id: "openai",
name: "OpenAI",
nameKey: "presetNameOpenai",
websiteUrl: "https://platform.openai.com",
category: "official",
providerType: "openai",
baseUrl: "https://api.openai.com/v1",
icon: "openai",
models: [
{ model: "gpt-5.2" },
{ model: "gpt-5.1-codex-max" },
{ model: "gpt-5-mini" },
],
},
{
id: "google-genai",
name: "Google AI Studio",
nameKey: "presetNameGoogleGenai",
websiteUrl: "https://aistudio.google.com",
category: "official",
providerType: "google-genai",
baseUrl: "https://generativelanguage.googleapis.com",
icon: "google",
models: [
{ model: "gemini-3-pro-preview" },
{ model: "gemini-3-flash-preview" },
{ model: "gemini-2.5-flash" },
],
},
{
id: "volcengine",
name: "Volcengine Ark",
nameKey: "presetNameVolcengine",
websiteUrl: "https://www.volcengine.com/product/ark",
category: "cn_official",
providerType: "openai",
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
icon: "huoshan",
// v1: inference only — plan queries need separate AK/SK (P2).
models: [
{
model: "doubao-seed-1-6-250615",
displayName: "Doubao Seed 1.6",
},
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
],
},
];
export interface PresetConversionResult {
provider: Provider;
models: Model[];
/** Alias to write into config.default_model ("" when no models). */
defaultModel: string;
usageKinds: ReadonlyArray<string> | undefined;
}
/**
* Convert a preset into concrete Provider + Model entries.
*
* - Provider name collisions get a "-2" / "-3" suffix.
* - Model aliases are forced to `${name}/${modelId}` and deduped one by one
* against existing aliases (also with "-2" / "-3" suffixes).
* - max_context_size priority: preset override → models.dev snapshot →
* regex rules → DEFAULT_MAX_CONTEXT_SIZE (the last two are both handled
* inside getDefaultMaxContextSize).
*/
export function presetToProviderAndModels(
preset: ProviderPreset,
options: {
existingProviderNames: Set<string>;
existingModelAliases: Set<string>;
},
): PresetConversionResult {
let name = preset.id;
let n = 2;
while (options.existingProviderNames.has(name)) {
name = `${preset.id}-${n}`;
n++;
}
const models: Model[] = preset.models.map((m) => {
let alias = `${name}/${m.model}`;
let k = 2;
while (options.existingModelAliases.has(alias)) {
alias = `${name}/${m.model}-${k}`;
k++;
}
const ref = getModelRef(m.model);
const refCaps = ref ? capabilitiesFromRef(ref) : [];
const maxContextSize =
m.maxContextSize ?? ref?.context ?? getDefaultMaxContextSize(m.model);
return {
alias,
provider: name,
model: m.model,
max_context_size: maxContextSize,
display_name: m.displayName ?? ref?.name ?? alias,
capabilities: m.capabilities ?? (refCaps.length > 0 ? refCaps : ["thinking"]),
supports_1m: maxContextSize >= 1_000_000,
raw_other: {},
};
});
const provider: Provider = {
name,
provider_type: preset.providerType,
base_url: preset.baseUrl,
api_key: null,
env: {},
note: null,
official_url: preset.websiteUrl ?? null,
managed: false,
enabled: true,
icon: preset.icon ?? null,
icon_color: preset.iconColor ?? null,
usageKinds: preset.usageKinds ? [...preset.usageKinds] : undefined,
raw_other: {},
};
return {
provider,
models,
defaultModel: models[0]?.alias ?? "",
usageKinds: preset.usageKinds,
};
}
// Dev-only cross-end drift check: assert that every usage kind referenced by
// any preset is one the Rust `UsageKind` enum knows how to handle. The
// runtime filter in `query_provider_usage` silently drops unknown kinds,
// so without this guard a typo or schema drift would only surface as a
// missing usage footer with no error message. Gated by Vite's DEV flag so
// it costs nothing in production bundles.
if (import.meta.env.DEV) {
for (const preset of providerPresets) {
if (!preset.usageKinds) continue;
for (const kind of preset.usageKinds) {
if (!SUPPORTED_USAGE_KINDS.has(kind)) {
// eslint-disable-next-line no-console
console.error(
`[providerPresets] preset "${preset.id}" references unknown usage kind "${kind}". ` +
`Add it to src-tauri/src/services/mod.rs UsageKind + SUPPORTED_USAGE_KINDS, or remove it from the preset.`,
);
}
}
}
}
+47
View File
@@ -11,6 +11,12 @@ export const enTranslations: Record<TranslationKey, string> = {
reloadConfig: "Reload Config",
openConfigDir: "Open Config Dir",
unsavedConfirm: "You have unsaved changes. Are you sure you want to reload?",
validationNoApiKey: "Missing API key",
validationNoBaseUrl: "Missing base URL",
validationNoModels: "No models configured",
validationBlankModels: "Model entries with empty model id: {aliases}",
saveValidationConfirm:
"The following providers are not fully configured. Save anyway?\n\n{details}\n\n(Click OK to save to config.toml as-is; click Cancel to keep editing.)",
providerNotFound: "Provider not found",
backToList: "Back to list",
syncToOmp: "Sync to OMP",
@@ -316,4 +322,45 @@ export const enTranslations: Record<TranslationKey, string> = {
// Sessions — preview
previewCollapseChars: "Collapse · {n} chars",
previewExpandChars: "Expand · {n} chars",
// Provider presets
presetPickerTitle: "Choose a provider preset",
presetSearchPlaceholder: "Search presets...",
presetSortOriginal: "Default",
presetSortAlpha: "A-Z",
presetNoResults: "No matching presets",
presetCustomConfig: "+ Custom configuration",
presetFooterHint: "Presets only pre-fill the form; every field stays editable afterwards.",
presetCategoryOfficial: "Official",
presetCategoryCnOfficial: "China",
presetCategoryThirdParty: "Third-party",
presetCategoryAggregator: "Aggregator",
presetNameAnthropic: "Anthropic",
presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot Platform",
presetNameDeepseek: "DeepSeek",
presetNameZhipu: "Zhipu GLM",
presetNameZai: "z.ai (Zhipu Global)",
presetNameBailian: "Alibaba Bailian",
presetNameMinimax: "MiniMax",
presetNameStepfun: "StepFun",
presetNameSiliconflow: "SiliconFlow",
presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter",
presetNameOpenai: "OpenAI",
presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "Volcengine Ark",
// Provider usage
usageBalance: "Balance",
usageUsed: "used",
usageRemaining: "left",
usageRefresh: "Refresh",
usageRetry: "Retry",
usageQueryFailed: "Query failed",
usageNetworkError: "Network error",
usageInvalidKey: "Invalid API key",
usageResetIn: "resets in {time}",
usageResetDone: "reset",
usageLoading: "Querying…",
};
+47
View File
@@ -9,6 +9,12 @@ export const zhTranslations = {
reloadConfig: "读取配置",
openConfigDir: "打开配置目录",
unsavedConfirm: "有未保存修改,确定要重新读取配置吗?",
validationNoApiKey: "缺少 API Key",
validationNoBaseUrl: "缺少 Base URL",
validationNoModels: "未配置模型",
validationBlankModels: "存在未填写 model id 的模型:{aliases}",
saveValidationConfirm:
"以下供应商尚未完成配置,仍要保存吗?\n\n{details}\n\n(点击「确定」仍会保存到 config.toml;点击「取消」可继续编辑)",
providerNotFound: "供应商不存在",
backToList: "返回列表",
syncToOmp: "同步到 OMP",
@@ -311,6 +317,47 @@ export const zhTranslations = {
// Sessions — preview
previewCollapseChars: "收起 · {n} 字符",
previewExpandChars: "展开全文 · {n} 字符",
// Provider presets
presetPickerTitle: "选择预设供应商",
presetSearchPlaceholder: "搜索预设供应商...",
presetSortOriginal: "原始",
presetSortAlpha: "A-Z",
presetNoResults: "没有匹配的预设",
presetCustomConfig: "+ 自定义配置",
presetFooterHint: "预设只是一键填表,后续可继续修改所有字段。",
presetCategoryOfficial: "官方",
presetCategoryCnOfficial: "国产",
presetCategoryThirdParty: "第三方",
presetCategoryAggregator: "聚合",
presetNameAnthropic: "Anthropic 官方",
presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot 平台",
presetNameDeepseek: "DeepSeek",
presetNameZhipu: "智谱 GLM",
presetNameZai: "z.ai(智谱海外)",
presetNameBailian: "阿里百炼",
presetNameMinimax: "MiniMax",
presetNameStepfun: "阶跃星辰",
presetNameSiliconflow: "硅基流动",
presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter",
presetNameOpenai: "OpenAI 官方",
presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "火山方舟",
// Provider usage
usageBalance: "余额",
usageUsed: "已使用",
usageRemaining: "剩余",
usageRefresh: "刷新",
usageRetry: "重试",
usageQueryFailed: "查询失败",
usageNetworkError: "网络异常",
usageInvalidKey: "API Key 无效",
usageResetIn: "{time} 后重置",
usageResetDone: "已重置",
usageLoading: "查询中…",
} as const;
export type TranslationKey = keyof typeof zhTranslations;
+86
View File
@@ -0,0 +1,86 @@
import type { Config, Model, ProviderType } from "../types";
/**
* Provider types that must have a base_url set in order to function.
* Anthropic and Vertexai intentionally have no default — they read the
* agent's CLI environment / SDK defaults, so leaving base_url empty is
* a valid (and the only) state.
*/
const TYPES_REQUIRING_BASE_URL: ReadonlySet<ProviderType> = new Set<ProviderType>([
"openai",
"openai_responses",
"kimi",
"google-genai",
]);
export interface ProviderIssue {
name: string;
reasons: string[];
}
/**
* Inspect a config and report any enabled providers that are not ready to
* use. Used by the save flow to warn the user before persisting an
* incomplete state to config.toml / SQLite.
*
* "Incomplete" means any of:
* - missing API key (and not a managed OAuth provider)
* - missing base_url for provider types that need one
* - zero configured models
*
* Disabled providers are skipped — the user explicitly turned them off.
*/
export function validateProviders(
config: Config,
modelIndex: Record<string, Model>,
// The i18n `t` from useTranslation() is strictly typed to its known key
// union; a generic (key: string) signature is the right shape for a
// library-style helper that doesn't want to import the project's i18n
// module directly.
t: (key: string, vars?: Record<string, unknown>) => string,
// When set, only inspect this single provider. Used when activating a
// specific provider: sibling providers merely listed in the config
// must not block the switch.
target?: string,
): ProviderIssue[] {
const issues: ProviderIssue[] = [];
for (const provider of Object.values(config.providers)) {
if (target && provider.name !== target) continue;
if (!provider.enabled) continue;
const reasons: string[] = [];
const apiKey = provider.api_key?.trim();
if (!apiKey && !provider.managed) {
reasons.push(t("validationNoApiKey"));
}
const baseUrl = provider.base_url?.trim();
if (!baseUrl && TYPES_REQUIRING_BASE_URL.has(provider.provider_type)) {
reasons.push(t("validationNoBaseUrl"));
}
const providerModels = Object.values(modelIndex).filter(
(m) => m.provider === provider.name,
);
if (providerModels.length === 0) {
reasons.push(t("validationNoModels"));
} else {
// A model entry exists but its `model` field may be blank (e.g. the
// user hit "+ 添加模型" in the edit page and saved before filling
// in the upstream model id). Those still break the provider.
const blankAliases = providerModels
.filter((m) => !m.model || !m.model.trim())
.map((m) => m.alias);
if (blankAliases.length > 0) {
reasons.push(
t("validationBlankModels", { aliases: blankAliases.join(", ") }),
);
}
}
if (reasons.length > 0) {
issues.push({ name: provider.name, reasons });
}
}
return issues;
}
+6
View File
@@ -28,6 +28,12 @@ export interface Provider {
icon?: string | null;
/** Custom icon color; falls back to icon metadata defaultColor. */
icon_color?: string | null;
/**
* Usage query kinds (e.g. "balance:deepseek", "plan:kimi_coding").
* Merged in by the Rust side on load (SQLite settings / base_url detect)
* and persisted back to SQLite on save; never written to config.toml.
*/
usageKinds?: string[];
/** Extra agent-specific provider fields preserved across edits. */
raw_other?: unknown;
}