Compare commits

..
19 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
28 changed files with 1676 additions and 1031 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"]
+24 -30
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)
---
@@ -51,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)。
## 核心特性
| 分类 | 功能 |
@@ -498,38 +509,21 @@ 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 及以后版本的发布渠道。
## 常见问题
+27 -30
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)
---
@@ -51,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 |
@@ -494,38 +505,24 @@ 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
-1
View File
@@ -1 +0,0 @@
13
+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.
-144
View File
@@ -1,144 +0,0 @@
# 用户自定义预设方案(User Presets)
## 动机
当前内置预设(`providerPresets.ts`)硬编码在源码中,用户无法自行添加自定义供应商预设或修改已有预设的 `apiKeyUrl`(获取 Key / 推广链接)。每次新增或修改都需要修改源码、重新编译、重新打包,缺乏灵活性。
## 目标
用户可通过外部配置文件**零代码**添加/修改预设供应商,包含:
- 自定义供应商预设(名称、Base URL、API 格式、图标、套餐类型)
- 自定义获取 API Key 入口 / 推广链接(`apiKeyUrl`,可直接放带 ref 的链接)
- 自定义模型列表(模型 ID、显示名称、上下文大小、能力)
- 覆盖内置预设(同一 `id` 时用户预设优先)
## 方案设计
### 1. 配置文件位置
```
Windows: %APPDATA%/KimiSwitch/user-presets.json
macOS: ~/Library/Application Support/KimiSwitch/user-presets.json
Linux: ~/.config/KimiSwitch/user-presets.json
```
统一使用应用数据目录(与 `app_config_dir` 一致),不扫描项目根目录——发行版是编译后的二进制,终端用户没有"项目根目录"。
首次启动时若文件不存在,自动创建一个空 `[]` 模板文件,方便用户直接编辑。
PresetPickerModal / 设置页提供一个"打开预设文件夹"按钮,让用户能找到文件位置。
### 2. 文件格式
> 支持 `//` 行注释(jsonc 风格):加载时先剥离 `//` 注释再 `JSON.parse`,方便用户写备注。
```jsonc
[
{
// 唯一标识,与内置预设 id 重名则覆盖内置预设
"id": "my-custom-vendor",
// 显示名称(fallback 英文)
"name": "My Custom Vendor",
// i18n key(可选,有则优先用 t(nameKey))
"nameKey": "presetNameMyCustom",
// 官网链接
"websiteUrl": "https://example.com",
// 获取 API Key 入口 / 推广链接(可直接放带 ref 的链接,如 ?ref=CODE)
"apiKeyUrl": "https://example.com?ref=MYCODE",
// 分类:official | cn_official | third_party | aggregator | custom
"category": "third_party",
// API 格式:openai | anthropic | google-genai | vertexai | kimi | openai_responses
"providerType": "openai",
// Base URL(null 表示使用默认)
"baseUrl": "https://api.example.com/v1",
// 图标 key(从 src/icons/extracted 中选,或空字符串用首字母)
"icon": "",
// 图标颜色(可选,hex 格式)
"iconColor": "",
// 计费模式:subscription | pay_as_you_go
"billingMode": "pay_as_you_go",
// 用量查询类型(可选,参考内置预设)
"usageKinds": [],
// 模型列表
"models": [
{
"model": "my-model-1",
"displayName": "My Model 1",
"maxContextSize": 128000,
"capabilities": ["thinking"]
}
]
}
]
```
### 3. 加载逻辑(伪代码)
```
function loadAllPresets(): ProviderPreset[] {
const builtin = providerPresets; // 内置预设
const user = readUserPresetsFile(); // 读取用户预设文件
if (!user || user.length === 0) return builtin;
const merged = new Map<string, ProviderPreset>();
for (const p of builtin) merged.set(p.id, p); // 内置先进
for (const p of user) merged.set(p.id, p); // 用户覆盖
return Array.from(merged.values());
}
```
### 4. 需要修改的代码文件
| 文件 | 修改内容 |
|------|----------|
| `src/config/providerPresets.ts` | 导出 `loadAllPresets()` 函数替代直接使用 `providerPresets` 数组 |
| `src/config/userPresets.ts`(新建) | 实现 `readUserPresetsFile()`:通过 Tauri 命令(Rust 侧 `read_text_file`)读取 + 剥离注释 + `JSON.parse` + 校验结构。用 Rust 命令而非前端 FS 插件,避免 scope 配置且跨平台路径由 Rust `app_config_dir` 统一解析 |
| `src/components/PresetPickerModal.tsx` | 将 `providerPresets` 引用改为 `loadAllPresets()` |
| `src/config/providerPresets.ts`(`findPresetForProvider`) | 同样需要从合并后的预设中查找 |
### 5. 校验逻辑
用户 JSON 文件可能写错,需要做基础校验:
```typescript
interface UserPresetValidation {
valid: boolean;
errors: string[];
presets: ProviderPreset[];
}
function validateUserPreset(raw: unknown): UserPresetValidation {
// 1. 必须是数组
// 2. 每项必须有 id、name、providerType、billingMode
// 3. providerType 必须是合法值之一(openai | openai_responses | anthropic | google-genai | vertexai | kimi)
// 4. billingMode 必须是 "subscription" | "pay_as_you_go"
// 5. models 数组每项必须有 model 字段
// 6. usageKinds 若提供,每项必须在 SUPPORTED_USAGE_KINDS 内,否则丢弃并警告
// (Rust 侧运行时也会静默丢弃未知 kind,这里提前拦截给用户明确反馈)
// 7. 忽略未知字段不报错(仅警告)
// 8. 单条严重错误时跳过该条,不阻塞其他预设
}
```
### 6. 用户交互
- PresetPickerModal 中用户预设顶部显示,与内置预设分栏展示(或加"自定义"标签)
- 若 JSON 文件解析失败,界面上显示一个黄色警告条 + 错误详情
- 提供一个"刷新用户预设"按钮,不必重启应用
### 7. 安全问题
- 不读取系统敏感路径,仅限于应用数据目录
- 不执行 JSON 中的任何代码;`JSON.parse` 产出的是纯对象,无原型污染风险,校验只需关注字段类型与取值
- 对 URL 字段(websiteUrl / apiKeyUrl)做基本 `http(s)://` 协议校验,`openUrl` 调用前过滤非法 scheme
### 8. 向后兼容
- 没有 `user-presets.json` 时行为完全不变
- 用户预设只影响 PresetPickerModal 中的选项,不影响已保存的 Provider
- 已保存的 Provider 不受用户预设删除影响
## 未解决的问题
1. **图标来源**:用户用内置图标集不存在的 key(含空字符串/null)时,统一降级为首字母显示。是否支持用户自定义 SVG 图标?—— 当前首字母降级足够,先不做。
2. **热加载**:文件修改后是否需要自动检测并刷新?—— 初期手动刷新按钮即可,后续可加 `fs::watch`。
3. **i18n**:用户预设的 `nameKey` 如果对应内置 i18n key 则显示翻译,否则 fallback 到 `name`。用户不能添加自定义 i18n key(涉及翻译文件修改,违背零代码原则)。
@@ -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.6.3",
"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.6.2"
version = "0.6.0"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.6.3"
version = "0.6.0"
description = "Kimi Switch - model config manager"
authors = ["you"]
edition = "2021"
+113 -16
View File
@@ -740,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}"))?;
@@ -771,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);
@@ -807,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}"))?;
@@ -858,13 +867,101 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
.map_err(|e| e.to_string())
}
/// Open an external https/http URL in the system default browser.
/// Uses the Rust opener directly (bypassing the JS plugin scope) so it
/// works regardless of capability scope configuration.
#[tauri::command]
pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(url, 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);
}
}
-1
View File
@@ -104,7 +104,6 @@ pub fn run() {
commands::check_for_update,
commands::download_update,
commands::open_installer,
commands::open_external_url,
dashboard::get_paths,
dashboard::get_prices,
dashboard::get_summary,
+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();
+20 -8
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.6.3",
"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
}
}
}
}
-5
View File
@@ -703,11 +703,6 @@ export default function App() {
updateConfig((cfg) => {
const updated = { ...cfg.models };
for (const m of models) {
// Skip if this model id already exists for the same provider
const dup = Object.values(updated).some(
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
);
if (dup) continue;
let alias = m.alias;
let n = 1;
while (alias in updated) {
+39 -109
View File
@@ -2,12 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import {
providerPresets,
type BillingMode,
type PresetCategory,
type ProviderPreset,
} from "../config/providerPresets";
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
import { ProviderIcon } from "./ProviderIcon";
interface PresetPickerModalProps {
@@ -31,26 +26,12 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
aggregator: "presetCategoryAggregator",
};
const BILLING_BADGE: Record<BillingMode, string> = {
subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400",
pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400",
};
const BILLING_LABEL: Record<BillingMode, TranslationKey> = {
subscription: "billingBadgeSubscription",
pay_as_you_go: "billingBadgePayAsYouGo",
};
type SortMode = "original" | "alpha";
type BillingTab = "all" | BillingMode;
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("original");
const [billingTab, setBillingTab] = useState<BillingTab>("all");
// Esc to close
useEffect(() => {
@@ -77,31 +58,15 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
if (open) {
setQuery("");
setSort("original");
setBillingTab("all");
}
}, [open]);
// Counts per billing tab — shown in the tab labels so users see how many
// presets are in each bucket without having to click through.
const billingCounts = useMemo(() => {
let sub = 0;
let payg = 0;
for (const p of providerPresets) {
if (p.billingMode === "subscription") sub++;
else payg++;
}
return { subscription: sub, pay_as_you_go: payg, all: sub + payg };
}, []);
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 (billingTab !== "all") {
list = list.filter((p) => p.billingMode === billingTab);
}
if (q) {
list = list.filter(
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
@@ -112,7 +77,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
}
return list;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, sort, billingTab, t]);
}, [query, sort, t]);
if (!open) return null;
@@ -145,67 +110,39 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
</button>
</div>
{/* Billing-mode tabs + search + sort */}
<div className="flex flex-col gap-2 border-b border-border px-5 py-3">
<div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
{BILLING_TAB_ORDER.map((tab) => {
const labelKey: TranslationKey =
tab === "all"
? "billingTabAll"
: tab === "subscription"
? "billingTabSubscription"
: "billingTabPayAsYouGo";
const count = billingCounts[tab];
const active = billingTab === tab;
return (
<button
key={tab}
type="button"
onClick={() => setBillingTab(tab)}
className={`px-2.5 py-1 text-xs rounded transition-colors ${
active
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t(labelKey)} ({count})
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<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>
{/* 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>
@@ -231,20 +168,13 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
<div className="w-full truncate text-sm font-medium text-content-primary">
{presetName(p)}
</div>
<div className="flex flex-wrap gap-1">
{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>
)}
{p.category !== "custom" && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${BILLING_BADGE[p.billingMode]}`}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
>
{t(BILLING_LABEL[p.billingMode])}
{t(CATEGORY_LABEL[p.category])}
</span>
</div>
)}
</button>
))}
</div>
+5 -38
View File
@@ -3,7 +3,6 @@ import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { findPresetForProvider } from "../config/providerPresets";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
@@ -118,10 +117,6 @@ export function ProviderEdit({
const [showApiKey, setShowApiKey] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
// Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input.
const preset = findPresetForProvider(provider);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
@@ -220,7 +215,7 @@ export function ProviderEdit({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
{t("providerName")} <span className="text-red-500">*</span>
{t("providerName")}
</label>
<input
id={nameId}
@@ -301,7 +296,7 @@ export function ProviderEdit({
{!provider.managed && (
<div>
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span>
{t("apiKey")}
</label>
<div className="relative">
<input
@@ -321,29 +316,12 @@ export function ProviderEdit({
{showApiKey ? t("hide") : t("show")}
</button>
</div>
{(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null;
return (
<div className="mt-1.5 flex items-center gap-4 text-xs">
<button
type="button"
onClick={() =>
invoke("open_external_url", { url: linkUrl }).catch(() => {})
}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{preset?.apiKeyUrl ? t("getApiKeyLink") : t("officialUrl")}
</button>
</div>
);
})()}
</div>
)}
<div>
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
{t("requestUrl")} <span className="text-red-500">*</span>
{t("requestUrl")}
</label>
<input
id={baseUrlId}
@@ -481,9 +459,6 @@ function ModelMapping({
provider,
});
setDiscovered(result);
// Auto-select models that already exist in the current provider's list
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
setSelected(new Set(result.filter((dm) => existingIds.has(dm.id)).map((dm) => dm.id)));
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err));
} finally {
@@ -502,11 +477,9 @@ function ModelMapping({
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
if (existingIds.has(dm.id)) continue; // skip models already in this provider
const safeProvider = provider.name.replace(/\//g, "-");
const safeModelId = dm.id.replace(/\//g, "-");
const alias = `${safeProvider}/${safeModelId}`;
@@ -582,9 +555,7 @@ function ModelMapping({
{t("fetchEnableThinking")}
</label>
<div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => {
const exists = models.some((mm) => mm.model === m.id);
return (
{discovered.map((m) => (
<label
key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
@@ -598,12 +569,8 @@ function ModelMapping({
{m.display_name && (
<span className="text-content-muted">({m.display_name})</span>
)}
{exists && (
<span className="ml-auto text-[10px] text-emerald-500 shrink-0">{t("alreadyAdded")}</span>
)}
</label>
);
})}
))}
</div>
<button
type="button"
+39 -64
View File
@@ -5,7 +5,6 @@ import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { Heatmap } from "./Heatmap";
import { TrendLineChart } from "./TrendLineChart";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
@@ -13,20 +12,18 @@ function Card({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
<div className="flex flex-col rounded-xl border border-border bg-panel">
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
</div>
<div className="flex flex-1 flex-col p-4">{children}</div>
<div className="flex-1 p-4">{children}</div>
</div>
);
}
@@ -48,7 +45,7 @@ export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
@@ -190,29 +187,18 @@ export function DashboardPage() {
))}
</div>
{/* Heatmap — full row */}
{/* Heatmap — full-year strip, fixed-size cells, centered */}
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
<Heatmap heatmap={heatmap} />
</Card>
{/* Usage-trend tabs + Model table side by side */}
<div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
{/* Charts + Model side table */}
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
{/* Usage-trend tab container */}
<div className="flex flex-col rounded-xl border border-border bg-panel">
{/* Tab bar header */}
<div className="flex items-end justify-between border-b border-border px-2">
<div className="flex gap-1">
<button
type="button"
onClick={() => setTrendTab("daily")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "daily"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabDailyTrend")}
</button>
<button
type="button"
onClick={() => setTrendTab("model")}
@@ -237,29 +223,21 @@ export function DashboardPage() {
</button>
</div>
<span className="pb-2 text-xs text-content-muted">
{trendTab === "daily"
? t("trendLineSub", { n: trendDaily.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
{daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
</span>
</div>
{/* Active tab content */}
<div className="flex flex-1 flex-col p-4">
{trendTab === "daily" ? (
<div className="flex-1 min-h-[240px]">
<TrendLineChart daily={trendDaily} />
</div>
) : (
<div className="flex-1 min-h-[240px]">
<DailyBars
daily={trendDaily}
dimension={trendTab}
names={trendTab === "model" ? modelNames : providerNames}
unknownProviderLabel={t("providerUnknown")}
/>
</div>
)}
<div className="flex-1 p-4">
<div className="h-full min-h-[240px]">
<DailyBars
daily={trendDaily}
dimension={trendTab}
names={trendTab === "model" ? modelNames : providerNames}
unknownProviderLabel={t("providerUnknown")}
/>
</div>
</div>
</div>
@@ -268,7 +246,7 @@ export function DashboardPage() {
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm border-collapse">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
@@ -282,7 +260,7 @@ export function DashboardPage() {
{visibleModels.map((m) => (
<tr
key={m.model}
className="border-b border-border hover:bg-hover"
className="border-b border-border/50 hover:bg-hover"
>
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
@@ -295,10 +273,10 @@ export function DashboardPage() {
: "#6b7280",
}}
/>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
<div className="min-w-0">
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
)}
</div>
</div>
@@ -338,8 +316,8 @@ export function DashboardPage() {
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
) : (
<>
<div className="overflow-auto max-h-[860px]">
<table className="w-full text-sm border-collapse">
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
@@ -352,7 +330,7 @@ export function DashboardPage() {
</thead>
<tbody>
{recentPageRows.map((r, i) => (
<tr key={recentStart + i} className="border-b border-border hover:bg-hover">
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)}
</td>
@@ -420,23 +398,20 @@ export function DashboardPage() {
)}
</Card>
{/* Footer — meta + attribution at the end of the content flow */}
{/* Attribution footer */}
<div className="text-xs text-content-muted pb-2">
<div>
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
{data.meta.home}
</div>
<div className="mt-0.5">
{t("dashboardAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div>
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
{data.meta.home}
<br />
{t("dashboardAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div>
</div>
);
+1 -1
View File
@@ -19,7 +19,7 @@ const LEVEL_BG = [
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const CELL = 16; // px
const CELL = 12; // px
const GAP = 3; // px
const LABEL_W = 28; // dow label column width
-369
View File
@@ -1,369 +0,0 @@
import { useMemo, useState, type MouseEvent } from "react";
import { useTranslation } from "../../i18n";
import { fmtTokens } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
interface TrendLineChartProps {
/** Per-day totals, already capped to the desired window by the caller. */
daily: DailyRow[];
}
const W = 800;
const H = 130;
const PAD_L = 56;
const PAD_R = 56;
const PAD_T = 10;
const PAD_B = 22;
const PLOT_W = W - PAD_L - PAD_R;
const PLOT_H = H - PAD_T - PAD_B;
const COLOR_TOKENS = "#3b82f6"; // blue
const COLOR_CACHE = "#22c55e"; // green
const COLOR_REQUESTS = "#f97316"; // orange
/** Human-friendly token count for axis labels (K / M / B). */
function fmtTokenAxis(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
return String(Math.round(value));
}
function fmtInt(n: number): string {
return Math.round(n).toLocaleString();
}
function niceMax(value: number): number {
if (value <= 0) return 1;
const exp = Math.floor(Math.log10(value));
const base = Math.pow(10, exp);
const norm = value / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
}
export function TrendLineChart({ daily }: TrendLineChartProps) {
const { t } = useTranslation();
const [showRequests, setShowRequests] = useState(true);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const maxTokens = useMemo(() => {
if (!daily?.length) return 1;
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
}, [daily]);
const maxRequests = useMemo(() => {
if (!daily?.length) return 1;
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
}, [daily]);
const n = daily?.length ?? 0;
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
const pathTokens = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
.join(" ");
}, [daily, maxTokens]);
const pathCache = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
.join(" ");
}, [daily]);
const pathRequests = useMemo(() => {
if (!daily?.length) return "";
return daily
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
.join(" ");
}, [daily, maxRequests]);
const areaTokens = useMemo(() => {
if (!daily?.length) return "";
const lastX = pointX(daily.length - 1);
return (
pathTokens +
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
);
}, [pathTokens, daily]);
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
const xTicks = useMemo(() => {
if (!daily?.length) return [];
if (n <= 6) return daily.map((_, i) => i);
const step = Math.max(1, Math.round(n / 6));
const set = new Set<number>([0, n - 1]);
for (let i = step; i < n - 1; i += step) set.add(i);
return Array.from(set).sort((a, b) => a - b);
}, [daily, n]);
// Hover handling
const onMove = (e: MouseEvent<SVGSVGElement>) => {
if (!daily?.length) return;
const rect = e.currentTarget.getBoundingClientRect();
const scale = W / rect.width;
const x = (e.clientX - rect.left) * scale - PAD_L;
if (x < 0 || x > PLOT_W) {
setHoverIndex(null);
return;
}
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
setHoverIndex(idx);
};
const onLeave = () => setHoverIndex(null);
if (!daily?.length) {
return (
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
{t("trendLineNoData")}
</div>
);
}
const hover = hoverIndex != null ? daily[hoverIndex] : null;
return (
<div className="flex h-full flex-col gap-2">
{/* Legend + toggle */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
{t("trendLineLegendTokens")}
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
{t("trendLineLegendCacheHit")}
</span>
{showRequests && (
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
{t("trendLineLegendRequests")}
</span>
)}
</div>
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showRequests}
onChange={(e) => setShowRequests(e.target.checked)}
className="h-3 w-3 accent-blue-500"
/>
{t("trendLineShowRequests")}
</label>
</div>
{/* Chart */}
<div className="relative flex-1 min-h-0">
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-full"
preserveAspectRatio="none"
onMouseMove={onMove}
onMouseLeave={onLeave}
>
{/* Gradient definitions */}
<defs>
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
</linearGradient>
</defs>
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const y = PAD_T + PLOT_H * (1 - p);
return (
<line
key={p}
x1={PAD_L}
y1={y}
x2={W - PAD_R}
y2={y}
stroke="currentColor"
className="text-border"
strokeWidth={1}
strokeDasharray={p === 0 ? "" : "2 3"}
opacity={p === 0 ? 0.9 : 0.4}
/>
);
})}
{/* Area fill for tokens */}
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
{/* Tokens line (area + line) */}
<path
d={pathTokens}
fill="none"
stroke={COLOR_TOKENS}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Cache hit line */}
<path
d={pathCache}
fill="none"
stroke={COLOR_CACHE}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Requests line (optional) */}
{showRequests && (
<path
d={pathRequests}
fill="none"
stroke={COLOR_REQUESTS}
strokeWidth={1.25}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{/* Hover crosshair + dot */}
{hover && (
<g pointerEvents="none">
<line
x1={pointX(hoverIndex!)}
y1={PAD_T}
x2={pointX(hoverIndex!)}
y2={PAD_T + PLOT_H}
stroke="currentColor"
className="text-content-muted"
strokeWidth={1}
strokeDasharray="2 2"
opacity={0.5}
/>
<circle
cx={pointX(hoverIndex!)}
cy={pointYTokens(hover.totalTokens || 0)}
r={3.5}
fill={COLOR_TOKENS}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
<circle
cx={pointX(hoverIndex!)}
cy={pointYCache(hover.cacheHitRate || 0)}
r={3}
fill={COLOR_CACHE}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
{showRequests && (
<circle
cx={pointX(hoverIndex!)}
cy={pointYReq(hover.requests || 0)}
r={2.5}
fill={COLOR_REQUESTS}
stroke="currentColor"
className="text-panel"
strokeWidth={1.5}
/>
)}
</g>
)}
</svg>
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
{/* Left Y-axis (tokens) */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
return (
<span
key={`yl-${p}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
left: 0,
top: `${top}%`,
width: `${((PAD_L - 4) / W) * 100}%`,
textAlign: "right",
transform: "translateY(-50%)",
}}
>
{fmtTokenAxis(maxTokens * p)}
</span>
);
})}
{/* Right Y-axis (cache hit %) */}
{[0, 0.5, 1].map((p) => {
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
return (
<span
key={`yr-${p}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
right: 0,
top: `${top}%`,
width: `${((PAD_R - 4) / W) * 100}%`,
textAlign: "left",
transform: "translateY(-50%)",
}}
>
{Math.round(p * 100)}%
</span>
);
})}
{/* X-axis labels */}
{xTicks.map((i) => (
<span
key={`x-${i}`}
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
style={{
left: `${(pointX(i) / W) * 100}%`,
bottom: 0,
transform: "translateX(-50%)",
}}
>
{daily[i].date.slice(5)}
</span>
))}
{/* Tooltip (HTML overlay for readability) */}
{hover && (
<div
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
style={{
left: `${(pointX(hoverIndex!) / W) * 100}%`,
top: 4,
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
}}
>
<div className="font-medium">{hover.date}</div>
<div className="text-content-muted">
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
</div>
<div className="text-content-muted">
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
</div>
{showRequests && (
<div className="text-content-muted">
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
</div>
)}
</div>
)}
</div>
</div>
);
}
+6 -155
View File
@@ -17,9 +17,6 @@ export type UsageKind =
| "plan:zhipu"
| "plan:minimax";
/** Billing model for a preset. Drives the tab filter in PresetPickerModal. */
export type BillingMode = "subscription" | "pay_as_you_go";
/** 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",
@@ -48,7 +45,6 @@ export interface ProviderPreset {
nameKey?: string;
/** Official website / console URL. */
websiteUrl?: string;
/** Where to create / fetch an API key; shown as a link under the key input. */
apiKeyUrl?: string;
/** Category: sorting & badge. */
category: PresetCategory;
@@ -75,12 +71,6 @@ export interface ProviderPreset {
/** Defaults to ["thinking"]. */
capabilities?: string[];
}>;
/**
* Billing model — drives the 套餐/按量 tab in PresetPickerModal.
* - "subscription": coding-plan / token-plan style subscription (quota-based)
* - "pay_as_you_go": per-token API billing
*/
billingMode: BillingMode;
/**
* Billing/usage query kinds; omitted means "do not query".
* Persisted to SQLite settings by the Rust side on save, never to
@@ -105,14 +95,12 @@ export const providerPresets: ProviderPreset[] = [
{ model: "claude-sonnet-4-5" },
{ model: "claude-haiku-4-5" },
],
billingMode: "pay_as_you_go",
},
{
id: "kimi-coding",
name: "Kimi For Coding",
nameKey: "presetNameKimiCoding",
websiteUrl: "https://www.kimi.com/code",
apiKeyUrl: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
category: "official",
providerType: "kimi",
baseUrl: "https://api.kimi.com/coding/v1",
@@ -123,7 +111,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
{ model: "kimi-k2.7-code" },
],
billingMode: "subscription",
usageKinds: ["plan:kimi_coding"],
},
{
@@ -140,7 +127,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "kimi-k2-thinking-turbo" },
{ model: "kimi-k2.6" },
],
billingMode: "pay_as_you_go",
},
{
id: "deepseek",
@@ -152,13 +138,12 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.deepseek.com/v1",
icon: "deepseek",
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
billingMode: "pay_as_you_go",
usageKinds: ["balance:deepseek"],
},
{
id: "zhipu-api",
name: "Zhipu GLM (API)",
nameKey: "presetNameZhipuApi",
id: "zhipu",
name: "Zhipu GLM",
nameKey: "presetNameZhipu",
websiteUrl: "https://open.bigmodel.cn",
category: "cn_official",
providerType: "openai",
@@ -169,51 +154,18 @@ export const providerPresets: ProviderPreset[] = [
{ model: "glm-4.6" },
{ model: "glm-4.5-air" },
],
billingMode: "pay_as_you_go",
},
{
id: "zhipu-coding",
name: "Zhipu GLM Coding Plan",
nameKey: "presetNameZhipuCoding",
websiteUrl: "https://open.bigmodel.cn",
category: "cn_official",
providerType: "openai",
// Coding Plan endpoint is separate from the pay-as-you-go PaaS API.
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
icon: "zhipu",
models: [
{ model: "glm-4.7" },
{ model: "glm-4.6" },
{ model: "glm-4.5-air" },
],
billingMode: "subscription",
usageKinds: ["plan:zhipu"],
},
{
id: "zai-api",
name: "z.ai (API)",
nameKey: "presetNameZaiApi",
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" }],
billingMode: "pay_as_you_go",
},
{
id: "zai-coding",
name: "z.ai Coding Plan",
nameKey: "presetNameZaiCoding",
websiteUrl: "https://z.ai",
category: "third_party",
providerType: "openai",
// Coding Plan endpoint (mirror of Zhipu GLM Coding Plan). If the path
// differs on z.ai, fall back to a known mirror.
baseUrl: "https://api.z.ai/api/coding/paas/v4",
icon: "zhipu",
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
billingMode: "subscription",
usageKinds: ["plan:zhipu"],
},
{
@@ -230,7 +182,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "qwen3-coder-plus" },
{ model: "qwen3.5-plus" },
],
billingMode: "pay_as_you_go",
},
{
id: "minimax",
@@ -242,26 +193,6 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.minimaxi.com/v1",
icon: "minimax",
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
billingMode: "pay_as_you_go",
usageKinds: ["plan:minimax"],
},
{
id: "minimax-token-plan",
name: "MiniMax Token Plan",
nameKey: "presetNameMinimaxTokenPlan",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
category: "cn_official",
providerType: "openai",
// Token Plan is sold against the same platform endpoint as the
// pay-as-you-go API — only the API key differs. The Rust detect
// routes api.minimaxi.com to plan:minimax, so this preset picks up
// the same query path; the `subscription` tag helps the user pick
// the right entry based on which key they hold.
baseUrl: "https://api.minimaxi.com/v1",
icon: "minimax",
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
billingMode: "subscription",
usageKinds: ["plan:minimax"],
},
{
@@ -274,24 +205,8 @@ export const providerPresets: ProviderPreset[] = [
baseUrl: "https://api.stepfun.com/v1",
icon: "stepfun",
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
billingMode: "pay_as_you_go",
usageKinds: ["balance:stepfun"],
},
{
id: "stepfun-plan",
name: "StepFun Plan",
nameKey: "presetNameStepfunPlan",
websiteUrl: "https://platform.stepfun.com/step-plan",
apiKeyUrl: "https://platform.stepfun.com/?invite_code=HSIGHTPS",
category: "cn_official",
providerType: "openai",
// Step Plan endpoint — same host, different path prefix than the
// pay-as-you-go API (/v1). Serves both OpenAI and Anthropic protocols.
baseUrl: "https://api.stepfun.com/step_plan/v1",
icon: "stepfun",
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
billingMode: "subscription",
},
{
id: "siliconflow",
name: "SiliconFlow",
@@ -314,7 +229,6 @@ export const providerPresets: ProviderPreset[] = [
},
],
usageKinds: ["balance:siliconflow"],
billingMode: "pay_as_you_go",
},
{
id: "novita",
@@ -337,7 +251,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "zai-org/glm-4.6" },
],
usageKinds: ["balance:novita"],
billingMode: "pay_as_you_go",
},
{
id: "openrouter",
@@ -360,7 +273,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "google/gemini-3-pro-preview" },
],
usageKinds: ["balance:openrouter"],
billingMode: "pay_as_you_go",
},
{
id: "openai",
@@ -376,7 +288,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "gpt-5.1-codex-max" },
{ model: "gpt-5-mini" },
],
billingMode: "pay_as_you_go",
},
{
id: "google-genai",
@@ -392,7 +303,6 @@ export const providerPresets: ProviderPreset[] = [
{ model: "gemini-3-flash-preview" },
{ model: "gemini-2.5-flash" },
],
billingMode: "pay_as_you_go",
},
{
id: "volcengine",
@@ -411,50 +321,6 @@ export const providerPresets: ProviderPreset[] = [
},
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
],
billingMode: "pay_as_you_go",
},
{
id: "opencode-go",
name: "OpenCode Go",
nameKey: "presetNameOpencodeGo",
// Referral link — supports the OpenCode project
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
category: "third_party",
providerType: "openai",
baseUrl: "https://opencode.ai/zen/go/v1",
icon: "opencode",
models: [
{ model: "grok-4.5", displayName: "Grok 4.5" },
{ model: "glm-5.2", displayName: "GLM-5.2" },
{ model: "glm-5.1", displayName: "GLM-5.1" },
{ model: "kimi-k3", displayName: "Kimi K3" },
{ model: "kimi-k2.7-code", displayName: "Kimi K2.7 Code" },
{ model: "kimi-k2.6", displayName: "Kimi K2.6" },
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
],
billingMode: "subscription",
},
{
id: "opencode-zen",
name: "OpenCode Zen",
nameKey: "presetNameOpencodeZen",
websiteUrl: "https://opencode.ai/zen/",
apiKeyUrl: "https://opencode.ai/zen/",
category: "third_party",
providerType: "openai",
baseUrl: "https://opencode.ai/zen/v1",
icon: "opencode",
models: [
{ model: "claude-opus-5", displayName: "Claude Opus 5" },
{ model: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
{ model: "gpt-5.5", displayName: "GPT 5.5" },
{ model: "gemini-3.6-flash", displayName: "Gemini 3.6 Flash" },
{ model: "grok-4.5", displayName: "Grok 4.5" },
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
],
billingMode: "pay_as_you_go",
},
];
@@ -539,21 +405,6 @@ export function presetToProviderAndModels(
};
}
/**
* Best-effort reverse lookup: which preset (if any) a Provider was created
* from. Matches the provider name with any "-2"/"-3" dedup suffix stripped,
* falling back to an exact official_url match. Used to surface preset-only
* metadata (apiKeyUrl / referralUrl) in the edit form without persisting
* extra fields on Provider.
*/
export function findPresetForProvider(provider: Provider): ProviderPreset | undefined {
const base = provider.name.replace(/-\d+$/, "");
return (
providerPresets.find((p) => p.id === base) ??
providerPresets.find((p) => p.websiteUrl != null && p.websiteUrl === provider.official_url)
);
}
// 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,
+2 -28
View File
@@ -60,7 +60,6 @@ export const enTranslations: Record<TranslationKey, string> = {
apiFormat: "API Format",
authField: "Auth Field",
apiKey: "API Key",
getApiKeyLink: "Get API Key",
show: "Show",
hide: "Hide",
requestUrl: "Request URL",
@@ -74,7 +73,6 @@ export const enTranslations: Record<TranslationKey, string> = {
fetchingModels: "Fetching...",
discoveredModels: "Discovered {count} models",
addSelected: "Add selected",
alreadyAdded: "Added",
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
@@ -285,7 +283,6 @@ export const enTranslations: Record<TranslationKey, string> = {
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
tabModelTrend: "Model usage trend",
tabProviderTrend: "Provider–model usage trend",
tabDailyTrend: "Daily usage trend",
providerUnknown: "Unknown provider",
cardDailyTrendSub: "{n} days",
cardModelUsage: "Model usage",
@@ -342,23 +339,17 @@ export const enTranslations: Record<TranslationKey, string> = {
presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot Platform",
presetNameDeepseek: "DeepSeek",
presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)",
presetNameZhipuCoding: "Zhipu GLM Coding Plan",
presetNameZaiApi: "z.ai (Pay-as-you-go)",
presetNameZaiCoding: "z.ai Coding Plan",
presetNameZhipu: "Zhipu GLM",
presetNameZai: "z.ai (Zhipu Global)",
presetNameBailian: "Alibaba Bailian",
presetNameMinimax: "MiniMax",
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
presetNameStepfun: "StepFun",
presetNameStepfunPlan: "StepFun Plan",
presetNameSiliconflow: "SiliconFlow",
presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter",
presetNameOpenai: "OpenAI",
presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "Volcengine Ark",
presetNameOpencodeGo: "OpenCode Go",
presetNameOpencodeZen: "OpenCode Zen",
// Provider usage
usageBalance: "Balance",
@@ -372,21 +363,4 @@ export const enTranslations: Record<TranslationKey, string> = {
usageResetIn: "resets in {time}",
usageResetDone: "reset",
usageLoading: "Querying…",
// Billing mode (subscription / pay-as-you-go)
billingTabSubscription: "Subscription",
billingTabPayAsYouGo: "Pay-as-you-go",
billingTabAll: "All",
billingBadgeSubscription: "Plan",
billingBadgePayAsYouGo: "PAYG",
// Usage trend line chart
trendLineTitle: "Usage Trend",
trendLineSub: "last {n} days",
trendLineLegendTokens: "Token usage",
trendLineLegendCacheHit: "Cache hit rate",
trendLineLegendRequests: "Requests",
trendLineShowRequests: "Show requests",
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
trendLineNoData: "No data",
};
+2 -28
View File
@@ -58,7 +58,6 @@ export const zhTranslations = {
apiFormat: "API 格式",
authField: "认证字段",
apiKey: "API Key",
getApiKeyLink: "获取 API Key",
show: "显示",
hide: "隐藏",
requestUrl: "请求地址",
@@ -72,7 +71,6 @@ export const zhTranslations = {
fetchingModels: "获取中...",
discoveredModels: "发现 {count} 个模型",
addSelected: "添加选中的",
alreadyAdded: "已添加",
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
@@ -280,7 +278,6 @@ export const zhTranslations = {
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
tabModelTrend: "模型用量趋势",
tabProviderTrend: "供应商模型用量趋势",
tabDailyTrend: "每日用量趋势",
providerUnknown: "未知供应商",
cardDailyTrendSub: "共 {n} 天",
cardModelUsage: "模型用量",
@@ -337,23 +334,17 @@ export const zhTranslations = {
presetNameKimiCoding: "Kimi For Coding",
presetNameMoonshot: "Moonshot 平台",
presetNameDeepseek: "DeepSeek",
presetNameZhipuApi: "智谱 GLM(按量)",
presetNameZhipuCoding: "智谱 GLM Coding Plan",
presetNameZaiApi: "z.ai(按量)",
presetNameZaiCoding: "z.ai Coding Plan",
presetNameZhipu: "智谱 GLM",
presetNameZai: "z.ai(智谱海外)",
presetNameBailian: "阿里百炼",
presetNameMinimax: "MiniMax",
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
presetNameStepfun: "阶跃星辰",
presetNameStepfunPlan: "阶跃星辰 Step Plan",
presetNameSiliconflow: "硅基流动",
presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter",
presetNameOpenai: "OpenAI 官方",
presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "火山方舟",
presetNameOpencodeGo: "OpenCode Go",
presetNameOpencodeZen: "OpenCode Zen",
// Provider usage
usageBalance: "余额",
@@ -367,23 +358,6 @@ export const zhTranslations = {
usageResetIn: "{time} 后重置",
usageResetDone: "已重置",
usageLoading: "查询中…",
// Billing mode (套餐 / 按量)
billingTabSubscription: "套餐",
billingTabPayAsYouGo: "按量",
billingTabAll: "全部",
billingBadgeSubscription: "套餐",
billingBadgePayAsYouGo: "按量",
// Usage trend line chart
trendLineTitle: "用量趋势",
trendLineSub: "最近 {n} 天",
trendLineLegendTokens: "Token 用量",
trendLineLegendCacheHit: "缓存命中率",
trendLineLegendRequests: "请求数",
trendLineShowRequests: "显示请求数",
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
trendLineNoData: "暂无数据",
} as const;
export type TranslationKey = keyof typeof zhTranslations;