Compare commits

..
34 Commits
Author SHA1 Message Date
KimiSwitch Dev a0e36e928d feat(webui): v0.7.3 — Kimi Code WebUI 应用内嵌窗口(单例/独立顶层窗口/服务器自动清理)+ 发布说明与网站同步
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-08-07 15:46:45 +08:00
KimiSwitch Dev fc09a29b9f chore(release): bump to v0.7.2 — 高级设置 + WebUI 快捷入口 + loop_control 兼容 + macOS 安装脚本
Release / Attach macOS install script (push) Canceled after 0s
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
2026-08-07 10:21:47 +08:00
KimiSwitch Dev 24d37fbbf4 chore: 设置出品公司为 codingplan.site 2026-08-06 15:09:12 +08:00
KimiSwitch Dev 54110316b4 chore(data): 同步 models.dev 最新模型数据 2026-08-06 15:00:04 +08:00
KimiSwitch Dev 75e48bc4c6 feat(website): 官网企业版视觉升级
- 引入 Geist 字体,去除紫色渐变,克制阴影与交互态
- 导航/卡片/按钮统一企业风格,各区块视觉收敛
2026-08-06 01:56:37 +08:00
KimiSwitch Dev b2f6e7a166 chore(data): 更新 models-dev 模型列表 2026-08-06 01:56:36 +08:00
KimiSwitch Dev 90cc4a16d2 fix(subagent): 实验开关改为滑动开关,修复开启态因 bg-primary 未定义而隐形
- Toggle 开启态改用已定义的 bg-blue-600(tailwind 配置无 primary 背景色,
  导致开启态滑轨透明、白色滑块在白底上不可见)
- 子代理开关移至模型下拉之后,统一使用滑动开关
2026-08-06 01:56:35 +08:00
KimiSwitch Dev 8aed04898c feat(subagent): 实验功能设置新增子代理模型配置
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
- 子代理设置页支持选择次主力模型,仅保存模型引用(不再写补丁字段),
  子代理直接继承所选模型的上下文长度与思考模式
- 界面只读展示继承的上下文长度与思考模式,移除补丁编辑项
- 仪表盘统计识别子代理请求(__secondary__):归为「子代理模型(估算)」
  并缓存次主力模型定价;自定义网关模型价格按官方同名模型跨 provider 匹配
- bump v0.7.1
2026-08-05 23:38:05 +08:00
KimiSwitch Dev 8b0daabd95 fix(tray): 托盘图标悬停气泡显示程序名(Kimi Switch) 2026-08-05 11:19:30 +08:00
KimiSwitch Dev 6e38bed092 docs(website): v0.7.0 更新日志补入 Kimi Eyes 插件站链接
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
2026-08-02 18:18:34 +08:00
KimiSwitch Dev 6df9095ee8 feat(settings): 关于页新增 Kimi Eyes 插件站链接(billowliu2.github.io/kimi-eyes) 2026-08-02 18:12:29 +08:00
KimiSwitch Dev d06fb41f48 chore: bump v0.7.0 + 官网版本文案/更新日志同步
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
2026-08-02 17:17:12 +08:00
KimiSwitch Dev 69f9b8d603 feat(usage): 订阅套餐计价修正 + 识图能力可编辑 + 单套餐用量显示修复
- 订阅套餐零价条目按官方付费模型计价(zhipuai-coding-plan/glm-5.2 → zhipuai/glm-5.2 牌价)
- -highspeed / -free 派生变体自动回退到基础模型价格;Kimi For Coding 无牌价时按旗舰 kimi-k3 估算
- 设置页:image_in 识图能力可手动编辑,原生多模态模型不再提示 kimi-eyes 插件
- 修复仅单个套餐档位时用量卡片不显示的问题
- kimi usages 响应异常时透出原始返回(仅用量数字),便于对照接口排查
- models.dev 价格快照同步
2026-08-02 17:17:06 +08:00
KimiSwitch Dev 2f75929fda ci: release 重跑时覆盖同名资产(overwrite: true)
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
2026-08-02 00:38:51 +08:00
KimiSwitch Dev a1c3f06bdf feat(settings): 关于页新增官方网站链接(billowliu2.github.io/KimiSwitch) 2026-08-02 00:37:28 +08:00
KimiSwitch Dev acde6d97c2 merge: Dev_20260801 → main(v0.6.9:仪表盘缓存命中列 + 人民币计价 + 性能优化)
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
2026-08-02 00:18:49 +08:00
KimiSwitch Dev 6cbaea7159 chore: bump v0.6.9 + 官网版本文案/更新日志同步 2026-08-02 00:18:23 +08:00
KimiSwitch Dev f0d5e27bb4 chore: models.dev 价格快照同步 2026-08-02 00:18:22 +08:00
KimiSwitch Dev 38a11666ac feat(dashboard): 最近请求缓存命中列 + 中文模式人民币计价 + 价格解析记忆化
- 最近请求表新增缓存命中率列(缓存读取/总输入)
- useCurrency:中文界面费用按最新 USD→CNY 汇率显示 ¥,英文保持 $(open.er-api.com,缓存 12h,失败兜底 7.2)
- match_price 记忆化缓存:O(记录数×5910) → O(模型种类×5910),扫描从秒级降到百毫秒级
- 仪表盘右上角显示加载耗时/数据量(get_summary 往返埋点);Rust 侧分段计时
2026-08-02 00:18:19 +08:00
KimiSwitch Dev de132ffb3a feat(website): GLM 推广图标换为智谱官方 Z 字标;SEO 关键词补充 Kimi Switch / KimiCode Switch 2026-08-01 20:38:53 +08:00
KimiSwitch Dev a285d192eb feat(website): 首页 Hero 徽标显示 v0.6.8 + 更新日志缓存按版本失效 2026-08-01 20:35:32 +08:00
KimiSwitch Dev 127818a7c3 merge: Dev_20260801 → main(官网 v0.6.8 文案) 2026-08-01 20:32:43 +08:00
KimiSwitch Dev 6bc9e4644e feat(website): 版本号更新到 v0.6.8 + 更新日志内置 v0.6.8 条目
- Nav 下载按钮 / 下载页副标题 / 三平台已发布文案
- 内置更新日志补 v0.6.8(GitHub API 不可达时兜底也能看到最新版本说明)
2026-08-01 20:32:27 +08:00
KimiSwitch Dev dfd41d12c0 merge: Dev_20260801 → main(release.yml 自动发布) 2026-08-01 20:18:32 +08:00
KimiSwitch Dev 615dfb54e0 ci: 去掉 releaseDraft,tag 推送时自动发布 Release
之前 releaseDraft: true 导致每次发版后 release 停在草稿状态,下载直链 404,需手动 PATCH 发布
2026-08-01 20:18:05 +08:00
KimiSwitch Dev 8914b40bec merge: main → Dev_20260801(SEO 升级) 2026-08-01 19:58:47 +08:00
KimiSwitch Dev 4071930aff chore: Cargo.lock 同步 v0.6.8 2026-08-01 19:57:40 +08:00
KimiSwitch Dev 1b5cd0f416 feat(website): SEO 升级 — BrowserRouter + 每页独立 meta
- HashRouter → BrowserRouter(basename /KimiSwitch),GitHub Pages 用 404.html SPA 回退,/features /download /changelog 变为可收录的独立 URL
- 每页独立 title / description / OG / canonical(RouteMeta 随语言切换)
- og:image 改为绝对地址;sitemap 更新为干净路径
2026-08-01 19:56:54 +08:00
KimiSwitch Dev 5788624d12 merge: Dev_20260801 → main(v0.6.8 发布:仪表盘性能优化 + 官网下载页三平台)
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
2026-08-01 19:37:54 +08:00
KimiSwitch Dev beff12a90e chore: bump version to v0.6.8 2026-08-01 19:37:23 +08:00
KimiSwitch Dev 63761e763c feat(website): 下载页支持三平台真实下载链接
- macOS / Linux 由开发中改为已发布,直连 GitHub Releases 资产(.dmg / .deb / .AppImage / .rpm)
- Windows 保留 MSI + 国内镜像双入口
2026-08-01 19:37:21 +08:00
KimiSwitch Dev 1fecb161a9 perf(dashboard): 仪表盘加载提速
- models-dev 快照移出主 bundle(1.35MB→515KB),改为 /models-dev.json 运行时异步加载,就绪后重渲染
- Rust 启动时后台预热价格索引(warm_price_index),首次 get_summary 不再卡
- get_summary 扫描结果 8s TTL 缓存(按 home),切 tab/改范围复用;刷新按钮 refresh=true 强制重扫
2026-08-01 19:37:20 +08:00
KimiSwitch Dev 9fd22ef55a fix(dashboard): k3-256k 按 k3 牌价计费
- 命中零价订阅条目(kimi-for-coding/k3-256k)时,剥掉上下文长度后缀重查基础模型(k3-256k → moonshotai/kimi-k3)
- 无命中时同样先尝试剥后缀再回落旧表
- cache_write 改按解析后的 price_id 取值
- 新增回归测试 context_suffix_variant_bills_at_base_model
2026-08-01 19:04:35 +08:00
KimiSwitch Dev 3fbd70932c feat(website): 官网多页面 + 中英文切换 + Kimi 风格科技风重设计
- 多页路由:首页 / 功能 / 更新日志 / 下载(HashRouter,适配 GitHub Pages)
- 中英文切换(zh/en 双语文案 + localStorage 记忆)
- Kimi 品牌配色(中性灰 + 蓝)+ 科技风(网格底纹 / 辉光按钮 / 渐变强调词)
- 更新日志页:运行时同步 GitHub Releases,localStorage 缓存 1h,网络失败回退内置列表
- 功能页 8 张截图卡片;下载页含自动更新说明
- 修复资源路径 404(asset() + %BASE_URL%);自托管 Inter/Fira Code 字体
2026-08-01 19:04:33 +08:00
114 changed files with 214732 additions and 132540 deletions

No files matched your search

+33 -2
View File
@@ -79,13 +79,44 @@ jobs:
releaseBody: |
## ${{ github.ref_name }}
### 本次更新
- **高级设置**:原「子代理设置」升级为「高级设置」,聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
- **Kimi Code WebUI 应用内嵌窗口**:高级设置页可将新版 Web 界面以**独立顶层窗口**在应用内打开(单例、可缩放,关闭自动清理服务器进程),也可在系统浏览器打开(复用已运行的本地服务器)
- **子代理模型指定(实验功能)**:为子代理绑定次主力模型,不再默认继承主模型;实验开关改为滑动开关并修复开启态隐形
- **兼容 kimi-code 0.33+(v2 引擎)**:`loop_control` 写入新键名 `max_attempts_per_step`,旧配置自动迁移并保留旧键兼容 `KIMI_CODE_LEGACY_FLAG=1`;hooks 候选补充新事件
- models.dev 模型数据同步;官网企业版视觉升级;出品公司标识统一
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app`
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
1. 下载 `KimiSwitch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
Full changelog: see commit history.
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
overwrite: true
args: ${{ matrix.args }}
upload-macos-script:
name: Attach macOS install script
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload install-macos.sh to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.ref_name }}" scripts/install-macos.sh --clobber
+7 -1
View File
@@ -55,7 +55,13 @@
## 下载
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名** —— 首次启动请右键 → 打开绕过 Gatekeeper)
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名**,仅 Apple Silicon / M 系列芯片)。安装请下载 Release 附带的 `install-macos.sh` 一键处理:
```bash
bash install-macos.sh
```
或手动:把 `Kimi Switch.app` 拖入「应用程序」→ 右键 → 打开 → 点「打开」绕过 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)。
+3 -1
View File
@@ -26,7 +26,9 @@ 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.
Unsigned (no Developer ID account): downloaded copies are blocked by Gatekeeper.
For end users, attach `install-macos.sh` (auto-uploaded to each Release) or instruct:
right-click → Open, or `xattr -cr "/Applications/Kimi Switch.app"`.
### Linux (native)
+54
View File
@@ -0,0 +1,54 @@
# 工作交接(2026-08-01 第二轮)
> 新会话续作入口:先读本文件,再按「六、下一步」继续。历史交接见 `docs/HANDOVER-usage-billing.md`(账单功能,已发布完)。
## 一、当前未完成任务(按优先级)
1. **官网截图归档**(`docs/screenshots/site-2026-08-01/`,14 张 Snipaste 图):
- **当前模型无视觉输入,无法识图**——需用户在会话里切换到支持视觉的模型后逐个查看
- 确认每张对应界面 → 按官网区块重命名(hero / feature-usage / kimi-oauth / settings…)
- 注意:`.gitignore` 忽略 `Snipaste_*.png`,这些图不进 git,归档目录也只是暂存
2. **官网规划待拍板**(`docs/WEBSITE-PLAN.md` v2,3 项待定):
- 素材时机:等 v0.7.0 稳定版(推荐)还是用当前 v0.6.x 截图
- 技术栈:Vite + React + Tailwind(已写入方案,等确认)
- 推荐链接:官网页脚放基元律动等推荐链接,还是不放
## 二、v0.6.7 发布状态详情(已完成)
- **提交**:`ce28252` feat: Kimi 设备授权登录(应用内置 kimi login)+ 外链打开统一走 Rust 命令(v0.6.7)
- **版本**:三处 0.6.7(package.json / Cargo.toml / tauri.conf.json),`check-version.sh 0.6.7` 通过
- **两仓库**:origin(git.codingplan.site/admin/KimiCodeSwitch)+ github(billowliu2/KimiSwitch)的 main / Dev_20260801 / tag v0.6.7 全部同步
- **私有库 Release**:https://git.codingplan.site/admin/KimiCodeSwitch/releases/tag/v0.6.7(MSI 6.9MB)
- **GitHub Release**:https://github.com/billowliu2/KimiSwitch/releases/tag/v0.6.7(三平台构建成功,6 个产物齐全,已转正式)
- **本地包**:`src-tauri/target/release/bundle/{nsis,msi}/Kimi Switch_0.6.7_*`
- **v0.6.7 功能**:Kimi 设备授权登录(Device Code Flow,等同 `kimi login`)——`kimi_oauth_start` / `kimi_oauth_poll` 命令 + `KimiOAuthDialog` 前端;授权页/设置页外链统一改走 Rust `open_external_url` 命令(JS 插件版 openUrl 在此环境静默失败,已弃用)
## 三、关键环境与凭据获取
- **代理**:git 全局 `http.proxy=http://127.0.0.1:7897`;推 origin 需 `git -c http.proxy= push` 绕过(schannel 握手失败);调 GitHub API 必须 `-x http://127.0.0.1:7897`
- **GitHub API token**:`printf "protocol=https\nhost=github.com\n\n" | git credential fill` → password 行(临时文件 /tmp/ghcred.txt 可能已失效,重新获取)
- **私有库 API 凭据**:同上 host=git.codingplan.site(/tmp/pcred.txt)
- **models.dev 同步**:`npm run fetch-models-dev`(脚本自动读 git http.proxy 走代理);打包前 `pretauri` 钩子自动同步;离线时用本地已提交 `src/lib/models-dev.json` + last-good 备份恢复
- **版本发布流程**:check-version → tauri build → commit → 推两仓库 → 私有库 API 建 Release+传 MSI → 推 github tag → CI 完成转正式
## 四、官网规划要点(docs/WEBSITE-PLAN.md v2)
- 定位:多供应商统一管理 + 用量监控(非 Kimi 单一产品)
- 部署:仅 GitHub Pages(**私有库 Gitea Pages 实测未启用**,勿再做镜像方案)
- 技术栈:Vite + React + Tailwind(独立 `website/` 子目录)
- 结构:Hero(供应商列表截图)→ 功能 6 卡 → 性能 → 更新 → 隐私 → 平台 → CTA
- 素材:真实截图+动图;性能指标需实测后如实写
## 五、已有交接/文档
- `docs/HANDOVER-usage-billing.md`:账单查询功能全量交接(已随 v0.6.4-v0.6.6 发布)
- `docs/USAGE-QUERY-ADAPTATION.md`:账单查询调研
- `docs/WEBSITE-PLAN.md`:官网规划 v2
- `docs/screenshots/site-2026-08-01/`:14 张待归档产品截图(需视觉模型)
## 六、下一步 TODO
1. 查看后台轮询结果(`TaskList` / `TaskOutput` 或读 `/tasks`):v0.6.7 GitHub CI 完成后转正式 Release
2. 用户切视觉模型后:识图 → 重命名归档 14 张截图 → 更新素材清单
3. 用户对 3 个待拍板项确认后更新 WEBSITE-PLAN.md
4. 后续:官网 M0-M4 里程碑(等拍板 + 稳定版)
+138
View File
@@ -0,0 +1,138 @@
# KimiSwitch 官网建设规划(Landing Page)v4
> 状态:方案 v4 定稿(待启动实现)。参考对象:[KimiCodeBar 官网](https://xifandev.github.io/KimiCodeBar/)(GitHub Pages 单页营销站)。
> v4 变更:3 项待拍板全部定案(素材用 v0.6.7 / 技术栈 Vite+React+Tailwind / 推荐链接页脚);素材完整度 ~95%;性能实测数据已写入(WS 93 MB / Private 34 MB)。
## 一、目标与定位
为 KimiSwitch 打造一个**单页营销官网(landing page)**:
- 展示核心卖点:**多 LLM 供应商统一管理 + 一键切换 + 用量/账单监控**(主角,不是单一 Kimi 产品)
- 建立可信度:开源(MIT)、隐私(本地存储)、官方直连
- 提供下载入口:Windows 安装包(GitHub Releases + 私有库 Releases)
- 引流与推广:推荐链接低调放页脚(不喧宾夺主)
## 二、参考拆解:KimiCodeBar 官网结构
| 区块 | 内容 | 可借鉴点 | 是否照搬 |
| --- | --- | --- | --- |
| Hero | 实时用量卡片 + 主标题 + CTA | 产品实拍做主视觉 | 部分(见 3.1) |
| Features | "为 Kimi Code 而生":轻量菜单栏 | 一句话定位 + 简短特性 | 是 |
| Kimi Login | 一键授权登录 / API Key 切换 | 认证方式展示 | **否**(降级为功能卡) |
| Performance | Swift 原生、内存 30-50MB、任务管理器对比图 | 硬指标 + 对比图 | 是(指标需实测) |
| Auto Update | 自动探测更新、一键安装 | 功能卖点 | 是 |
| Showcase | 明暗双主题截图 | 真实截图轮播 | 是 |
| Privacy | 本地存储 / 官方直连 / 开源可审计 | 三卡片信任区 | 是 |
| Platforms | macOS 已发布 / Windows 开发中 | 平台状态 | 是(镜像反转) |
| 尾屏 CTA | 强行动号召 + 下载按钮 | 是 | 是 |
风格特征:深色主题、大字号排版、真实截图/动图、数据指标卡片、滚动锚点导航。
## 三、KimiSwitch 官网结构规划(修订版)
### 3.1 Hero
- 主标题:**"一个应用,管理你所有的 AI 供应商"**(主)+ 副标题"一键切换 · 用量监控 · 账单一目了然"
- 主视觉:**主界面供应商列表 + 切换**截图(核心差异点,非用量卡片——用量卡是 KimiCodeBar 的产品本体,不是我们的)
- CTA:下载 Windows 版(主)+ 查看 GitHub 源码(次)
### 3.2 功能区块(6 卡,一屏一组)
1. **多供应商管理**:Kimi / 智谱 / MiniMax / DeepSeek / OpenRouter / 基元律动等预设,一键添加与切换
2. **用量与账单监控**:套餐类(5 小时 / 每周用量)+ 按量余额(models.dev 真实价格计算成本)卡片直显
3. **Kimi 一键授权登录**:内置设备码授权(等同 `kimi login`),浏览器授权即用、自动续期 ← **Kimi 相关只此一张卡**
4. **配置用量查询**:全屏配置页、NewAPI 模板、超时/自动刷新、测试查询
5. **智能模型发现**:模型能力 / 上下文自动同步 models.dev
6. **自动更新**:检查更新(私有库优先、GitHub 回退),一键下载安装
### 3.3 认证方式区块
- **删除**独立大区块。Kimi 授权并入 3.2 功能卡 3;API Key 方式在主界面截图里自然体现(普通供应商编辑 API Key 输入框)
### 3.4 性能 / 技术区块
- Tauri + Rust 内核 + WebView2;常驻托盘
- 实测(28 核 Windows,用户运行 v0.6.7):
- **Working Set 93 MB / Private WS 34 MB**(程序空闲态)
- 52 线程 / 1086 句柄;CPU 空闲 0%,查询时短暂升高
- 对比 KimiCodeBar(Swift)自报 38 MB —— Tauri 因 WebView2 内核偏重是事实,**不作"碾压"话术**
- 配任务管理器对比图(M2 实测打安装包时补)
### 3.5 更新区块
- 检查更新:私有库优先 → GitHub 回退;一键下载 / 安装(截图展示设置面板更新区)
### 3.6 隐私与信任区块(三卡片)
- 本地存储:配置与用量仅存本机(`~/.kimi-switch` / config.toml)
- 官方直连:账单查询直连各厂商官方 API
- 开源可审计:MIT 全量开源(GitHub)
### 3.7 平台与下载
- **Windows:已发布**(.msi / .exe)——主推平台
- macOS / Linux:规划中(GitHub Actions 已能构建,但未正式适配/发布,如实标注"开发中")
- 下载入口:GitHub Releases 为主 + **私有库 Releases 链接**(国内下载快)
### 3.8 尾屏 CTA
- **"现在就把所有供应商,放进一个应用"** + 下载按钮 + 当前版本号(构建时注入)
## 四、视觉与交互方向
- 深色为主(对齐 app)、大字号对比排版、圆角卡片、品牌蓝紫渐变(`#3b82f6 → #8b5cf6`)
- 素材:真实截图 + 动图,避免插画风
- 动效:滚动渐入、数字滚动、截图 hover 缩放,克制
- 响应式:桌面优先,移动端可看结构与下载按钮
## 五、技术选型与部署(已收敛)
| 项 | 方案 | 说明 |
| --- | --- | --- |
| 技术栈 | **Vite + React + Tailwind**(与主应用技能栈一致) | 定案;后续扩展英文版/多页顺 |
| 构建 | 独立工程 `website/` 子目录,`vite build` 产物纯静态 | 与 tauri 构建隔离 |
| 部署 | **仅 GitHub Pages**(`gh-pages` 分支或独立 repo) | 私有库 Pages **确认未启用**,不做镜像 |
| 域名 | `https://billowliu2.github.io/KimiSwitch/`(默认) | 自定义域名待定 |
| CI | GitHub Actions:website 变更自动构建 → 部署 gh-pages | 独立 workflow |
| 下载链接 | 构建时注入**版本号常量**(v0.6.x),链接指向 GitHub + 私有库 Releases | 发版时改一个常量,不做脚本自动化 |
| SEO | meta description / OG 分享图 / favicon(用 `public/kimi.svg`) | 补进 M1 |
## 六、素材清单(已就位 ✅)
- [x] app 主界面截图 ×5:供应商列表(hero)、编辑基本信息、模型映射、Kimi OAuth 对话框、Kimi OAuth 授权成功页
- [x] 用量仪表盘截图 ×4:浅色/深色/完整版/每日详情
- [x] 会话管理截图 ×2:浅色/深色
- [x] 编辑器截图 ×3:基本信息、模型映射、配置 JSON
- [x] 设置弹窗截图 ×1(更新区+超链接+关于)
- [x] 配置用量查询截图 ×1(OAuth 托管模板/变量/超时/测试结果)
- [x] 选择预设供应商截图 ×1(22 个预设卡片)
- [x] 套餐用量卡片特写 ×1(5h/7天)
- [x] kimi CLI 模型选择 TUI ×1(辅助素材)
- [x] 性能实测指标:WS 93 MB / Private 34 MB / 52 threads / 1086 handles
- [ ] 任务管理器对比图(M2 阶段补:打安装包后跑 5-10 分钟采样,含对比标注)
- [ ] OG 分享图(M1 阶段用 hero.png 合成 1200×630)
- [ ] 版本号与下载链接注入(构建常量)
## 七、里程碑(绑定稳定版)
1. **M0 前提**:app 进入稳定版(**建议 v0.7.0 发布后**)——截图/动图才不过时
2. **M1 结构骨架**:布局 + 导航 + 区块占位 + SEO/meta(1 天)
3. **M2 内容填充**:文案定稿 + 截图/动图就位(1-2 天)
4. **M3 打磨上线**:动效、响应式、GitHub Pages 部署、下载链接注入(1 天)
5. **M4 持续维护**:发版时更新版本号/截图(随 Release 流程)
## 八、不做的事(当前阶段)
- 不做多语言(先中文,英文版按需)
- 不做博客 / 文档站(README / docs 已有)
- 不做在线购买 / 支付(免费开源)
- 不采集统计埋点(与隐私定位一致)
- **不做私有库 Pages 镜像**(服务器未启用该功能)
## 九、已定案(原 3 项待拍板)
1. **素材时机** ✅ **用当前 v0.6.7**(用户确认,不等 v0.7.0)
2. **技术栈** ✅ **Vite + React + Tailwind**(与主应用技能栈一致)
3. **推荐链接** ✅ **页脚放 6 个供应商官网**(KimiSwitch 已全部内置):
| # | 供应商 | 推荐文案 | 链接 |
|---|---|---|---|
| 1 | **Kimi** | 完成 Kimi 注册,你我都能 100% 拿奖,最高可得 1 年会员等值权益 | https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J |
| 2 | **智谱 GLM** | 我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5.2 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平,通过我的邀请链接注册即可获得 2000 万 Tokens 大礼包,期待和你一起在 BigModel 上畅享卓越模型能力 | https://www.bigmodel.cn/invite?icode=mQwTVj1MeLnqI2wa7Ivg7unfet45IvM%2BqDogImfeLyI%3D |
| 3 | **DeepSeek** | (无推荐链接,官方平台) | https://platform.deepseek.com |
| 4 | **OpenCodeGo** | (无特定文案,官方推荐链接) | https://opencode.ai/go?ref=DFCNADQCEM |
| 5 | **MiniMax** | 🚀 MiniMax Token Plan 订阅一份套餐,解锁最新模型 — 前沿 Coding 能力、1M 超长上下文、原生多模态,图文音视频共用套餐额度。🎁 邀友双赢福利 好友订阅享 9 折 + Builder 权益,邀请人得 10% 返利 + 社区特权!立即订阅 | https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link |
| 6 | **基元律动** | (无特定文案,新供应商推荐链接) | https://tokenrhythm.studio/i/rf_tr_v0QXuBTzyl3-bJOS2uFFPg6x |
> 方案 v4 定稿,可启动 M1(结构骨架 + SEO)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.6.7",
"version": "0.7.3",
"type": "module",
"scripts": {
"dev": "vite",
+63986
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
## v0.7.1
### 实验功能设置:子代理模型配置
- 新增「子代理模型(次主力模型)」设置:选择次主力模型后,子代理默认绑定该模型,不再继承主模型
- 仅保存模型引用,子代理直接继承所选模型的上下文长度与思考模式,无需补丁配置
- 界面只读展示继承的上下文长度与思考模式
### 仪表盘用量统计
- 识别子代理请求(`__secondary__`):归为「子代理模型(估算)」独立展示,并缓存次主力模型定价,删除配置后历史记录仍稳定估算
- 自定义网关模型(未收录 models.dev)按官方同名模型跨 provider 匹配价格,不再落入兜底估算
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app`
- **Linux**: `.deb` / `.AppImage` / `.rpm`
+39
View File
@@ -0,0 +1,39 @@
## v0.7.2
### 高级设置与 WebUI(本次新增)
- 「子代理设置」升级为「**高级设置**」:聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
- 新增 **Kimi Code WebUI 快捷打开**:高级设置页可将新版 Web 界面以独立窗口在应用内打开,也可在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)
### 子代理模型指定(实验功能)
- 新增「子代理模型(次主力模型)」指定:在实验功能设置中为子代理选择次主力模型后,子代理默认绑定该模型,不再继承主模型
- 实验开关改为滑动开关,修复开启态因样式缺失而不可见的问题
- 仅保存模型引用,子代理直接继承所选模型的上下文长度与思考模式
### 模型数据与发布
- models.dev 模型列表与价格数据同步更新
- 兼容 kimi-code 0.33+(v2 引擎):`loop_control` 改用新键名 `max_attempts_per_step`(旧配置自动迁移,并保留旧键兼容 `KIMI_CODE_LEGACY_FLAG=1`)
- 官网企业版视觉升级;出品公司标识统一为 codingplan.site
- macOS 发布优化:Release 附带 `install-macos.sh` 一键安装脚本(见下方说明)
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
> 原因:macOS 包未做 Developer ID 签名与公证(无 Apple 开发者账号),系统会拦截从网络下载的未签名应用(Gatekeeper)。`install-macos.sh` 通过清除「下载隔离」属性绕过该拦截,不影响功能与安全。
1. 下载 `Kimi.Switch_0.7.2_aarch64.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除下载隔离属性并启动应用,只需执行一次,之后正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗点「打开」;或 系统设置 → 隐私与安全性 → 仍要打开
+33
View File
@@ -0,0 +1,33 @@
## v0.7.3
### Kimi Code WebUI 应用内嵌窗口(本次新增)
- 高级设置页「在应用内打开」:新版 Web 界面以**独立顶层窗口**打开(1100×750、可缩放、居中),不再跳转到外部浏览器
- **单例**:窗口最多一个,重复点击自动聚焦;最小化后再次打开可恢复
- **服务器自动管理**:复用已在运行的 `kimi web` 本地服务(不重复拉起);自己启动的服务器在窗口关闭或应用退出时自动清理,不留残留进程
- 两个入口并存:「在应用内打开」+「在浏览器打开」(浏览器入口复用已运行的本地服务器,直接打开 `http://127.0.0.1:58627`)
### 修复与打磨
- 窗口创建失败(快速双击竞态)时不再泄漏 `kimi web` 进程
- WebUI 说明文案与发布说明同步更新
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
> 原因:macOS 包未做 Developer ID 签名与公证(无 Apple 开发者账号),系统会拦截从网络下载的未签名应用(Gatekeeper)。`install-macos.sh` 通过清除「下载隔离」属性绕过该拦截,不影响功能与安全。
1. 下载 `Kimi.Switch_0.7.3_aarch64.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除下载隔离属性并启动应用,只需执行一次,之后正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗点「打开」;或 系统设置 → 隐私与安全性 → 仍要打开
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# install-macos.sh — Kimi Switch macOS 一键安装辅助脚本
#
# 背景:macOS 包未做 Developer ID 签名(无开发者账号),从网络下载后会被
# Gatekeeper 的「下载隔离(quarantine)」属性拦截。本脚本清除该属性后启动应用。
#
# 用法(把 Kimi Switch.app 拖入「应用程序」文件夹后执行):
# bash install-macos.sh
#
# 若脚本提示找不到应用,请先手动把 Kimi Switch.app 拖到 /Applications 再运行。
set -e
APP_NAME="Kimi Switch"
APP_PATH="/Applications/${APP_NAME}.app"
DOWNLOADED_DMG_NAME="KimiSwitch.dmg"
if [ ! -d "$APP_PATH" ]; then
echo "❌ 未找到 ${APP_PATH}"
echo ""
echo "请先把「${APP_NAME}.app」拖到「应用程序」文件夹,再重新运行本脚本:"
echo " 1. 双击下载的 ${DOWNLOADED_DMG_NAME}(磁盘映像会自动挂载)"
echo " 2. 把其中的 Kimi Switch.app 拖入「应用程序」文件夹"
echo " 3. 回到终端重新运行:bash install-macos.sh"
exit 1
fi
echo "① 清除「下载隔离」属性(Gatekeeper 拦截的来源)..."
xattr -cr "$APP_PATH" 2>/dev/null || true
echo " 完成"
echo "② 验证代码签名状态:"
if codesign -dv "$APP_PATH" 2>&1 | grep -q "Signature=adhoc"; then
echo " Signature=adhoc(正常,ad-hoc 签名,右键打开即可运行)"
else
echo " (未检测到签名信息,属正常,不影响本脚本的运行方式)"
fi
echo "③ 正在启动「${APP_NAME}」..."
open "$APP_PATH"
echo ""
echo "✅ 启动完成。"
echo ""
echo "如果仍提示「无法验证开发者」,请按下面任一种方式再试:"
echo " a) 右键 ${APP_NAME}.app → 打开 → 在弹出的提示中点「打开」"
echo " b) 系统设置 → 隐私与安全性 → 滚动到「安全性」→ 点「仍要打开」"
echo ""
echo "首次启动后应用即可正常使用,之后无需重复本操作。"
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.6.7"
version = "0.7.3"
dependencies = [
"anyhow",
"chrono",
+2 -2
View File
@@ -1,8 +1,8 @@
[package]
name = "kimiswitch"
version = "0.6.7"
version = "0.7.3"
description = "Kimi Switch - model config manager"
authors = ["you"]
authors = ["codingplan.site"]
edition = "2021"
[build-dependencies]
+219
View File
@@ -7,6 +7,7 @@ use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
@@ -949,6 +950,193 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
.open_path(&path, None::<&str>)
.map_err(|e| e.to_string())
}
/// Open the Kimi Code WebUI in the system default browser. Reuses an
/// already-running local `kimi web` server (embedded window, terminal, …);
/// otherwise spawns `kimi web`, which starts the server and opens the browser.
#[tauri::command]
pub async fn open_kimi_web(app: tauri::AppHandle) -> Result<(), String> {
if kimi_web_alive(Duration::from_millis(800)).await {
// Server already running — just open the browser at the local origin.
return app
.opener()
.open_url(kimi_web_url(), None::<&str>)
.map_err(|e| e.to_string());
}
let mut cmd = std::process::Command::new("kimi");
cmd.arg("web");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: avoid a console window flashing next to the GUI.
cmd.creation_flags(0x08000000);
}
cmd.spawn()
.map(|_| ())
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"kimi executable not found on PATH — install Kimi Code CLI first".to_string()
} else {
format!("failed to start `kimi web`: {e}")
}
})
}
// ---------------------------------------------------------------------------
// Kimi Code WebUI — embedded in-app window
//
// `open_kimi_web_embedded` opens the Kimi Code Web UI inside a dedicated,
// independent top-level window (no parent/child relationship with the main
// window). At most one such window exists: re-invoking focuses it. The local
// `kimi web` server is reused when already running; otherwise one is spawned
// (`--no-open`) and terminated when the window closes or the app exits.
// ---------------------------------------------------------------------------
/// Shared state for the embedded WebUI window and the server we may have spawned.
#[derive(Default)]
pub struct KimiWebState {
/// The embedded WebUI window (at most one).
window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>,
}
const KIMI_WEB_PORT: u16 = 58627;
const KIMI_WEB_ORIGIN: &str = "http://127.0.0.1:58627";
const KIMI_WEB_WINDOW_LABEL: &str = "kimi-web-ui";
/// Build the Web UI URL; the home-wide bearer token rides in the `#token=`
/// fragment (same as the `kimi web` ready banner, see kimi-code
/// `cli/sub/web/access-urls.ts`).
fn kimi_web_url() -> String {
let token = dirs::home_dir()
.map(|h| h.join(".kimi-code").join("server.token"))
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match token {
Some(t) => format!("{KIMI_WEB_ORIGIN}/#token={t}"),
None => format!("{KIMI_WEB_ORIGIN}/"),
}
}
/// Best-effort probe of the local Kimi web server root.
async fn kimi_web_alive(timeout: Duration) -> bool {
let Ok(client) = reqwest::Client::builder().timeout(timeout).build() else {
return false;
};
client
.get(KIMI_WEB_ORIGIN)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
/// Kill the `kimi web` server spawned by this app (no-op when reusing one).
pub fn kill_spawned_kimi_web(state: &tauri::State<'_, KimiWebState>) {
if let Ok(mut guard) = state.child.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Open the Kimi Code WebUI in an embedded, independent top-level window.
#[tauri::command]
pub async fn open_kimi_web_embedded(
app: tauri::AppHandle,
state: tauri::State<'_, KimiWebState>,
) -> Result<(), String> {
// Singleton: focus the existing window instead of creating a second one.
if let Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) {
let _ = w.unminimize();
let _ = w.set_focus();
return Ok(());
}
}
}
// Reuse an already-running server, otherwise spawn `kimi web --no-open`.
if !kimi_web_alive(Duration::from_millis(800)).await {
let mut cmd = std::process::Command::new("kimi");
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW — no console flash
}
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"kimi executable not found on PATH — install Kimi Code CLI first".to_string()
} else {
format!("failed to start `kimi web`: {e}")
}
})?;
if let Ok(mut guard) = state.child.lock() {
*guard = Some(child);
}
}
// Wait for the server to come up (fresh start takes a moment).
if !kimi_web_alive(Duration::from_secs(8)).await {
kill_spawned_kimi_web(&state);
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
}
// Create the window on the main thread (required on macOS).
let url: tauri::Url = match kimi_web_url().parse() {
Ok(u) => u,
Err(_) => return Err("invalid kimi web url".to_string()),
};
let app = app.clone();
tauri::async_runtime::spawn(async move {
let builder_app = app.clone();
let _ = app.run_on_main_thread(move || {
match tauri::WebviewWindowBuilder::new(
&builder_app,
KIMI_WEB_WINDOW_LABEL,
tauri::WebviewUrl::External(url),
)
.title("Kimi Code WebUI")
.inner_size(1100.0, 750.0)
.resizable(true)
.center()
.build()
{
Ok(window) => {
// Track the window for the singleton check.
if let Ok(mut guard) = builder_app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the
// `kimi web` server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
let app = w.app_handle().clone();
let state = app.state::<KimiWebState>();
if let Ok(mut guard) = state.window.lock() {
*guard = None;
}
kill_spawned_kimi_web(&state);
}
_ => {}
});
}
Err(_) => {
// Window creation failed (e.g. rapid double-click racing
// the singleton check): don't leak the server we spawned.
kill_spawned_kimi_web(&builder_app.state::<KimiWebState>());
}
}
});
});
Ok(())
}
/// 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.
@@ -1078,3 +1266,34 @@ pub async fn kimi_oauth_poll(
) -> Result<crate::oauth::DevicePollStatus, String> {
crate::oauth::poll_device_token(&device_code, interval).await
}
// ---------------------------------------------------------------------------
// Experimental feature env-var probe (Kimi Code secondary model etc.)
// ---------------------------------------------------------------------------
/// Read the Kimi Code experimental-feature environment variables that are
/// currently set (non-empty) in this process's environment. The frontend
/// uses the result to mark config.toml `[experimental]` toggles as locked:
/// env vars outrank the config file in Kimi Code's flag resolution.
///
/// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
/// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [&str; 5] = [
"KIMI_CODE_EXPERIMENTAL_FLAG",
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT",
"KIMI_CODE_EXPERIMENTAL_ACP_V2",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
];
let mut out = HashMap::new();
for name in VARS {
if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() {
out.insert(name.to_string(), value);
}
}
}
out
}
+338 -8
View File
@@ -149,6 +149,9 @@ pub struct ModelRow {
pub model_resolved: String,
pub price_id: String,
pub cost_estimated: bool,
/// True when any aggregated record came from a subagent request (Kimi Code
/// `__secondary__` marker, resolved to the configured secondary model).
pub is_secondary: bool,
pub requests: usize,
pub input_other: u64,
pub output: u64,
@@ -175,6 +178,9 @@ pub struct RecentRow {
pub cost_estimated: bool,
pub price_id: String,
pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -330,6 +336,9 @@ pub struct UsageRecord {
pub model_display: String,
pub provider: Option<String>,
pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
}
// ---------------------------------------------------------------------------
@@ -400,6 +409,26 @@ fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
map
}
/// Read `[secondary_model] model` from the Kimi Code config.toml — the alias
/// subagents bind to when the experimental secondary-model feature is on.
/// Kimi Code emits usage records with the internal marker `__secondary__` for
/// those requests; the marker is resolved to this alias so prices and display
/// line up with the actual model.
fn read_secondary_model_alias(home: &Path) -> Option<String> {
let path = home.join("config.toml");
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
parsed
.get("secondary_model")
.and_then(|s| s.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Resolve the provider for a usage record's raw model key: prefer the `provider/`
/// prefix, then fall back to the legacy alias→provider map (config + backups).
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
@@ -530,6 +559,15 @@ fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
})
}
/// Warm the compiled-in models.dev price index off the first dashboard open:
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
/// background thread at startup instead of lazily on the first get_summary.
pub fn warm_price_index() {
std::thread::spawn(|| {
let _ = models_dev_cost_index();
});
}
/// Official providers take precedence over resellers when the same model id
/// ships under multiple providers and no provider prefix disambiguates.
const OFFICIAL_PROVIDERS: &[&str] = &[
@@ -581,15 +619,151 @@ fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
Some((key.clone(), *cost))
}
/// Strip a trailing derived-variant suffix ("-highspeed", "-free") from a
/// model id: plan endpoints expose e.g. `glm-5.2-highspeed`, which bills at
/// the base model's price.
fn strip_variant_suffix(model_name: &str) -> Option<String> {
for suffix in ["-highspeed", "-free"] {
if let Some(base) = model_name.strip_suffix(suffix) {
if !base.is_empty() {
return Some(base.to_string());
}
}
}
None
}
/// Strip a trailing context-size suffix ("-256k", "-128k", "-1m") from a model
/// id: context variants like "k3-256k" bill at the base model's ("k3") price.
/// Returns the stripped id (provider prefix preserved), or None when there is
/// no such suffix.
fn strip_context_suffix(model_name: &str) -> Option<String> {
let (prefix, bare) = match model_name.rsplit_once('/') {
Some((p, b)) => (Some(p), b),
None => (None, model_name),
};
let (stem, suffix) = bare.rsplit_once('-')?;
let digits = suffix.strip_suffix(|c| matches!(c, 'k' | 'K' | 'm' | 'M'))?;
if stem.is_empty() || digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(match prefix {
Some(p) => format!("{p}/{stem}"),
None => stem.to_string(),
})
}
/// Memoized price resolution. `match_price` is called once per usage record
/// during the scan, and the models.dev suffix scan is O(5910) per miss — with
/// thousands of records sharing a handful of model names, memoizing the result
/// turns the whole scan from O(records × 5910) into O(unique_models × 5910).
static MATCH_PRICE_MEMO: OnceLock<Mutex<HashMap<String, (String, f64, f64, f64, bool)>>> =
OnceLock::new();
/// Resolve a model's price, caching per-model results (hits and misses).
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
let key = model_name.to_ascii_lowercase();
let memo = MATCH_PRICE_MEMO.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(hit) = memo.lock().unwrap().get(&key) {
return hit.clone();
}
let result = match_price_inner(model_name);
memo.lock().unwrap().insert(key, result.clone());
result
}
/// Suffix-match a model against the models.dev index, skipping zero-priced
/// (subscription-plan) listings so a paid official entry wins. Used as the
/// fallback when the exact hit resolves to a zero-cost plan entry such as
/// `zhipuai-coding-plan/glm-5.2` or `kimi-for-coding/kimi-for-coding`.
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
.iter()
.filter(|(key, cost)| {
(cost.input > 0.0 || cost.output > 0.0)
&& key
.rsplit_once('/')
.map(|(_, model)| model.ends_with(&bare_l))
.unwrap_or(false)
})
.collect();
if matches.is_empty() {
return None;
}
matches.sort_by(|(a, _), (b, _)| {
provider_rank(a)
.cmp(&provider_rank(b))
.then_with(|| a.cmp(b))
});
let (key, cost) = matches[0];
Some((key.clone(), *cost))
}
/// Subscription-plan models without any priced counterpart in the snapshot
/// (e.g. the "Kimi For Coding" plan endpoint has no per-token price) bill at
/// the flagship model of the same family for cost estimation.
const PLAN_MODEL_FALLBACK: &[(&str, &str)] = &[
("kimi-for-coding", "moonshotai/kimi-k3"),
];
fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
let bare = match model_name.rsplit_once('/') {
Some((_, b)) => b,
None => model_name,
};
// 1) models.dev snapshot first (authoritative, all providers).
if let Some((id, c)) = models_dev_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
// Zero-priced listing (subscription-plan entries such as
// zhipuai-coding-plan/glm-5.2 or kimi-for-coding/kimi-for-coding):
// bill at a priced equivalent — the full id first, then derived
// variants (-highspeed / -free), then the context-size base, then the
// curated flagship fallback.
for base in [
Some(model_name.to_string()),
strip_variant_suffix(model_name),
strip_context_suffix(model_name),
]
.into_iter()
.flatten()
{
if let Some((bid, bc)) = priced_suffix_lookup(&base) {
if bc.input > 0.0 || bc.output > 0.0 {
return (bid, bc.cache_read, bc.input, bc.output, false);
}
}
}
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
for (plan, flagship) in PLAN_MODEL_FALLBACK {
if bare_l.contains(plan) {
if let Some((fid, fc)) = models_dev_lookup(flagship) {
return (fid, fc.cache_read, fc.input, fc.output, false);
}
}
}
return (id, c.cache_read, c.input, c.output, false);
}
// 1b) no direct match: retry with the context-size suffix stripped
// ("k3-256k" → "k3") before falling back to legacy tables.
if let Some(base) = strip_context_suffix(model_name) {
if let Some((id, c)) = models_dev_lookup(&base) {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 1c) still no exact match: fall back to a cross-provider bare-name
// suffix lookup in the models.dev snapshot. Custom-gateway aliases (e.g.
// "CodingPlan.site/deepseek-v4-flash", not catalogued in models.dev)
// then price against the official listing of the same model id.
if let Some((id, c)) = priced_suffix_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 2) legacy Kimi table (kept as a fallback for names not in models.dev).
let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() {
@@ -603,10 +777,12 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
}
fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) {
let (_price_id, cache_hit, input_price, output_price, est) = match_price(model);
let (price_id, cache_hit, input_price, output_price, est) = match_price(model);
// models.dev reports a separate cache_write price; fall back to input when
// absent (models without a cache_write field bill cache creation at input).
let cache_write_price = models_dev_lookup(model)
// Resolve via the matched price id so context-suffix fallbacks (k3-256k →
// k3) also pick up the base model's cache_write price.
let cache_write_price = models_dev_lookup(&price_id)
.and_then(|(_, c)| c.cache_write)
.unwrap_or(input_price);
let cost = (input_other as f64 / 1e6) * input_price
@@ -620,9 +796,30 @@ fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create:
// Scanner
// ---------------------------------------------------------------------------
/// Settings key caching the last configured secondary-model alias. Historical
/// `__secondary__` usage records carry no model identity, so billing follows
/// the currently configured secondary model; this cache keeps a stable basis
/// even after the secondary config is removed or changed.
const SECONDARY_MODEL_CACHE_KEY: &str = "dashboard.secondary_model_cache";
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home);
// Resolve the `__secondary__` marker (subagent requests bound to the
// configured secondary model) to the real model alias once per scan.
// Prefer the current config; fall back to the persisted cache so records
// keep a stable billing basis after the secondary config is removed or
// changed. When the config still declares a secondary model, refresh the
// cache with it.
let current_secondary = read_secondary_model_alias(home);
let cached_secondary = crate::db::get_setting_pub(SECONDARY_MODEL_CACHE_KEY)
.ok()
.flatten()
.filter(|s| !s.is_empty());
let secondary_alias = current_secondary.clone().or(cached_secondary);
if let Some(alias) = &current_secondary {
let _ = crate::db::set_setting_pub(SECONDARY_MODEL_CACHE_KEY, alias);
}
let mut records = Vec::new();
let mut files_scanned = 0;
let mut lines_seen = 0;
@@ -662,6 +859,21 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -671,16 +883,22 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
// Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &model_raw);
let (pid, _ch, _ip, _op, _) = match_price(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
// Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord {
time,
model: model_raw.clone(),
model_resolved: bare.to_string(),
model_display: model_raw.clone(),
provider: resolve_provider(&model_raw, &alias2prov),
from_env: model_raw == "__kimi_env_model__",
provider: resolve_provider(&price_model, &alias2prov),
from_env,
is_secondary,
input_other,
output,
input_cache_read: cache_read,
@@ -702,6 +920,45 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
})
}
// ---------------------------------------------------------------------------
// Scan cache
// ---------------------------------------------------------------------------
/// Short-TTL cache for the expensive `scan_usage` walk (reads every
/// wire.jsonl under the sessions root and prices every record). Tab switches
/// call get_summary repeatedly; re-walking the whole tree on every switch is
/// the main dashboard lag. Keyed by home, expires after 8s; `refresh=true`
/// bypasses it.
struct SummaryCacheEntry {
home: String,
scanned_at: std::time::Instant,
records: Vec<UsageRecord>,
meta: ScanMeta,
}
static SUMMARY_CACHE: Mutex<Option<SummaryCacheEntry>> = Mutex::new(None);
const SUMMARY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(8);
fn scan_usage_cached(home: &Path, refresh: bool) -> (Vec<UsageRecord>, ScanMeta) {
let home_s = home.to_string_lossy().to_string();
let mut cache = SUMMARY_CACHE.lock().unwrap();
let hit = cache
.as_ref()
.is_some_and(|e| !refresh && e.home == home_s && e.scanned_at.elapsed() < SUMMARY_CACHE_TTL);
if hit {
let e = cache.as_ref().unwrap();
return (e.records.clone(), e.meta.clone());
}
let (records, meta) = scan_usage(home);
*cache = Some(SummaryCacheEntry {
home: home_s,
scanned_at: std::time::Instant::now(),
records: records.clone(),
meta: meta.clone(),
});
(records, meta)
}
// ---------------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------------
@@ -748,6 +1005,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
model_resolved: r.model_resolved.clone(),
price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
@@ -756,6 +1014,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
});
entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
}
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
@@ -802,6 +1061,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation,
cost_usd: r.cost_usd, cost_estimated: r.cost_estimated,
price_id: r.price_id.clone(), from_env: r.from_env,
is_secondary: r.is_secondary,
}).collect();
RangeStats {
@@ -1448,13 +1708,15 @@ pub fn get_prices() -> PricesResult {
#[tauri::command]
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
let _refresh = refresh.unwrap_or(false);
let t0 = std::time::Instant::now();
let refresh = refresh.unwrap_or(false);
let home = resolve_kimi_home(home_override);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let r = range.unwrap_or_else(|| "30d".into());
let (records, meta) = scan_usage(&home);
let (records, meta) = scan_usage_cached(&home, refresh);
let t1 = std::time::Instant::now();
let stats = aggregate(&records, &r, now_ms);
let all_stats = aggregate(&records, "all", now_ms);
let heatmap = build_heatmap(&records, now_ms);
@@ -1479,6 +1741,15 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
});
let t2 = std::time::Instant::now();
let scan_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
let aggregate_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
let total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
eprintln!(
"[dashboard] get_summary range={} records={} scan={:.0}ms aggregate={:.0}ms total={:.0}ms",
r, records.len(), scan_ms, aggregate_ms, total_ms
);
SummaryResult {
home: home.to_string_lossy().to_string(),
valid: is_kimi_home(&home),
@@ -1572,6 +1843,19 @@ mod pricing_tests {
assert!(est, "unknown models must be flagged as estimated");
}
#[test]
fn custom_gateway_alias_bills_at_official_listing() {
// Custom-gateway aliases not catalogued in models.dev (e.g. the user's
// aggregated gateway "CodingPlan.site/deepseek-v4-flash") must resolve
// via the cross-provider bare-name lookup to a priced official entry
// instead of falling into the last-resort estimate.
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
assert!(!est, "resolved via models.dev, not estimated");
assert_eq!(id, "deepseek/deepseek-v4-flash");
assert_eq!(input, 0.14);
assert_eq!(output, 0.28);
}
#[test]
fn ambiguous_ids_prefer_official_provider() {
// glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai
@@ -1581,6 +1865,52 @@ mod pricing_tests {
assert_eq!(input, 0.6);
}
#[test]
fn context_suffix_variant_bills_at_base_model() {
// kimi-code/k3-256k only exists as a zero-priced subscription entry
// (kimi-for-coding/k3-256k); it must bill at the k3 list price.
let (id, ch, input, output, est) = match_price("kimi-code/k3-256k");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
assert_eq!(input, 3.0);
assert_eq!(output, 15.0);
assert_eq!(ch, 0.3);
// The strip helper leaves normal ids alone.
assert_eq!(strip_context_suffix("kimi-code/k3-256k").as_deref(), Some("kimi-code/k3"));
assert_eq!(strip_context_suffix("glm-4.6"), None);
assert_eq!(strip_context_suffix("kimi-k2-thinking"), None);
}
#[test]
fn subscription_plan_models_bill_at_priced_equivalent() {
// zhipuai-coding-plan/glm-5.2 is a zero-cost plan entry; the priced
// official zhipuai/glm-5.2 must win.
let (id, ch, input, output, est) = match_price("zhipuai-coding-plan/glm-5.2");
assert!(!est);
assert_eq!(id, "zhipuai/glm-5.2");
assert_eq!(input, 1.4);
assert_eq!(output, 4.4);
assert_eq!(ch, 0.26);
// Kimi For Coding has no priced counterpart at all → curated flagship.
let (id, _, input, _, est) = match_price("kimi-code/kimi-for-coding");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
assert_eq!(input, 3.0);
// -highspeed variants strip back to the priced base model.
let (id, _, input, _, est) = match_price("zhipuai-coding-plan/glm-5.2-highspeed");
assert!(!est);
assert_eq!(id, "zhipuai/glm-5.2");
assert_eq!(input, 1.4);
assert_eq!(
strip_variant_suffix("glm-5.2-highspeed").as_deref(),
Some("glm-5.2")
);
assert_eq!(strip_variant_suffix("glm-5.2"), None);
}
#[test]
fn cache_write_price_preferred_when_present() {
// glm-4.6 has cache_write: 0 → cache creation billed at 0, not input.
+103
View File
@@ -343,6 +343,39 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
}
root.insert("models".to_string(), TomlValue::Table(models_table));
// Sync UI-managed top-level sections (thinking, experimental,
// secondary_model, ...) from config.raw_other back into the root.
// At import time every top-level key except default_model/providers/
// models was collected into raw_other, so raw_other is the authoritative
// snapshot of those sections. This both persists UI edits to those
// sections and drops sections the user explicitly removed (absent from
// raw_other but still present in `existing`).
let mut managed_keys = std::collections::HashSet::new();
if let TomlValue::Table(extra) =
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
{
for (k, v) in extra {
if k == "default_model" || k == "providers" || k == "models" {
continue;
}
managed_keys.insert(k.clone());
root.insert(k, v);
}
}
let stale_keys: Vec<String> = root
.keys()
.filter(|k| {
*k != "default_model"
&& *k != "providers"
&& *k != "models"
&& !managed_keys.contains(*k)
})
.cloned()
.collect();
for k in stale_keys {
root.remove(&k);
}
TomlValue::Table(root)
}
@@ -552,6 +585,76 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_syncs_raw_other_top_level_sections() {
// Regression: UI-managed top-level sections ([thinking],
// [experimental], [secondary_model], ...) collected into raw_other at
// import time must be written back on export; sections removed from
// raw_other must be dropped from the output.
let toml_str = r#"
default_model = "glm-5.2"
default_thinking = true
[providers."glmzhongzhuan"]
type = "anthropic"
api_key = "sk-test"
[models."glm-5.2"]
provider = "glmzhongzhuan"
model = "glm-5.2"
max_context_size = 900000
[thinking]
enabled = true
[experimental]
secondary-model = true
[secondary_model]
model = "glm-5.2"
default_effort = "low"
"#;
let value: TomlValue = toml_str.parse().unwrap();
let mut config = kimi_code_to_config(&value);
// raw_other collected the unknown top-level sections
let raw = config.raw_other.as_object().unwrap();
assert!(raw.contains_key("thinking"));
assert!(raw.contains_key("experimental"));
assert!(raw.contains_key("secondary_model"));
// UI edit: flip the experimental flag off and drop secondary_model
let mut raw = config.raw_other.as_object().unwrap().clone();
raw.insert(
"experimental".to_string(),
serde_json::json!({"secondary-model": false}),
);
raw.remove("secondary_model");
config.raw_other = serde_json::Value::Object(raw);
let exported = config_to_kimi_code(&config, Some(&value));
let root = exported.as_table().unwrap();
// Updated section is written back
let exp = root.get("experimental").unwrap().as_table().unwrap();
assert_eq!(
exp.get("secondary-model").and_then(|v| v.as_bool()),
Some(false)
);
// Removed section is dropped
assert!(root.get("secondary_model").is_none());
// Untouched sections are preserved
let thinking = root.get("thinking").unwrap().as_table().unwrap();
assert_eq!(
thinking.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
root.get("default_thinking").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn kimi_code_export_does_not_inject_thinking_from_supports_1m() {
let mut providers = IndexMap::new();
+17 -2
View File
@@ -27,6 +27,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.setup(|app| {
println!("[Tauri] Setup started");
// Build the models.dev price index off the first dashboard query.
dashboard::warm_price_index();
let window = app.get_webview_window("main").unwrap();
println!("[Tauri] Window label: {}", window.label());
@@ -42,6 +44,7 @@ pub fn run() {
let icon = app.default_window_icon().unwrap().clone();
TrayIconBuilder::new()
.icon(icon)
.tooltip("Kimi Switch")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
@@ -106,8 +109,11 @@ pub fn run() {
commands::download_update,
commands::open_installer,
commands::open_external_url,
commands::open_kimi_web,
commands::open_kimi_web_embedded,
commands::kimi_oauth_start,
commands::kimi_oauth_poll,
commands::get_experimental_env_status,
dashboard::get_paths,
dashboard::get_prices,
dashboard::get_summary,
@@ -118,6 +124,15 @@ pub fn run() {
dashboard::delete_workspace,
dashboard::get_session_preview,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.manage(commands::KimiWebState::default())
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app: &tauri::AppHandle, event| {
// Ensure a `kimi web` server spawned for the embedded WebUI window
// is stopped when the app exits (windows already destroyed first).
if let tauri::RunEvent::Exit = event {
let state = app.state::<commands::KimiWebState>();
commands::kill_spawned_kimi_web(&state);
}
});
}
+14 -1
View File
@@ -79,7 +79,20 @@ pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result<Usage
)
.await?
{
Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))),
Fetched::Body(body) => {
let tiers = parse_kimi_coding(&body);
if tiers.is_empty() {
// 响应里没有可解析的套餐档位(limits/usage 缺失或字段变了)。
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。
let preview = serde_json::to_string(&body)
.unwrap_or_else(|_| "<unserializable body>".into());
let trimmed: String = preview.chars().take(400).collect();
return Ok(UsageResult::failure(format!(
"kimi usages 响应无套餐数据,原始返回: {trimmed}"
)));
}
Ok(UsageResult::ok(tiers))
}
Fetched::Failed(err) => Ok(err),
}
}
+6 -2
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.6.7",
"version": "0.7.3",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -26,7 +26,10 @@
},
"bundle": {
"active": true,
"targets": ["msi", "nsis"],
"targets": [
"msi",
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -34,6 +37,7 @@
"icons/icon.ico",
"icons/icon.icns"
],
"publisher": "codingplan.site",
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
+31 -4
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useConfig } from "./hooks/useConfig";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit";
import { SubagentSettingsPage } from "./components/SubagentSettingsPage";
import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal";
@@ -12,7 +13,7 @@ import { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
import { getModelRef } from "./lib/models-dev";
import { getModelRef, modelsDevReady, onModelsDevReady } from "./lib/models-dev";
import {
presetToProviderAndModels,
type ProviderPreset,
@@ -70,7 +71,7 @@ export default function App() {
updateConfig,
} = useConfig(agent);
const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list");
const [view, setView] = useState<"list" | "edit" | "subagent" | "dashboard" | "sessions">("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
@@ -121,6 +122,14 @@ export default function App() {
}).catch(() => {});
}, [loading, config, agent]);
// Load the models.dev index in the background (not in the main bundle)
// and re-render once it lands so context/capability columns fill in.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
onModelsDevReady(forceModelsDevReady);
void modelsDevReady();
}, []);
const providers = useMemo(
() => (config ? Object.values(config.providers) : []),
[config]
@@ -565,7 +574,7 @@ export default function App() {
type="button"
onClick={() => setView("list")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "list" || view === "edit"
view === "list" || view === "edit" || view === "subagent"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
@@ -641,6 +650,15 @@ export default function App() {
<DashboardPage />
) : view === "sessions" ? (
<SessionsPage />
) : view === "subagent" ? (
<SubagentSettingsPage
rawOther={config.raw_other}
models={config.models}
onChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
/>
) : view === "list" ? (
<ProviderList
providers={providers}
@@ -805,6 +823,15 @@ export default function App() {
>
{t("openConfigDir")}
</button>
{view === "list" && (
<button
type="button"
onClick={() => setView("subagent")}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("subagentSettings")}
</button>
)}
</div>
</footer>
)}
+25 -104
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps {
rawOther: unknown;
@@ -24,7 +24,18 @@ const PERMISSION_LABELS: Record<(typeof PERMISSION_DECISIONS)[number], Translati
ask: "permissionAsk",
};
const COMMON_EVENTS = ["PreToolUse", "PostToolUse"] as const;
// kimi-code 0.33+ added TurnStarted / UserPromptQueued / TaskStarted /
// SessionHeartbeat hook events (plus SessionStart / SessionEnd).
const COMMON_EVENTS = [
"PreToolUse",
"PostToolUse",
"TurnStarted",
"UserPromptQueued",
"TaskStarted",
"SessionHeartbeat",
"SessionStart",
"SessionEnd",
] as const;
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
const { t } = useTranslation();
@@ -91,9 +102,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
<Card title={t("loopControlSettings")}>
<NumberField
label={t("maxRetriesPerStep")}
value={settings.loop_control?.max_retries_per_step ?? 3}
onChange={(v) => updateLoopControl({ max_retries_per_step: v })}
label={t("maxAttemptsPerStep")}
value={
settings.loop_control?.max_attempts_per_step ??
settings.loop_control?.max_retries_per_step ??
3
}
onChange={(v) =>
updateLoopControl({
max_attempts_per_step: v,
max_retries_per_step: v,
})
}
/>
<NumberField
label={t("reservedContextSize")}
@@ -275,102 +295,3 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</div>
);
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+16 -7
View File
@@ -699,6 +699,7 @@ function ModelMapping({
<td className="px-4 py-2">
<CapabilitiesCell
capabilities={m.capabilities || []}
nativeImage={getModelRef(m.model)?.image === true}
onChange={(next) =>
onModelChange({ ...m, capabilities: next })
}
@@ -822,9 +823,13 @@ function JsonPreview({
function CapabilitiesCell({
capabilities,
onChange,
nativeImage,
}: {
capabilities: string[];
onChange: (next: string[]) => void;
/** 模型本身是否原生支持图像输入(models.dev `image` 能力)。原生多模态
* 模型无需 kimi-eyes 插件,提示不展示。 */
nativeImage?: boolean;
}) {
const { t } = useTranslation();
@@ -838,13 +843,12 @@ function CapabilitiesCell({
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* Only `thinking` is user-editable. The other capabilities
(always_thinking / image_in / video_in / tool_use) are still
auto-derived from models.dev via `capabilitiesFromRef` and saved
to config.toml — they just aren't exposed for manual editing.
To restore the full editor, drop the `.filter(...)` below and
re-add the custom-capabilities input (see git history). */}
{KNOWN_CAPABILITIES.filter((cap) => cap === "thinking").map((cap) => (
{/* `thinking` 与 `image_in`(识图)可手动编辑;其余能力
(always_thinking / video_in / tool_use) 仍由 models.dev 自动派生,
保存时照常写入 config.toml。 */}
{KNOWN_CAPABILITIES.filter(
(cap) => cap === "thinking" || cap === "image_in",
).map((cap) => (
<label
key={cap}
className="flex items-center gap-1 text-xs text-content-muted cursor-pointer"
@@ -858,6 +862,11 @@ function CapabilitiesCell({
{t(CAPABILITY_LABELS[cap])}
</label>
))}
{capabilities.includes("image_in") && !nativeImage && (
<span className="w-full text-[11px] text-content-muted">
{t("capImageInHint")}
</span>
)}
</div>
);
}
+24
View File
@@ -372,6 +372,30 @@ export function SettingsModal({
<div>{t("appDescription")}</div>
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
<div className="pt-2 space-y-1">
<div>
{t("settingsWebsite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/KimiSwitch")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/KimiSwitch
</button>
</div>
<div>
{t("kimiEyesSite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/kimi-eyes")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/kimi-eyes
</button>
</div>
<div>
{t("attributionPorted")}{" "}
<button
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import type { ExperimentalEnvStatus, Model } from "../types";
import {
EXPERIMENTAL_FLAGS,
clearSecondaryModel,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
isFlagLockedByEnv,
isMasterEnvOn,
setExperimentalFlag,
setSecondaryModelOnly,
type ExperimentalFlagDef,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
rawOther: unknown;
/** All configured models (alias → entry) for the secondary-model picker. */
models: Record<string, Model>;
onChange: (nextRawOther: unknown) => void;
onBack: () => void;
}
const EFFORTS = ["low", "medium", "high", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
"acp-v2": { name: "flagAcpV2", desc: "flagAcpV2Desc" },
persistence_minidb_readmodel: {
name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc",
},
};
export function SubagentSettingsPage({
rawOther,
models,
onChange,
onBack,
}: SubagentSettingsPageProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
}, []);
const flags = getExperimentalFlags(rawOther);
const secondary = getSecondaryModel(rawOther);
const masterOn = isMasterEnvOn(env);
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag (env overrides config). */
const secondaryEnabled = masterOn
? true
: isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: flags["secondary-model"] === true;
const secondaryLocked = masterOn || isFlagLockedByEnv(env, secondaryFlag);
const aliasList = Object.keys(models).sort();
// Inherited settings shown for the picked model: subagents bind the pointed
// entry directly (no patch fields), so context size and effort come from the
// model entry / global [thinking], not from a `[secondary_model]` patch.
const selectedEntry = secondary.model ? models[secondary.model] : undefined;
const inheritedContext = selectedEntry?.max_context_size;
const modelEffort =
selectedEntry &&
typeof (selectedEntry.raw_other as Record<string, unknown> | undefined)
?.default_effort === "string"
? ((selectedEntry.raw_other as Record<string, unknown>).default_effort as string)
: undefined;
const thinkingEffort = (() => {
const ro = rawOther as Record<string, unknown> | undefined;
const thinking = ro?.thinking as Record<string, unknown> | undefined;
return thinking && typeof thinking.effort === "string" && thinking.effort
? thinking.effort
: undefined;
})();
const inheritedEffort = modelEffort ?? thinkingEffort;
const fmtContext = (n: number): string =>
n >= 1_000_000
? `${(n / 1_000_000).toFixed(1)}M`
: n >= 1000
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag);
const on = masterOn
? true
: isFlagLockedByEnv(env, flag)
? forcedEnvValue(env, flag)
: flags[flag.id] === true;
return (
<div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">{t(labels.name)}</div>
<div className="text-xs text-content-muted">
{t(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={on}
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked))
}
/>
</div>
);
};
return (
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
<h2 className="font-semibold text-lg">{t("subagentSettings")}</h2>
</div>
<div className="flex-1 overflow-auto p-6 space-y-4">
{masterOn && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-4 py-2 text-sm text-green-700 dark:text-green-400">
{t("masterEnvOnHint")}
</div>
)}
{/* Kimi Code WebUI quick open */}
<Card title={t("webuiSection")}>
<p className="text-xs text-content-muted">{t("webuiDesc")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={async () => {
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("openWebUIEmbedded")}
</button>
<button
type="button"
onClick={async () => {
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("openWebUIBrowser")}
</button>
</div>
</Card>
{/* Subagent: secondary model picker + inherited settings */}
<Card title={t("secondaryModelSection")}>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{t("secondaryModelLabel")}</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={secondary.model ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
onChange(clearSecondaryModel(rawOther));
} else {
// Only the model reference is persisted — no patch fields.
// Subagents bind the pointed entry directly and inherit its
// settings; usage records stay keyed on the real alias
// instead of Kimi Code's synthesized `__secondary__` entry.
onChange(setSecondaryModelOnly(rawOther, alias));
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias}
</option>
))}
</select>
</label>
<div className="border-t border-border" />
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code>
</div>
</div>
{secondaryLocked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={secondaryEnabled}
disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked))
}
/>
</div>
{secondary.model !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedContext")}
</span>
<span className="font-medium text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedEffort")}
</span>
<span className="font-medium text-content-primary">
{inheritedEffort ? effortLabel(inheritedEffort) : "—"}
</span>
</div>
<p className="text-xs text-content-muted">
{t("secondaryModelInheritedHint")}
</p>
</div>
)}
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Other experimental features (not subagent-related) */}
<Card title={t("otherExperimentalFlags")}>
{otherFlags.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
</div>
);
}
+3 -1
View File
@@ -357,7 +357,9 @@ export function UsageFooter({
// success, or error/loading with last-good data (ghost).
// Compact header already shows the primary entry; detail only adds value
// when there are multiple tiers (e.g. 5h + weekly) or on error ghost.
if (data.length <= 1 && status === "success") return null;
// NOTE: single-tier plans (e.g. only five_hour) must still render — hiding
// them made kimi-for-coding look like it had no plan quota at all.
if (data.length === 0 && status === "success") return null;
const ghost = status !== "success";
return (
<div
+8 -2
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { DailyDetailModal } from "./DailyDetailModal";
@@ -43,6 +44,7 @@ function shortModel(model: string): string {
export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) {
const { t } = useTranslation();
const { money } = useCurrency();
const [selected, setSelected] = useState<DailyRow | null>(null);
const colorMap = useMemo(() => {
@@ -57,6 +59,10 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
if (dimension === "provider") {
return key === "unknown" ? unknownProviderLabel : key;
}
// Subagent records (Kimi Code `__secondary__` marker) show their own label.
if (key === "__secondary__") {
return t("subagentModel");
}
return shortModel(key);
};
@@ -103,7 +109,7 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
const tooltipLines = [
d.date,
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
`${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${money(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`,
...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`),
"",
t("doubleClickHint"),
+10 -4
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
import type { DailyRow } from "../../types/dashboard";
import { modelColor } from "./DailyBars";
@@ -22,6 +23,11 @@ function shortModel(model: string): string {
export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) {
const { t } = useTranslation();
const { money } = useCurrency();
/** Subagent records (Kimi Code `__secondary__` marker) show their own label. */
const modelName = (model: string) =>
model === "__secondary__" ? t("subagentModel") : shortModel(model);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -106,7 +112,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
{/* Day summary chips */}
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
<SummaryChip label={t("cost")} value={money(day.costUsd)} tone="orange" />
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
</div>
@@ -141,7 +147,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<div className="mt-1 ml-5 space-y-0.5">
{p.models.map((m) => (
<div key={m.model} className="flex items-baseline justify-between gap-2 text-xs">
<span className="truncate text-content-muted" title={m.model}>{shortModel(m.model)}</span>
<span className="truncate text-content-muted" title={m.model}>{modelName(m.model)}</span>
<span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span>
</div>
))}
@@ -165,7 +171,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-content-primary" title={s.model}>{shortModel(s.model)}</span>
<span className="truncate text-content-primary" title={s.model}>{modelName(s.model)}</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span>
</span>
+50 -25
View File
@@ -1,13 +1,20 @@
import { useState, type ReactNode } from "react";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { useCurrency } from "../../hooks/useCurrency";
import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { Heatmap } from "./Heatmap";
import { TrendLineChart } from "./TrendLineChart";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
/** Per-request cache hit rate: cached tokens / total input tokens. */
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
const denom = r.inputOther + r.inputCacheRead;
return denom > 0 ? r.inputCacheRead / denom : 0;
}
function Card({
title,
subtitle,
@@ -44,7 +51,8 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st
export function DashboardPage() {
const { t } = useTranslation();
const { range, changeRange, data, loading, error, refresh } = useDashboard();
const { money } = useCurrency();
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
@@ -71,7 +79,7 @@ export function DashboardPage() {
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
<div className="text-sm text-content-muted">{error}</div>
<button
onClick={() => refresh()}
onClick={() => refresh(true)}
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
>
{t("retry")}
@@ -120,32 +128,39 @@ export function DashboardPage() {
{ label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
{ label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) },
{ label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
{ label: t("kpiCost"), value: fmtUsd(totals.costUsd) },
{ label: t("kpiCost"), value: money(totals.costUsd) },
];
return (
<div className="h-full overflow-auto p-4 space-y-4">
{/* Range tabs + refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => changeRange(r)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{RANGE_LABELS[r]}
</button>
))}
<div className="flex items-center gap-3">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => changeRange(r)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{RANGE_LABELS[r]}
</button>
))}
</div>
{loadStats && !loading && (
<span className="hidden text-[10px] text-content-muted sm:inline">
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
</span>
)}
</div>
<button
type="button"
onClick={() => refresh()}
onClick={() => refresh(true)}
disabled={loading}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
>
@@ -175,7 +190,7 @@ export function DashboardPage() {
{fmtTokens(tot.totalTokens)}
</div>
<div className="mt-1 text-[11px] text-content-muted">
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
{fmtInt(tot.requests)} {t("unitTimes")} · {money(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
);
@@ -295,7 +310,9 @@ export function DashboardPage() {
}}
/>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
<span className="text-content-primary whitespace-nowrap truncate">
{m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model}
</span>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
)}
@@ -305,7 +322,7 @@ export function DashboardPage() {
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{money(m.costUsd)}</td>
</tr>
))}
</tbody>
@@ -346,6 +363,7 @@ export function DashboardPage() {
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheHit")}</th>
<th className="pb-2 font-normal text-right">{t("cost")}</th>
</tr>
</thead>
@@ -355,11 +373,18 @@ export function DashboardPage() {
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)}
</td>
<td className="py-1.5 pr-4 text-content-primary">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-content-primary">
<span className="flex items-center gap-1.5 min-w-0">
<span className="truncate">
{r.isSecondary ? t("subagentModel") : r.modelDisplay || r.model}
</span>
</span>
</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtPct(cacheHitOf(r))}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{money(r.costUsd)}</td>
</tr>
))}
</tbody>
+4 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../../i18n";
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtPct, fmtTokens } from "../../lib/dashboard-format";
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
interface HeatmapProps {
@@ -220,6 +221,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
function HeatmapTooltip({ state }: { state: HoverState }) {
const { t } = useTranslation();
const { money } = useCurrency();
const { cell, x, y } = state;
// Position above the cursor with a small offset; flip below if too close to top.
const offset = 14;
@@ -239,7 +241,7 @@ function HeatmapTooltip({ state }: { state: HoverState }) {
>
<div className="font-medium text-content-primary">{cell.date}</div>
<div className="text-content-muted">
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
{fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "}
{fmtInt(cell.requests)} {t("requests")}
</div>
<div className="text-content-muted">
+137
View File
@@ -0,0 +1,137 @@
import type { ReactNode } from "react";
/** Shared small form controls used by settings panels (Agent settings,
* Subagent settings, ...). Extracted from AgentSettingsPanel.tsx so new
* panels can reuse the same look without duplicating code. */
export function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
export function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
/** Pill-style toggle switch with a sliding knob. */
export function Toggle({
checked,
disabled,
onChange,
ariaLabel,
}: {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:outline-none ${
checked ? "bg-blue-600" : "bg-border"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
);
}
export function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
export function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "../i18n";
import { fmtUsd } from "../lib/dashboard-format";
/**
* USD → CNY exchange rate for the dashboard.
* Chinese mode shows prices in CNY (latest available rate), English stays in
* USD. The rate is fetched once and cached in localStorage for 12h; any
* failure falls back to a conservative fixed rate so the UI never blocks.
*/
const FALLBACK_RATE = 7.2;
const CACHE_KEY = "kimi-switch-cny-rate";
const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
function readCachedRate(): number | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const { ts, rate } = JSON.parse(raw) as { ts: number; rate: number };
if (Date.now() - ts > CACHE_TTL || !(rate > 1)) return null;
return rate;
} catch {
return null;
}
}
function writeCachedRate(rate: number) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), rate }));
} catch {
/* ignore */
}
}
export function useCurrency() {
const { lang } = useTranslation();
const [rate, setRate] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const cached = readCachedRate();
if (cached) {
setRate(cached);
return;
}
try {
const res = await fetch("https://open.er-api.com/v6/latest/USD");
if (res.ok) {
const data = (await res.json()) as { rates?: { CNY?: number } };
const cny = Number(data.rates?.CNY);
if (cny > 1) {
if (!cancelled) setRate(cny);
writeCachedRate(cny);
return;
}
}
} catch {
/* network blocked → fallback */
}
if (!cancelled) setRate(FALLBACK_RATE);
})();
return () => {
cancelled = true;
};
}, []);
/** Format a USD cost for display: CNY in Chinese mode, USD otherwise. */
const money = useCallback(
(usd: number): string => {
if (lang !== "zh") return fmtUsd(usd);
const v = (Number(usd) || 0) * (rate ?? FALLBACK_RATE);
if (v === 0) return "¥0.00";
if (v < 0.1) return "¥" + v.toFixed(3);
if (v < 1) return "¥" + v.toFixed(2);
return "¥" + v.toFixed(2);
},
[lang, rate],
);
return { money, cnyRate: rate ?? FALLBACK_RATE };
}
+13 -3
View File
@@ -22,13 +22,23 @@ export function useDashboard() {
const [data, setData] = useState<SummaryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Round-trip timing for the last get_summary call (user-perceived lag). */
const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null);
const refresh = useCallback(async () => {
const refresh = useCallback(async (force = false) => {
setLoading(true);
setError(null);
const start = performance.now();
try {
const result = await invoke<SummaryResult>("get_summary", { range });
const result = await invoke<SummaryResult>("get_summary", {
range,
refresh: force,
});
setData(result);
setLoadStats({
ms: Math.round(performance.now() - start),
kb: Math.round(JSON.stringify(result).length / 1024),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
@@ -50,5 +60,5 @@ export function useDashboard() {
}
}, []);
return { range, changeRange, data, loading, error, refresh };
return { range, changeRange, data, loading, error, refresh, loadStats };
}
+43 -1
View File
@@ -136,7 +136,7 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingMax: "Max",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control",
maxRetriesPerStep: "Max retries per step",
maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size",
backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks",
@@ -163,6 +163,7 @@ export const enTranslations: Record<TranslationKey, string> = {
capThinking: "Thinking",
capAlwaysThinking: "Always Thinking",
capImageIn: "Image Input",
capImageInHint: "Text models actually need the plugin-kimi-eyes:kimi-eyes plugin for image recognition",
capVideoIn: "Video Input",
capToolUse: "Tool Use",
customCapabilities: "Other capabilities (comma-separated)",
@@ -197,6 +198,7 @@ export const enTranslations: Record<TranslationKey, string> = {
scanning: "Scanning…",
refresh: "Refresh",
refreshing: "Refreshing…",
loadStats: "Loaded in {ms} ms · {kb} KB payload",
refreshed: "Refreshed",
cancel: "Cancel",
confirmDeleteTitle: "Delete session forever?",
@@ -260,6 +262,8 @@ export const enTranslations: Record<TranslationKey, string> = {
updateCheckFailed: "Update check failed",
lastChecked: "Last checked",
appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
referenceProject: "Reference project",
// Validation errors
@@ -316,6 +320,7 @@ export const enTranslations: Record<TranslationKey, string> = {
colCacheRead: "Cache read",
unitTimes: "requests",
estimate: "est.",
subagentModel: "Subagent model",
collapse: "Collapse",
expandAll: "Expand all ({n})",
@@ -443,4 +448,41 @@ export const enTranslations: Record<TranslationKey, string> = {
trendLineShowRequests: "Show requests",
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
trendLineNoData: "No data",
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "Advanced Settings",
experimentalFlags: "Experimental Features",
otherExperimentalFlags: "Other Experimental Features",
experimentalFlagsHint:
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
flagSecondaryModel: "Subagent Secondary Model",
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature",
flagAcpV2: "ACP v2 Protocol",
flagAcpV2Desc: "Enable Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
secondaryModelLabel: "Secondary model",
secondaryModelUnset: "Not configured",
secondaryModelInheritedContext: "Inherited context",
secondaryModelInheritedEffort: "Inherited effort",
secondaryModelInheritedHint:
"Subagents bind the selected model directly and inherit its context size and effort — no patch needed",
secondaryModelHint:
"Second model config bound to subagents by default; when unset, subagents inherit the primary model. Only the model reference is stored; subagents inherit all of that model's settings",
secondaryModelPriorityHint:
"Priority: explicit tool model param > profile model_preference > this config > inherit primary model",
// Kimi Code WebUI quick open
webuiSection: "Kimi Code WebUI",
webuiDesc:
"Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
openWebUIEmbedded: "Open in App",
openWebUIBrowser: "Open in Browser",
};
+43 -1
View File
@@ -134,7 +134,7 @@ export const zhTranslations = {
thinkingMax: "最大",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制",
maxRetriesPerStep: "单步重试次数",
maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小",
backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数",
@@ -161,6 +161,7 @@ export const zhTranslations = {
capThinking: "思考",
capAlwaysThinking: "始终思考",
capImageIn: "图像输入",
capImageInHint: "文本推理模型实际需要搭配 plugin-kimi-eyes:kimi-eyes 插件才能识图",
capVideoIn: "视频输入",
capToolUse: "工具调用",
customCapabilities: "其他能力(逗号分隔)",
@@ -194,6 +195,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",
@@ -255,6 +257,8 @@ export const zhTranslations = {
updateCheckFailed: "检查更新失败",
lastChecked: "上次检查",
appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
referenceProject: "引用项目",
// Validation errors
@@ -311,6 +315,7 @@ export const zhTranslations = {
colCacheRead: "缓存读",
unitTimes: "次",
estimate: "估算",
subagentModel: "子代理模型",
collapse: "收起",
expandAll: "展开全部 ({n})",
@@ -438,6 +443,43 @@ export const zhTranslations = {
trendLineShowRequests: "显示请求数",
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
trendLineNoData: "暂无数据",
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "高级设置",
experimentalFlags: "实验功能开关",
otherExperimentalFlags: "其他实验功能",
experimentalFlagsHint:
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
flagSecondaryModel: "子代理次主力模型",
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能",
flagAcpV2: "ACP v2 协议",
flagAcpV2Desc: "启用 Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
secondaryModelLabel: "次主力模型",
secondaryModelUnset: "未配置",
secondaryModelInheritedContext: "继承上下文长度",
secondaryModelInheritedEffort: "继承思考模式",
secondaryModelInheritedHint:
"子代理直接绑定所选模型,继承其上下文长度与思考设置,无需单独配置补丁",
secondaryModelHint:
"子代理默认绑定的第二份模型配置;未配置时子代理继承主模型;仅保存模型引用,子代理继承该模型的全部设置",
secondaryModelPriorityHint:
"优先级:工具显式 model 参数 > 子代理 profile 的 model_preference > 本配置 > 继承主模型",
// Kimi Code WebUI quick open
webuiSection: "Kimi Code WebUI",
webuiDesc:
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
openWebUIEmbedded: "在应用内打开",
openWebUIBrowser: "在浏览器打开",
} as const;
export type TranslationKey = keyof typeof zhTranslations;
+19 -5
View File
@@ -7,7 +7,7 @@ const DEFAULT_SETTINGS: AgentSettings = {
keep: "all",
},
loop_control: {
max_retries_per_step: 3,
max_attempts_per_step: 3,
reserved_context_size: 50000,
},
background: {
@@ -32,15 +32,29 @@ function getSection<T>(rawOther: unknown, key: string): T | undefined {
}
export function getAgentSettings(rawOther: unknown): AgentSettings {
const sectionLoop = getSection<AgentSettings["loop_control"]>(
rawOther,
"loop_control"
);
const loop: AgentSettings["loop_control"] = {
...DEFAULT_SETTINGS.loop_control,
...sectionLoop,
};
// kimi-code 0.33+ (v2 engine) renamed max_retries_per_step →
// max_attempts_per_step; migrate legacy values so they still show and edit.
if (
sectionLoop &&
sectionLoop.max_attempts_per_step === undefined &&
sectionLoop.max_retries_per_step !== undefined
) {
loop.max_attempts_per_step = sectionLoop.max_retries_per_step;
}
return {
thinking: {
...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
},
loop_control: {
...DEFAULT_SETTINGS.loop_control,
...getSection<AgentSettings["loop_control"]>(rawOther, "loop_control"),
},
loop_control: loop,
background: {
...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"),
+78167 -72243
View File
File diff suppressed because it is too large. Load diff
+65300 -60102
View File
File diff suppressed because it is too large. Load diff
+57 -15
View File
@@ -1,9 +1,15 @@
import snapshot from "./models-dev.json";
/**
* Compact per-model reference data from models.dev
* (see scripts/fetch-models-dev.mjs).
*
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
* as a static asset (`/models-dev.json`) and loaded once in the background.
* `getModelRef` stays synchronous and returns undefined until the index is
* ready — callers already fall back to defaults — and `modelsDevReady()`
* lets the app re-render once the index arrives.
*/
export interface ModelCost {
input?: number;
output?: number;
@@ -23,22 +29,58 @@ export interface ModelRef {
cost?: ModelCost;
}
const raw = snapshot as Record<string, unknown>;
let byLowerKey: Record<string, ModelRef> | null = null;
let readyListeners: Array<() => void> = [];
// Drop the "last_updated" metadata key; the rest is "<provider>/<model>".
const data: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(raw)) {
if (key !== "last_updated" && value && typeof value === "object") {
data[key] = value as ModelRef;
function buildIndex(raw: Record<string, unknown>) {
const data: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(raw)) {
if (key !== "last_updated" && value && typeof value === "object") {
data[key] = value as ModelRef;
}
}
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
const lower: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(data)) {
lower[key.toLowerCase()] = value;
}
return lower;
}
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
const byLowerKey: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(data)) {
byLowerKey[key.toLowerCase()] = value;
let loadPromise: Promise<void> | null = null;
export function modelsDevReady(): Promise<void> {
if (!loadPromise) {
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
.then((r) => {
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
})
.then((raw) => {
byLowerKey = buildIndex(raw);
const listeners = readyListeners;
readyListeners = [];
for (const cb of listeners) cb();
})
.catch((err) => {
// A failed load is permanent for this session: fall back to defaults.
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
}
return loadPromise;
}
/** Register a callback invoked once the models.dev index is ready. */
export function onModelsDevReady(cb: () => void): void {
if (byLowerKey) {
cb();
return;
}
readyListeners.push(cb);
}
/**
@@ -49,7 +91,7 @@ for (const [key, value] of Object.entries(data)) {
*/
export function getModelRef(modelId: string): ModelRef | undefined {
const id = modelId.trim().toLowerCase();
if (!id) return undefined;
if (!id || !byLowerKey) return undefined;
const exact = byLowerKey[id];
if (exact) return exact;
const suffix = `/${id}`;
+155
View File
@@ -0,0 +1,155 @@
import type { ExperimentalEnvStatus, SecondaryModelConfig } from "../types";
/**
* Helpers for the `[experimental]` and `[secondary_model]` sections of the
* Kimi Code config.toml. Both live in `config.raw_other` (the pass-through
* bucket for unknown top-level TOML sections), so these helpers read/write
* that JSON object directly.
*/
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
// ---------------------------------------------------------------------------
// [experimental] — experimental feature flags
// ---------------------------------------------------------------------------
export interface ExperimentalFlagDef {
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string;
}
/** Known experimental flags (Kimi Code v1 + v2 registries, merged). */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "acp-v2", envVar: "KIMI_CODE_EXPERIMENTAL_ACP_V2" },
{
id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
},
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
/** Whether the master env var is set to a truthy value (locks every flag on). */
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
}
/** Whether a flag's value is overridden by its env var (set & non-empty),
* locking the config toggle. Kimi Code lets the env var force the flag on
* *or* off, so a set env var locks regardless of its truthiness. */
export function isFlagLockedByEnv(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return env !== null && flag.envVar in env;
}
/** The value a locked flag is forced to by its env var. */
export function forcedEnvValue(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return isTruthyEnv(env?.[flag.envVar]);
}
/** Read `[experimental]` from raw_other: `{ flagId: boolean }`. */
export function getExperimentalFlags(rawOther: unknown): Record<string, boolean> {
const section = asRecord(rawOther)["experimental"];
const flags = asRecord(section);
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true;
}
return out;
}
/** Set one flag in `[experimental]`. Removing all flags drops the section. */
export function setExperimentalFlag(
rawOther: unknown,
id: string,
enabled: boolean
): unknown {
const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) {
flags[id] = true;
} else {
delete flags[id];
}
if (Object.keys(flags).length > 0) {
root.experimental = flags;
} else {
delete root.experimental;
}
return root;
}
// ---------------------------------------------------------------------------
// [secondary_model] — subagent secondary model
// ---------------------------------------------------------------------------
/** Read `[secondary_model]` from raw_other; `{}` when absent. */
export function getSecondaryModel(rawOther: unknown): SecondaryModelConfig {
const section = asRecord(rawOther)["secondary_model"];
const cfg = asRecord(section);
const out: SecondaryModelConfig = {};
if (typeof cfg.model === "string" && cfg.model !== "") out.model = cfg.model;
if (typeof cfg.default_effort === "string" && cfg.default_effort !== "")
out.default_effort = cfg.default_effort;
if (typeof cfg.max_output_size === "number" && cfg.max_output_size > 0)
out.max_output_size = cfg.max_output_size;
if (typeof cfg.max_context_size === "number" && cfg.max_context_size > 0)
out.max_context_size = cfg.max_context_size;
return out;
}
/**
* Write `[secondary_model]` with only the `model` field — no patch fields.
* Subagents then bind the pointed model entry directly and inherit its
* settings (context size, capabilities, effort fallback), which also keeps
* usage records keyed on the real model alias instead of the synthesized
* `__secondary__` derived entry. Unknown fields already present in the
* section are preserved; the known patch fields (`default_effort`,
* `max_output_size`, `max_context_size`) are dropped so stale patches are
* cleaned up when the model is re-picked.
*/
export function setSecondaryModelOnly(
rawOther: unknown,
alias: string
): unknown {
const root = { ...asRecord(rawOther) };
const section = asRecord(asRecord(rawOther)["secondary_model"]);
const next: Record<string, unknown> = { model: alias };
const knownFields: ReadonlySet<string> = new Set([
"model",
"default_effort",
"max_output_size",
"max_context_size",
]);
for (const [k, v] of Object.entries(section)) {
if (!knownFields.has(k)) next[k] = v;
}
root.secondary_model = next;
return root;
}
/** Remove `[secondary_model]` entirely (the "not configured" option). */
export function clearSecondaryModel(rawOther: unknown): unknown {
const root = { ...asRecord(rawOther) };
delete root.secondary_model;
return root;
}
+4
View File
@@ -24,6 +24,8 @@ export interface ModelRow extends TotalsRow {
modelResolved: string;
priceId: string;
costEstimated: boolean;
/** True when any record in this row came from a subagent (secondary-model) request. */
isSecondary: boolean;
}
export interface RecentRow {
@@ -40,6 +42,8 @@ export interface RecentRow {
costEstimated: boolean;
priceId: string;
fromEnv: boolean;
/** True when this request came from a subagent (secondary-model) call. */
isSecondary: boolean;
}
export interface RangeStats {
+21
View File
@@ -97,6 +97,11 @@ export interface ThinkingConfig {
}
export interface LoopControlConfig {
/** v2 engine key (kimi-code 0.33+): `max_retries_per_step` was renamed. */
max_attempts_per_step?: number;
/** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */
max_steps_per_turn?: number;
/** Legacy v1 key — written in sync so KIMI_CODE_LEGACY_FLAG=1 still works. */
max_retries_per_step?: number;
reserved_context_size?: number;
}
@@ -127,3 +132,19 @@ export interface AgentSettings {
permission?: { rules?: PermissionRule[] };
hooks?: Hook[];
}
/**
* Kimi Code `[secondary_model]` section of config.toml: the secondary model
* bound to newly spawned subagents (experimental feature).
* `model` points at an alias defined in `[models]`; the remaining fields are
* a patch applied on top of the referenced entry (subagents only).
*/
export interface SecondaryModelConfig {
model?: string;
default_effort?: string;
max_output_size?: number;
max_context_size?: number;
}
/** Values of experimental env vars that are currently set (non-empty). */
export type ExperimentalEnvStatus = Record<string, string>;
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
+37
View File
@@ -0,0 +1,37 @@
# KimiSwitch 官网(M1:结构骨架 + SEO)
独立 Vite + React + Tailwind 工程,与主 Tauri 应用隔离。
## 开发
```bash
cd website
npm install
npm run dev # http://localhost:5173/KimiSwitch/
```
## 构建
```bash
npm run build # tsc + vite build → dist/
npm run preview # 本地预览生产产物
```
## 部署
GitHub Pages(项目仓库 Pages):设置 → Pages → Source: `gh-pages` 分支 / root。
Vite `base` 已配置为 `/KimiSwitch/`(仓库子路径),构建产物可直接发布到 `gh-pages` 分支根目录。
```bash
# 推送 dist/ 到 gh-pages 分支(任选方式:gh-pages 包 / git subtree)
npx gh-pages -d dist
```
## 里程碑
- ✅ **M1**(当前):结构骨架 + SEO(meta、关键词、语义化)
- ⏭ **M2**:截图填充(替换 17 张归档素材占位)
- ⏭ **M3**:打磨动效 + 响应式 + GitHub Pages 部署 + OG 图合成
参考 `docs/WEBSITE-PLAN.md` v4。
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%kimi.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#efebe1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#231f1a" media="(prefers-color-scheme: dark)" />
<title>Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然</title>
<meta
name="description"
content="Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。"
/>
<meta
name="keywords"
content="Kimi Switch, KimiSwitch, KimiCode Switch, KimiCodeSwitch, SwitchKimi, Kimi Code Switch, Kimi 切换, LLM 供应商管理, AI 供应商切换, 多 LLM 管理, Kimi 用量监控, 智谱 GLM, MiniMax, DeepSeek, OpenRouter, 基元律动, 用量查询, 账单监控, Token 套餐, Claude, GPT, Windows LLM 桌面工具"
/>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
<meta
property="og:description"
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
/>
<meta property="og:url" content="https://billowliu2.github.io/KimiSwitch/" />
<meta property="og:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
<meta
name="twitter:description"
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
/>
<meta name="twitter:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
<!-- Canonical -->
<link rel="canonical" href="https://billowliu2.github.io/KimiSwitch/" />
<!-- JSON-LD: SoftwareApplication + Organization + WebSite -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareApplication",
"name": "Kimi Switch",
"alternateName": ["KimiSwitch", "KimiCodeSwitch", "Kimi Switch", "KimiCode Switch"],
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11",
"softwareVersion": "0.6.7",
"dateModified": "2026-08-01",
"description": "Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
"license": "https://opensource.org/licenses/MIT",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Organization",
"name": "KimiSwitch",
"url": "https://github.com/billowliu2/KimiSwitch"
}
},
{
"@type": "Organization",
"name": "KimiSwitch",
"url": "https://github.com/billowliu2/KimiSwitch",
"logo": "https://billowliu2.github.io/KimiSwitch/kimi.svg",
"sameAs": [
"https://github.com/billowliu2/KimiSwitch",
"https://git.codingplan.site/admin/KimiCodeSwitch"
]
},
{
"@type": "WebSite",
"name": "Kimi Switch",
"url": "https://billowliu2.github.io/KimiSwitch/",
"inLanguage": "zh-CN"
}
]
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2860
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "kimiswitch-website",
"private": true,
"version": "0.6.7",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/fira-code": "^5.3.0",
"@fontsource/geist": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@phosphor-icons/react": "^2.1.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="22" fill="url(#g)"/>
<text x="48" y="70" text-anchor="middle" font-size="52" font-weight="700"
fill="white" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">K</text>
<circle cx="74" cy="26" r="9" fill="white" opacity="0.35"/>
<circle cx="74" cy="26" r="6.5" fill="white" opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

After

Width:  |  Height:  |  Size: 711 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" fill="none">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#1A1A1E"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

+27
View File
@@ -0,0 +1,27 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 189 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#229;&#159;&#186;&#229;&#133;&#131;&#229;&#190;&#139;&#229;&#138;&#168;logo0630 2 [Vectorized]" opacity="0.75">
<path id="Vector" d="M28.2869 15.3313L52.3032 15.3318C56.6495 15.332 61.1409 15.3885 65.4782 15.3038C62.4476 17.8251 58.7745 21.2659 55.8259 23.9557C54.451 24.0375 52.5027 23.9839 51.0859 23.9842C48.459 23.9946 45.832 23.9874 43.2052 23.9626C44.8543 25.4287 47.4485 28.1941 49.1119 29.8604L61.0355 41.7999L68.4647 49.2348C68.9123 49.6831 71.0764 51.7891 71.2735 52.1321L71.1537 52.1688C67.1352 52.0344 62.9706 52.1522 58.9688 51.9728L45.811 38.8082C42.8897 35.885 39.8219 32.8966 36.9616 29.9257L36.9693 44.1437C34.1378 46.911 31.1624 49.6754 28.2845 52.4082L28.2869 15.3313Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_2" d="M98.7525 30.6261L100.21 30.6376L100.194 32.1506L103.548 32.1483L103.537 34.4298L100.204 34.4441C100.223 36.862 100.217 39.28 100.188 41.6978C101.416 41.6652 102.754 41.6897 103.991 41.6874C103.956 42.3315 103.988 43.3183 103.992 43.9834C102.257 43.9898 100.451 44.0225 98.723 43.9532C100.293 45.3913 102.261 45.9617 104.247 46.4987C104.03 47.4093 103.842 48.4144 103.65 49.3374C102.178 48.9282 99.9596 48.0446 98.7359 47.1392L98.7303 48.6004C97.2215 48.6082 95.7126 48.6069 94.2028 48.5966V50.1824C97.2187 50.1304 100.388 50.1795 103.416 50.1805C103.358 50.8001 103.384 51.7645 103.382 52.4095L85.6626 52.41L82.2408 52.4069C82.1845 51.6493 82.0959 50.9327 82.0033 50.1798L91.2904 50.1778L91.3044 48.6046L86.8554 48.6069L86.7349 47.176C84.9932 48.2367 83.7603 48.7314 81.8149 49.3328C81.5976 48.432 81.4066 47.4129 81.2142 46.4981C83.4621 45.9232 84.8957 45.4246 86.7564 43.9544C85.2596 44.0446 83.2197 43.9867 81.6781 43.9816C81.6749 43.3384 81.5832 42.3121 81.4779 41.6911L85.2003 41.6896L85.202 34.4349L82.4254 34.4319C82.3711 33.6655 82.326 32.9125 82.2188 32.1509L85.1936 32.1508L85.1881 30.652C86.1541 30.6266 87.1534 30.6348 88.1224 30.631L88.0896 32.1462L97.2732 32.1504L97.2824 30.6572L98.7525 30.6261ZM91.2808 44.7054L94.2028 44.7065L94.2038 46.2956C94.5083 46.5151 96.9954 46.3764 97.6672 46.4214C96.8117 45.5574 96.2802 45.0332 95.6194 43.9894C93.6999 44.0104 91.7815 44.0086 89.8628 43.984C89.1274 45.024 88.7137 45.528 87.7696 46.42C88.9413 46.3968 90.1131 46.3965 91.2848 46.419L91.2808 44.7054ZM97.2824 35.4409L97.2852 34.439C94.5175 34.4424 90.8129 34.5608 88.1224 34.4435L88.0839 35.4132L94.0229 35.4172C94.9817 35.4175 96.3485 35.3769 97.2824 35.4409ZM97.2741 41.6939C97.2824 41.4051 97.3359 40.9901 97.1892 40.7622C96.3189 40.6695 94.8212 40.7158 93.9066 40.7186L88.1216 40.7332C88.112 41.0237 88.088 41.3984 88.0937 41.6812C91.1174 41.683 94.2601 41.6422 97.2741 41.6939ZM88.1146 37.5911C88.1072 37.8571 88.0611 38.2777 88.1768 38.4975C88.6462 38.6161 90.4817 38.56 91.0733 38.5629C93.1342 38.5734 95.2069 38.5231 97.2695 38.5662C97.2759 38.3047 97.3248 37.8151 97.2058 37.6159C94.4695 37.475 90.9224 37.6123 88.1146 37.5911Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_3" d="M143.557 30.6421C144.519 30.6365 145.481 30.6369 146.444 30.6431L146.416 32.0838C147.712 32.0952 150.587 31.7416 151.532 32.6334C152.238 33.2995 152.073 34.9116 152.037 35.836L153.455 35.8341L153.452 38.2574L152.061 38.2665C152.068 38.6574 152.073 39.0484 152.074 39.4393C152.077 40.817 151.714 41.9762 150.071 42.0028C148.863 42.0222 147.644 41.9835 146.435 41.9641L146.417 43.4188L152.471 43.4164C152.45 44.172 152.462 44.9907 152.458 45.7513C150.451 45.767 148.443 45.7646 146.434 45.7444L146.421 47.307L153.455 47.3061L153.448 49.7244L146.433 49.7289C146.427 50.7883 146.426 51.8478 146.432 52.9071C145.466 52.9302 144.528 52.9131 143.562 52.8971L143.57 52.0394L143.562 49.7232C141.674 49.7332 139.786 49.7317 137.898 49.7187L137.652 47.305C139.595 47.2752 141.613 47.3037 143.563 47.3043V45.7518L138.841 45.7562C138.807 44.9585 138.72 44.2115 138.634 43.4192L143.567 43.4181L143.557 41.9638C142.283 41.9729 141.01 41.9746 139.735 41.9686C139.676 41.1971 139.589 40.4064 139.512 39.6348L143.557 39.6422L143.567 38.2609C142.025 38.2767 140.451 38.2581 138.907 38.2555C138.812 37.4492 138.73 36.6414 138.659 35.8325C140.283 35.8153 141.935 35.8318 143.561 35.8329L143.563 34.4377L139.734 34.4393C139.706 33.6753 139.59 32.8396 139.5 32.0747C140.831 32.1063 142.213 32.0809 143.561 32.1059L143.557 30.6421ZM146.418 35.8352C147.335 35.8344 148.302 35.817 149.215 35.8342C149.226 35.4685 149.29 35.001 149.104 34.7061C148.57 34.1891 147.223 34.4624 146.434 34.4293L146.418 35.8352ZM146.415 39.6429C147.046 39.6429 148.618 39.6765 149.164 39.578C149.198 39.1228 149.226 38.7194 149.231 38.263C148.344 38.2616 147.306 38.287 146.433 38.2546L146.415 39.6429Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_4" d="M106.528 38.04C113.768 38.1537 121.294 38.0359 128.569 38.0446L128.562 40.5572L122.09 40.5609C122.088 41.6707 121.918 49.1898 122.298 49.5794C122.677 49.9669 123.385 49.993 123.896 49.9976C124.384 50.0018 125.079 50.0346 125.443 49.658C126.096 48.9854 125.794 46.7277 125.787 45.8074C126.773 45.9027 127.712 46.0629 128.733 46.1458C128.717 47.4305 128.81 48.9848 128.679 50.2335C128.37 53.174 123.248 52.7047 121.193 52.341C118.598 51.6953 119.124 49.0866 119.126 46.9824L119.136 40.5538L115.088 40.5599C115.11 42.4962 115.153 44.316 114.498 46.1768C113.261 49.6902 110.052 51.4181 106.92 52.9188L106.102 49.9209C107.583 49.2936 109.128 48.4728 110.259 47.3244C112.25 45.3015 112.152 43.1551 112.126 40.5521L106.708 40.5516C106.715 39.8733 106.589 38.7478 106.528 38.04Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_5" d="M66.8075 16.3432C66.9854 16.5409 66.8988 26.5212 66.8939 27.8531C64.2096 30.6301 61.1162 33.5951 58.3448 36.3093L55.4318 33.3274C54.3728 32.2341 53.3067 31.1479 52.2334 30.0688L66.8075 16.3432Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_6" d="M168.959 30.6309C169.884 30.6563 170.931 30.6308 171.866 30.6301L171.852 34.4054C173.207 34.3801 175.717 34.1215 176.702 34.9053C177.766 36.6628 177.13 47.7169 177.116 50.1618C177.1 53.3678 173.527 52.8804 171.243 52.5637L170.593 50.0131C171.16 50.153 171.784 50.2022 172.368 50.2301C172.828 50.2521 173.663 50.2678 173.997 49.902C174.784 49.0385 174.376 39.5355 174.327 38.0864C174.322 37.7564 174.344 37.497 174.196 37.1993C173.703 36.7025 172.784 36.9975 171.856 36.8896C171.872 43.0678 171.482 47.246 168.791 52.9199L165.627 52.9227C168.446 46.8398 168.968 43.577 168.974 36.942L166.948 36.9497C166.846 36.141 166.779 35.2157 166.701 34.3979L168.97 34.3965L168.959 30.6309Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_7" d="M20.3634 15.2565C22.2816 15.2079 24.3745 15.2491 26.3065 15.2484C26.241 18.0694 26.2922 21.1045 26.2817 23.9411L16.4729 23.9386C14.5285 23.9342 12.3458 23.8846 10.4213 23.964C13.7597 21.1535 17.0176 18.0347 20.3634 15.2565Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_8" d="M155.77 38.0545L166.671 38.0592L166.66 40.4752C165.053 40.4843 163.446 40.4852 161.84 40.4778C161.528 43.4002 160.536 45.7653 159.075 48.266C160.563 48.2528 162.053 48.2562 163.542 48.2759C163.189 46.7011 162.709 45.1377 162.374 43.5428L162.451 43.4572L163.137 43.4397L165.225 43.4383C165.596 44.9311 165.967 46.4514 166.333 47.9353C166.75 49.6312 166.649 50.8135 164.561 50.8055C163.779 50.8026 162.918 50.8113 162.128 50.8104L155.98 50.8004C155.944 50.318 155.704 48.6508 155.865 48.2955L156.187 48.2595C156.442 48.0579 156.735 47.3129 156.883 46.9886C157.864 44.8345 158.443 42.8159 158.779 40.4811L155.978 40.4739L155.77 38.0545Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_9" d="M135.661 36.5098L138.529 36.5075C137.667 39.5604 136.341 41.2684 133.853 43.125C135.823 43.0944 136.683 42.9556 136.651 45.3019C136.642 45.9935 136.648 46.7679 136.646 47.4658L136.62 52.91C135.725 52.9391 134.714 52.9162 133.81 52.9155L133.813 44.8194L131.69 44.8038C131.463 43.9161 131.164 42.9683 130.903 42.0839C133.129 40.7958 134.759 38.9216 135.661 36.5098Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_10" d="M108.577 31.6073C114.355 31.739 120.686 31.6114 126.507 31.6117L126.501 34.1803C120.771 34.0572 114.56 34.1742 108.794 34.1751C108.692 33.3151 108.638 32.4706 108.577 31.6073Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_11" d="M119.479 15.71C120.798 15.693 123.164 15.5224 124.308 16.0558C126.299 16.9837 126.076 20.1731 123.91 20.9264L126.019 24.5984L124.235 24.5917C123.99 24.2657 122.699 21.6603 122.451 21.1619L121.062 21.1583C121.058 22.307 121.06 23.4556 121.068 24.6042L119.473 24.5985C119.566 21.8236 119.51 18.5035 119.479 15.71ZM121.051 19.7961C121.945 19.8033 123.018 19.9234 123.753 19.3913C124.995 17.011 122.631 17.0745 121.058 17.0804L121.051 19.7961Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_12" d="M155.37 18.0461C155.609 18.0631 156.076 18.0928 156.278 18.1888C156.662 18.3716 156.969 18.8524 157.439 18.8598C157.913 18.7376 157.723 18.2102 158.869 18.0577C162.365 17.5926 161.518 22.3556 161.493 24.6266C161.146 24.5826 160.371 24.5993 159.991 24.5975C160.008 23.7007 160.273 20.0376 159.597 19.6128C157.02 17.9932 157.607 22.7534 157.616 23.9524C157.627 24.1804 157.666 24.3672 157.523 24.5406C157.209 24.6959 156.557 24.606 156.131 24.6033C156.14 23.7629 156.368 20.0944 155.742 19.655C153.081 17.7873 153.733 23.3044 153.748 24.6107L152.219 24.596L152.224 18.1901L153.622 18.1893C153.646 18.4011 153.643 18.5972 153.772 18.7625C154.361 18.5998 154.068 18.2095 155.37 18.0461Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_13" d="M91.1429 18.0522C94.3302 18.122 94.6993 21.3866 93.4369 23.7784C93.088 24.4389 92.238 24.6456 91.5331 24.7415C89.621 24.7439 88.6302 23.9073 88.4773 21.9881C88.3105 19.8932 88.8263 18.2411 91.1429 18.0522ZM91.5428 23.479C92.8444 22.7222 93.0262 19.9867 91.5137 19.3544C91.3955 19.305 91.0173 19.3148 90.8734 19.3189C89.5574 20.1837 89.4669 23.4529 91.5428 23.479Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_14" d="M145.02 15.4482L146.555 15.456C146.534 16.612 146.558 17.8251 146.565 18.9856C146.847 18.6568 147.078 18.4626 147.403 18.1796C151.439 17.4693 150.425 21.8162 150.425 24.6083C149.943 24.5952 149.421 24.6041 148.934 24.6027L148.939 21.4117C148.948 20.7508 149.021 20.2024 148.532 19.6668C148.255 19.2434 147.363 18.9955 147.009 19.481C146.226 20.5554 146.536 23.3853 146.553 24.6374C146.158 24.5887 145.445 24.6029 145.024 24.5998C145.105 21.8847 145.105 18.1604 145.02 15.4482Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_15" d="M127.242 15.4528L128.754 15.4523L128.731 19.0125C129.054 18.6436 129.23 18.4927 129.609 18.1793C133.617 17.4115 132.634 21.915 132.617 24.626C132.225 24.5865 131.519 24.6004 131.104 24.5962C131.138 23.6667 131.118 22.6056 131.122 21.6658C131.127 21.2822 131.185 19.819 130.796 19.5937C128.046 18.0022 128.721 23.0514 128.732 24.6298C128.356 24.5837 127.656 24.6016 127.253 24.6009C127.299 21.6611 127.302 18.3882 127.242 15.4528Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_16" d="M104.703 18.0539C107.273 18.0208 107.522 19.7472 107.536 21.7736C107.485 21.7663 107.282 21.7393 107.24 21.7437C106.531 21.8174 103.885 21.4971 103.447 21.9005C103.203 22.6803 103.891 23.0805 104.486 23.4144C105.403 23.9286 105.93 23.2584 106.671 23.0228L107.281 23.9938C105.121 25.5435 102.096 24.8569 101.965 21.8757C101.873 19.7994 102.385 18.2445 104.703 18.0539ZM105.228 20.7215C105.635 20.7127 105.722 20.7826 106.002 20.5611C106.26 19.6405 105.264 19.2138 104.479 19.2263C103.984 19.3874 103.424 19.9315 103.375 20.4601C103.611 20.9109 104.71 20.7275 105.228 20.7215Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_17" d="M95.613 15.4501L97.1495 15.4595C97.0489 16.8341 97.132 19.5081 97.1476 20.9768C97.8592 20.0398 98.5873 19.1154 99.332 18.204L101.148 18.1842C100.305 19.0863 99.3081 20.3303 98.4959 21.2937C99.0164 21.8404 99.6947 22.698 100.195 23.2939C100.563 23.7308 100.935 24.1646 101.311 24.5952L99.44 24.5937C98.6621 23.6374 97.8536 22.6688 97.1255 21.6764C97.1273 22.6237 97.1089 23.6683 97.1532 24.6062C96.6299 24.6007 96.1288 24.6149 95.6056 24.6313C95.6683 21.6443 95.6314 18.4428 95.613 15.4501Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_18" d="M138.155 30.6314L138.216 30.6632C138.225 30.6912 138.25 30.7914 138.242 30.8154C137.368 33.7396 135.557 36.4317 132.96 38.0842C132.725 38.2333 132.282 38.5592 132.026 38.5579L131.893 38.3232L131.215 36.0397C133.275 34.5304 134.685 33.1812 135.475 30.6529C136.336 30.6234 137.286 30.6371 138.155 30.6314Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_19" d="M138.031 18.1923L139.634 18.187C139.113 19.4098 138.609 20.91 138.147 22.1743C137.664 23.3967 137.181 25.3724 136.413 26.3367C135.914 26.9644 135.196 26.9582 134.462 26.9886C134.433 25.9751 134.301 25.8813 135.281 25.5299C135.564 25.4284 135.723 24.84 135.83 24.5486L133.541 18.1904L135.218 18.1897C135.53 19.4879 136.212 21.5023 136.617 22.8346L138.031 18.1923Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_20" d="M112.102 18.046C115.346 18.1544 114.465 22.2111 114.441 24.6366C114.084 24.5819 113.334 24.6005 112.944 24.6C112.968 23.2884 113.132 21.3365 112.826 20.0538C112.623 19.2009 111.715 19.2205 110.994 19.5094C110.308 20.3316 110.553 23.3734 110.563 24.6263C110.172 24.5862 109.502 24.601 109.088 24.5984L109.092 18.1938C109.399 18.1918 110.308 18.1335 110.496 18.2688L110.576 18.8185C110.925 18.811 110.951 18.218 112.102 18.046Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_21" d="M157.03 32.3117L165.63 32.3144C165.624 33.1227 165.608 33.931 165.584 34.7389C165.222 34.8005 163.328 34.7749 162.89 34.7741L157.199 34.7598C157.159 33.9511 157.089 33.1214 157.03 32.3117Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_22" d="M81.1911 15.7075L87.5055 15.7099L87.4955 17.1429C86.7222 17.1164 85.9486 17.1026 85.1749 17.1016L85.1705 22.3787L85.1727 24.6281C84.799 24.5835 84.0131 24.599 83.6075 24.596L83.6191 17.1192C82.8556 17.1088 82.092 17.115 81.3288 17.1376C81.3098 16.6005 81.267 16.2407 81.1911 15.7075Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_23" d="M140.921 16.2497L142.45 16.2545C142.414 16.8395 142.427 17.6034 142.421 18.2023L143.71 18.1906L143.703 19.4062L142.427 19.3996C142.426 20.1072 142.287 22.5398 142.611 23.1728C142.719 23.383 143.27 23.3172 143.532 23.3359C143.762 23.5842 143.697 23.5858 143.741 23.9739L143.686 24.4864L143.561 24.5789C139.822 24.8423 141.248 21.9367 140.879 19.4935C140.535 19.4014 139.948 19.3017 139.914 18.8681C139.846 18.0247 140.318 18.2036 140.926 18.1833L140.921 16.2497Z" fill="var(--fill-0, #0D0D0C)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://billowliu2.github.io/KimiSwitch/sitemap.xml
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://billowliu2.github.io/KimiSwitch/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://billowliu2.github.io/KimiSwitch/features</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://billowliu2.github.io/KimiSwitch/changelog</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://billowliu2.github.io/KimiSwitch/download</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://github.com/billowliu2/KimiSwitch</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
+98
View File
@@ -0,0 +1,98 @@
import { useEffect } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import Nav from "./components/Nav";
import Hero from "./sections/Hero";
import Features from "./sections/Features";
import Changelog from "./sections/Changelog";
import Download from "./sections/Download";
import Privacy from "./sections/Privacy";
import CTA from "./sections/CTA";
import Footer from "./components/Footer";
import { useLang, type Dict } from "./i18n";
const SITE_URL = "https://billowliu2.github.io/KimiSwitch";
type SeoKey = keyof Dict["seo"];
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
/** Update per-page title / description / OG / canonical for SEO. */
function RouteMeta() {
const { pathname } = useLocation();
const { t } = useLang();
useEffect(() => {
const page = pathname.replace(/\/+$/, "");
const seoKey: SeoKey =
page === "/features" || page === "/changelog" || page === "/download"
? (page.slice(1) as SeoKey)
: "home";
const m = t.seo[seoKey];
const url = `${SITE_URL}${page === "" ? "/" : page}`;
document.title = m.title;
setMeta("name", "description", m.description);
setMeta("property", "og:title", m.title);
setMeta("property", "og:description", m.description);
setMeta("property", "og:url", url);
setMeta("name", "twitter:title", m.title);
setMeta("name", "twitter:description", m.description);
const canon = document.querySelector('link[rel="canonical"]');
if (canon) canon.setAttribute("href", url);
}, [pathname, t]);
return null;
}
function setMeta(attr: "name" | "property", key: string, content: string) {
let el = document.head.querySelector<HTMLMetaElement>(
`meta[${attr}="${key}"]`,
);
if (!el) {
el = document.createElement("meta");
el.setAttribute(attr, key);
document.head.appendChild(el);
}
el.setAttribute("content", content);
}
const homePage = (
<>
<Hero />
<CTA />
</>
);
export default function App() {
return (
<div className="flex min-h-screen flex-col">
<Nav />
<main className="flex-1">
<ScrollToTop />
<RouteMeta />
<Routes>
<Route path="/" element={homePage} />
<Route
path="/features"
element={
<>
<Features />
<Privacy />
</>
}
/>
<Route path="/changelog" element={<Changelog />} />
<Route path="/download" element={<Download />} />
<Route path="*" element={homePage} />
</Routes>
</main>
<Footer />
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { recommendLinks } from "../data/recommendLinks";
import { useLang } from "../i18n";
import { asset } from "../lib/asset";
/** Map recommendLink.name → logo file in public/logos (only when we have one). */
const logoFor: Record<string, string> = {
"Kimi": "logos/kimi.svg",
"智谱 GLM": "logos/zhipu.svg",
"DeepSeek": "logos/deepseek.svg",
"OpenCodeGo": "logos/opencode.svg",
"MiniMax": "logos/minimax.svg",
"基元律动": "logos/tokenrhythm.svg",
};
export default function Footer() {
const { t } = useLang();
return (
<footer className="border-t border-border py-12">
<div className="container-page space-y-8">
{/* 推荐链接 */}
<div>
<h3 className="mb-4 text-sm font-semibold text-muted-foreground">
{t.footer.recommend}
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{recommendLinks.map((link) => {
const logo = logoFor[link.name];
const text = t.footer.recommendText[link.name] ?? "";
return (
<a
key={link.name}
href={link.url}
target="_blank"
rel="noreferrer"
className="group flex items-start gap-3 rounded-2xl border border-border bg-card p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40"
style={{ boxShadow: "var(--shadow-soft)" }}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-white">
{logo ? (
<img
src={asset(logo)}
alt={`${link.name} logo`}
className="h-7 w-7 object-contain"
/>
) : (
<span className="text-base font-bold text-muted-foreground">
{link.name.slice(0, 1)}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold tracking-tight group-hover:text-primary">
{link.name}
</div>
{text && (
<p className="mt-1 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
{text}
</p>
)}
</div>
</a>
);
})}
</div>
</div>
{/* 版权与开源 */}
<div className="flex flex-col gap-2 border-t border-border pt-6 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<div>
© 2026 Kimi Switch ·{" "}
<a
href="https://github.com/billowliu2/KimiSwitch"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
GitHub
</a>{" "}
·{" "}
<a
href="https://git.codingplan.site/admin/KimiCodeSwitch"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
{t.footer.mirror}
</a>{" "}
· MIT License
</div>
<div className="text-xs opacity-80">
{t.footer.creditBefore}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
kimicode-dashboard
</a>
{t.footer.creditAfter}
</div>
</div>
</div>
</footer>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { NavLink } from "react-router-dom";
import { useLang } from "../i18n";
import { asset } from "../lib/asset";
export default function Nav() {
const { lang, setLang, t } = useLang();
const links = [
{ to: "/", label: t.nav.home, end: true },
{ to: "/features", label: t.nav.features },
{ to: "/changelog", label: t.nav.changelog },
{ to: "/download", label: t.nav.download },
];
return (
<header className="sticky top-0 z-40 border-b border-border bg-background/70 backdrop-blur-md">
<div className="container-page flex h-14 items-center justify-between gap-4">
<NavLink
to="/"
className="flex shrink-0 items-center gap-2 text-sm font-semibold tracking-tight"
>
<img
src={asset("kimi.svg")}
alt="Kimi Switch logo"
className="h-7 w-7 rounded-lg"
/>
<span>Kimi Switch</span>
</NavLink>
<nav className="flex items-center gap-5 overflow-x-auto overflow-y-hidden text-sm text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{links.map((l) => (
<NavLink
key={l.to}
to={l.to}
end={l.end}
className={({ isActive }) =>
`relative shrink-0 pb-1 transition-colors ${
isActive ? "text-foreground" : "hover:text-foreground"
}`
}
>
{({ isActive }) => (
<>
{l.label}
<span
className={`absolute -bottom-1 left-0 h-0.5 bg-primary transition-all duration-300 ${
isActive ? "w-full" : "w-0"
}`}
/>
</>
)}
</NavLink>
))}
</nav>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setLang(lang === "zh" ? "en" : "zh")}
className="rounded-lg border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Switch language / 切换语言"
>
{lang === "zh" ? "EN" : "中文"}
</button>
<NavLink to="/download" className="btn-primary hidden text-xs sm:inline-flex md:text-sm">
{t.nav.downloadBtn}
</NavLink>
</div>
</div>
</header>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useInView } from "../hooks";
import type { ReactNode } from "react";
interface SectionShellProps {
id: string;
title: string;
subtitle?: string;
children: ReactNode;
}
export default function SectionShell({ id, title, subtitle, children }: SectionShellProps) {
const [ref, inView] = useInView<HTMLElement>();
return (
<section
ref={ref}
id={id}
aria-labelledby={`${id}-title`}
className={`py-20 transition-all duration-700 ease-out ${
inView
? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-6"
}`}
>
<div className="container-page">
<div className="mb-12 max-w-3xl">
<h2
id={`${id}-title`}
className="text-3xl font-bold tracking-[-0.03em] md:text-4xl"
>
{title}
</h2>
{subtitle && (
<p className="mt-3 text-base leading-relaxed text-muted-foreground">{subtitle}</p>
)}
</div>
{children}
</div>
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
export interface RecommendLink {
name: string;
url: string;
}
export const recommendLinks: RecommendLink[] = [
{
name: "Kimi",
url: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
},
{
name: "智谱 GLM",
url: "https://www.bigmodel.cn/invite?icode=s2o6ZfY5VjEPIJj05K%2BhW2czbXFgPRGIalpycrEwJ28%3D",
},
{
name: "DeepSeek",
url: "https://platform.deepseek.com",
},
{
name: "OpenCodeGo",
url: "https://opencode.ai/go?ref=DFCNADQCEM",
},
{
name: "MiniMax",
url: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
},
{
name: "基元律动",
url: "https://tokenrhythm.studio/i/rf_tr_v0QXuBTzyl3-bJOS2uFFPg6x",
},
];
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from "react";
/** Fires once when the element scrolls into view. */
export function useInView<T extends Element = Element>(
options: IntersectionObserverInit = { threshold: 0.15 },
) {
const ref = useRef<T | null>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || inView) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
}, options);
io.observe(node);
return () => io.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView]);
return [ref, inView] as const;
}
/** Animates a numeric value once `trigger` flips to true. */
export function useCountUp(target: number, trigger: boolean, durationMs = 900) {
const [value, setValue] = useState(0);
useEffect(() => {
if (!trigger) return;
const start = performance.now();
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
let raf = 0;
const tick = (now: number) => {
const p = Math.min(1, (now - start) / durationMs);
setValue(target * easeOut(p));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, trigger, durationMs]);
return value;
}
+754
View File
@@ -0,0 +1,754 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type Lang = "zh" | "en";
const zh = {
nav: {
home: "首页",
features: "功能",
showcase: "演示",
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.7.3",
},
hero: {
badge: "v0.7.3 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
subtitle:
"一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量与账单一目了然。",
download: "免费下载",
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "5910", label: "模型价格库" },
{ value: "100%", label: "开源免费" },
],
},
features: {
title: "核心功能",
subtitle: "从多供应商管理到用量监控,一站搞定。",
items: [
{
id: "multi-provider",
title: "多供应商管理",
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,一键添加与切换。",
img: "screenshots/preset-picker.png",
},
{
id: "custom-provider",
title: "自定义供应商",
desc: "自定义 Base URL / API Key / 模型映射,支持 JSON 高级编辑。",
img: "screenshots/edit-provider-basic-managed.png",
},
{
id: "usage",
title: "用量与账单监控",
desc: "套餐类 5 小时 / 每周用量 + 按量余额,models.dev 真实价格计算成本,卡片直显。",
img: "screenshots/dashboard-light.png",
},
{
id: "sessions",
title: "会话管理",
desc: "跨供应商浏览、搜索与恢复历史会话。",
img: "screenshots/sessions-light.png",
},
{
id: "kimi-auth",
title: "Kimi 一键授权登录",
desc: "应用内置设备码授权(等同 kimi login),浏览器授权即用,15 分钟过期自动续期。",
img: "screenshots/kimi-oauth-dialog.png",
},
{
id: "usage-config",
title: "配置用量查询",
desc: "全屏配置页:NewAPI 中转站模板、超时时间、自动刷新间隔、测试查询。",
img: "screenshots/usage-config-page.png",
},
{
id: "model-discovery",
title: "智能模型发现",
desc: "模型能力 / 上下文大小自动从 models.dev 同步,构建时自动拉取,5910 模型覆盖。",
img: "screenshots/kimi-cli-select-model.png",
},
{
id: "auto-update",
title: "自动更新",
desc: "自动检测新版本,一键下载安装,始终保持最新。",
img: "screenshots/settings.png",
},
],
},
showcase: {
title: "界面演示",
subtitle: "真实界面截图,所见即所得。",
items: [
{
src: "screenshots/usage-config-page.png",
title: "配置用量查询",
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询一屏搞定",
},
{
src: "screenshots/preset-picker.png",
title: "预设供应商",
desc: "22 个预设开箱即用,一键添加常用厂商",
},
{
src: "screenshots/kimi-oauth-dialog.png",
title: "Kimi 授权登录",
desc: "设备码授权等同 kimi login,过期自动续期",
},
{
src: "screenshots/dashboard-daily-detail.png",
title: "用量明细",
desc: "按天查看 Token 与费用走势",
},
{
src: "screenshots/sessions-light.png",
title: "会话管理",
desc: "跨供应商浏览与恢复历史会话",
},
{
src: "screenshots/kimi-plan-quota-card.png",
title: "套餐余量卡",
desc: "5 小时 / 每周额度直读,余量一眼可见",
},
],
},
performance: {
title: "轻量原生",
subtitle: "Tauri + Rust 内核,WebView2 前端,常驻托盘零负担。",
metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "程序空闲态工作集内存" },
{ target: 34, suffix: " MB", label: "Private WS", desc: "独占内存(WebView2 共享部分扣除)" },
{ target: 0.3, decimals: 1, suffix: "%", label: "CPU 空闲", desc: "无活跃任务时几乎零占用" },
],
lightCaption: "浅色主题",
darkCaption: "深色主题",
},
changelog: {
title: "更新日志",
subtitle: "每个版本的重要变更记录。",
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
version: "v0.7.3",
date: "2026-08-07",
items: [
"高级设置新增 Kimi Code WebUI 应用内嵌窗口:新版 Web 界面以独立顶层窗口打开(单例、可缩放、居中),不再跳转外部浏览器",
"WebUI 两个入口并存:应用内独立窗口 / 系统浏览器(复用已运行的 kimi web 服务器,不重复拉起)",
"服务器进程自动管理:自己启动的服务器随窗口关闭/应用退出清理,不留残留;窗口创建失败不再泄漏进程",
"最小化窗口再次打开可恢复;发布说明文案同步更新",
],
},
{
version: "v0.7.2",
date: "2026-08-07",
items: [
"「子代理设置」升级为「高级设置」:聚合子代理模型指定、实验功能开关与 WebUI 快捷入口",
"新增 Kimi Code WebUI 快捷打开:高级设置页可将新版 Web 界面以独立窗口在应用内打开,也可在系统浏览器打开(kimi-code 0.33+)",
"子代理模型指定(实验功能):设置中为子代理选择次主力模型,子代理不再默认继承主模型",
"实验开关改为滑动开关,修复开启态因样式缺失而不可见的问题",
"兼容 kimi-code 0.33+(v2 引擎):循环控制改用新键 max_attempts_per_step,旧配置自动迁移",
"models.dev 模型列表与价格数据同步更新",
"官网企业版视觉升级;出品公司标识统一为 codingplan.site",
"macOS 安装优化:Release 附带 install-macos.sh 一键脚本,自动清除下载隔离,免去手动右键打开",
],
},
{
version: "v0.7.1",
date: "2026-08-05",
items: [
"实验功能设置新增「子代理模型(次主力模型)」配置:选择后子代理绑定该模型,不再继承主模型",
"仪表盘用量统计识别子代理请求(__secondary__)独立展示,并缓存次主力模型定价",
"自定义网关模型按官方同名模型跨 provider 匹配价格,不再落入兜底估算",
],
},
{
version: "v0.7.0",
date: "2026-08-02",
items: [
"订阅套餐模型计价修正:零价条目按官方付费模型计价(zhipuai-coding-plan/glm-5.2 按 zhipuai/glm-5.2 牌价)",
"-highspeed / -free 派生变体自动回退到基础模型价格",
"Kimi For Coding 套餐无牌价时按旗舰模型 kimi-k3 价格估算用量",
"设置页:识图(image_in)能力可手动编辑,原生多模态模型不再提示 kimi-eyes 插件",
"修复仅单个套餐档位(如 5 小时)时用量卡片不显示的问题",
"kimi usages 响应异常时透出原始返回,便于对照接口排查",
"models.dev 价格快照同步",
"关于页新增官方网站链接",
"设置-关于页新增 Kimi Eyes 识图插件站链接",
],
},
{
version: "v0.6.9",
date: "2026-08-01",
items: [
"最近请求新增缓存命中率列,每次请求命中一目了然",
"中文模式费用按最新汇率换算人民币(¥)显示,英文模式保持美元",
"仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪",
],
},
{
version: "v0.6.7",
date: "2026-08-01",
items: [
"应用内置 Kimi 授权登录(Device Code Flow,等同 kimi login):浏览器授权即用,无需命令行",
"授权后凭据自动写入,15 分钟过期自动续期,用量查询直接可用",
"修复外链打开问题:授权页 / 设置页超链接统一走 Rust 侧 opener 命令(更可靠)",
],
},
{
version: "v0.6.8",
date: "2026-08-01",
items: [
"仪表盘加载提速:models.dev 快照移出主 bundle,异步加载不阻塞首帧",
"Rust 价格索引启动时后台预热;get_summary 扫描结果 8 秒缓存",
"k3-256k 按 k3 牌价计费",
"官网更新:Kimi 风格重设计、中英文切换、更新日志同步 GitHub Releases、三平台下载",
],
},
{
version: "v0.6.6",
date: "2026-08-01",
items: [
"模型价格对标 models.dev,覆盖 5910 个模型",
"新增基元律动供应商与推荐链接",
"设置页文案与参考来源说明优化",
],
},
{
version: "v0.6.5",
date: "2026-08-01",
items: [
"更新检查优化:私有仓库优先,GitHub 回退,8 秒超时",
"修复按量余额显示",
],
},
{
version: "v0.6.4",
date: "2026-08-01",
items: [
"新增套餐账单查询(5 小时 / 每周用量卡片直显)",
"OpenCode Zen 预设补充推荐链接",
],
},
{
version: "v0.6.3",
date: "2026-07-30",
items: [
"添加默认供应商支持,新增 StepFun Plan 套餐预设",
"供应商编辑页新增 API Key 获取链接(支持推广链接)",
"修复链接无法打开浏览器的问题",
],
},
{
version: "v0.6.2",
date: "2026-07-30",
items: [
"仪表盘布局重构:每日用量趋势折线图(Token / 缓存命中率 / 请求数)",
"热力图独立整行,格子放大",
"新增 OpenCode Go / Zen 预设",
],
},
{
version: "v0.6.0",
date: "2026-07-29",
items: [
"预设供应商与供应商账单查询上线",
"多平台打包:macOS / Linux / Windows CI 同步发布",
],
},
{
version: "v0.5.2",
date: "2026-07-29",
items: ["用量趋势改为 tab 容器,新增供应商模型两级下钻"],
},
{
version: "v0.5.1",
date: "2026-07-29",
items: ["窗口与托盘行为优化;模型表格与能力编辑 UI 打磨"],
},
],
},
privacy: {
title: "你的数据,只在你的电脑上",
subtitle: "三句话讲清楚隐私与开源承诺。",
items: [
{
id: "local",
title: "本地存储",
desc: "配置与用量仅存本机 ~/.kimi-switch 与 config.toml,绝不上传。",
},
{
id: "direct",
title: "官方直连",
desc: "账单查询直连各厂商官方 API,无中间商、无统计埋点。",
},
{
id: "open",
title: "开源可审计",
desc: "MIT 协议全量开源(GitHub),欢迎审阅与共建。",
},
],
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.3 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。",
ready: "已发布",
wip: "开发中",
githubBtn: "GitHub 下载",
mirrorBtn: "镜像下载",
items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 install-macos.sh 后运行 bash install-macos.sh 一键安装" },
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
],
},
cta: {
title: "现在就把所有供应商,放进一个应用",
subtitle: "免费 · 开源 MIT · 一分钟完成安装",
download: "免费下载",
source: "GitHub 源码",
},
seo: {
home: {
title: "Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然",
description:
"Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
},
features: {
title: "核心功能 - Kimi Switch",
description:
"Kimi Switch 核心功能:多供应商管理、用量与账单监控、Kimi 一键授权登录、智能模型发现与自动更新,22 个预设开箱即用。",
},
changelog: {
title: "更新日志 - Kimi Switch",
description:
"Kimi Switch 更新日志:每个版本的重要变更记录,同步自 GitHub Releases。",
},
download: {
title: "下载 - Kimi Switch",
description:
"下载 Kimi Switch:Windows .msi、macOS .dmg、Linux .deb / .AppImage / .rpm,免费开源 MIT。",
},
},
footer: {
recommend: "推荐供应商",
mirror: "国内镜像",
creditBefore: "仪表盘与会话管理功能基于 ",
creditAfter: " (MIT, © JochenYang) 移植",
recommendText: {
Kimi: "完成 Kimi 注册,你我都能 100% 拿奖,最高可得 1 年会员等值权益",
"智谱 GLM":
"通过我的邀请链接注册 BigModel.cn,即可获得 2000 万 Tokens 大礼包,畅享新一代旗舰模型 GLM-5.2",
DeepSeek: "",
OpenCodeGo: "好友订阅后,您可获得 $5,对方也可获得 $5。",
MiniMax:
"MiniMax Token Plan:订阅套餐解锁最新模型,好友订阅享 9 折 + Builder 权益,邀请人得 10% 返利",
基元律动: "",
} as Record<string, string>,
},
};
export type Dict = typeof zh;
const en: Dict = {
nav: {
home: "Home",
features: "Features",
showcase: "Demo",
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.7.3",
},
hero: {
badge: "v0.7.3 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
subtitle:
"Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance.",
download: "Free Download",
source: "View Source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "5910", label: "Model price DB" },
{ value: "100%", label: "Open source" },
],
},
features: {
title: "Core Features",
subtitle: "From multi-provider management to usage monitoring, all in one place.",
items: [
{
id: "multi-provider",
title: "Multi-Provider Management",
desc: "22 presets including Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm. Add and switch in one click.",
img: "screenshots/preset-picker.png",
},
{
id: "custom-provider",
title: "Custom Providers",
desc: "Custom Base URL, API Key and model mapping, with advanced JSON editing.",
img: "screenshots/edit-provider-basic-managed.png",
},
{
id: "usage",
title: "Usage & Billing",
desc: "5-hour / weekly plan quotas plus pay-as-you-go balance, priced with real models.dev rates and shown on cards.",
img: "screenshots/dashboard-light.png",
},
{
id: "sessions",
title: "Session Manager",
desc: "Browse, search and resume past sessions across providers.",
img: "screenshots/sessions-light.png",
},
{
id: "kimi-auth",
title: "One-Click Kimi Login",
desc: "Built-in device-code authorization (same as kimi login). Authorize in the browser; 15-minute expiry auto-renews.",
img: "screenshots/kimi-oauth-dialog.png",
},
{
id: "usage-config",
title: "Usage Query Setup",
desc: "Full-page config: NewAPI relay templates, timeout, auto-refresh interval, and a test query.",
img: "screenshots/usage-config-page.png",
},
{
id: "model-discovery",
title: "Smart Model Discovery",
desc: "Model capabilities and context sizes sync from models.dev at build time, covering 5910 models.",
img: "screenshots/kimi-cli-select-model.png",
},
{
id: "auto-update",
title: "Auto Updates",
desc: "Automatically detects new versions and installs them in one click.",
img: "screenshots/settings.png",
},
],
},
showcase: {
title: "Screenshots",
subtitle: "Real screenshots from the app. What you see is what you get.",
items: [
{
src: "screenshots/usage-config-page.png",
title: "Usage Query Setup",
desc: "NewAPI relay templates, timeout and auto-refresh, plus a test query, all on one page",
},
{
src: "screenshots/preset-picker.png",
title: "Provider Presets",
desc: "22 presets ready out of the box, add popular vendors in one click",
},
{
src: "screenshots/kimi-oauth-dialog.png",
title: "Kimi Authorization",
desc: "Device-code flow identical to kimi login, auto-renews on expiry",
},
{
src: "screenshots/dashboard-daily-detail.png",
title: "Usage Details",
desc: "Daily token and cost trends",
},
{
src: "screenshots/sessions-light.png",
title: "Session Manager",
desc: "Browse and resume past sessions across providers",
},
{
src: "screenshots/kimi-plan-quota-card.png",
title: "Plan Quota Card",
desc: "5-hour / weekly quotas at a glance",
},
],
},
performance: {
title: "Lightweight & Native",
subtitle: "Tauri + Rust core with a WebView2 frontend. Zero tray overhead.",
metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "Idle working-set memory" },
{ target: 34, suffix: " MB", label: "Private WS", desc: "Private memory (shared WebView2 excluded)" },
{ target: 0.3, decimals: 1, suffix: "%", label: "Idle CPU", desc: "Nearly zero when idle" },
],
lightCaption: "Light theme",
darkCaption: "Dark theme",
},
changelog: {
title: "Changelog",
subtitle: "Notable changes in every release.",
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
version: "v0.7.3",
date: "2026-08-07",
items: [
"Advanced Settings gains an embedded Kimi Code WebUI window: the new Web UI opens in an independent top-level window (singleton, resizable, centered) instead of the external browser",
"Two WebUI entries coexist: in-app independent window / system browser (reuses an already-running kimi web server, no duplicate spawn)",
"Server process is managed automatically: servers we start are cleaned up when the window closes or the app exits; a failed window creation no longer leaks the process",
"Re-opening focuses and restores a minimized window; release notes wording synced",
],
},
{
version: "v0.7.2",
date: "2026-08-07",
items: [
"Subagent settings upgraded to an Advanced Settings page: subagent model assignment, experimental toggles and a WebUI quick launcher",
"New Kimi Code WebUI quick open: open the new Web UI in an independent in-app window from Advanced Settings, or in your system browser (kimi-code 0.33+)",
"Subagent model assignment (experimental): pick a secondary model for subagents so they no longer inherit the main model by default",
"Experiment toggle converted to a sliding switch, fixing the invisible enabled state",
"Compatible with kimi-code 0.33+ (v2 engine): loop control now writes max_attempts_per_step with auto-migration",
"models.dev model list and pricing snapshot synced",
"Enterprise-style website refresh; publisher identity unified as codingplan.site",
"macOS install polish: releases now ship install-macos.sh, a one-click script that clears the download quarantine",
],
},
{
version: "v0.7.1",
date: "2026-08-05",
items: [
"Experimental settings gain a secondary-model picker for subagents, decoupling them from the main model",
"Dashboard usage stats identify subagent requests (__secondary__) and show them separately with cached secondary-model pricing",
"Custom gateway models match prices against the same-named official model across providers",
],
},
{
version: "v0.7.0",
date: "2026-08-02",
items: [
"Plan-entry pricing fix: zero-price plan listings bill at the official priced model (zhipuai-coding-plan/glm-5.2 → zhipuai/glm-5.2)",
"-highspeed / -free derived variants fall back to the base model price",
"Kimi For Coding (no list price) estimates usage at the flagship kimi-k3 rate",
"Settings: image_in capability is now manually editable; native multimodal models no longer show the kimi-eyes plugin hint",
"Fix single-tier plans (e.g. 5-hour only) showing no usage card",
"kimi usages failure now exposes the raw response for easier debugging",
"models.dev price snapshot sync",
"About page gains an official website link",
"Settings-About page gains a Kimi Eyes plugin site link",
],
},
{
version: "v0.6.9",
date: "2026-08-01",
items: [
"Recent requests gain a cache-hit-rate column, per request at a glance",
"Chinese UI now shows costs in CNY (latest exchange rate); English keeps USD",
"Dashboard speedup: memoized price resolution drops the scan from seconds to milliseconds; load time / payload tracking in the top-right corner",
],
},
{
version: "v0.6.8",
date: "2026-08-01",
items: [
"Dashboard speedup: models.dev snapshot moved out of the main bundle, async load without blocking first paint",
"Rust price index warmed in the background at startup; get_summary scan cached for 8s",
"k3-256k billed at the k3 list price",
"Website refresh: Kimi-style redesign, zh/en switch, changelog synced from GitHub Releases, downloads for all three platforms",
],
},
{
version: "v0.6.7",
date: "2026-08-01",
items: [
"Built-in Kimi device-code login (same as kimi login): authorize in the browser, auto-renews on expiry",
"External links now open via a unified Rust command, fixing broken jumps",
],
},
{
version: "v0.6.6",
date: "2026-08-01",
items: [
"Model pricing aligned with models.dev, covering 5910 models",
"New TokenRhythm provider with referral link",
"Settings copy and source attribution polish",
],
},
{
version: "v0.6.5",
date: "2026-08-01",
items: [
"Update check rework: private mirror first, GitHub fallback, 8s timeout",
"Fix pay-as-you-go balance display",
],
},
{
version: "v0.6.4",
date: "2026-08-01",
items: [
"Plan billing queries (5-hour / weekly usage shown on cards)",
"OpenCode Zen preset gains a referral link",
],
},
{
version: "v0.6.3",
date: "2026-07-30",
items: [
"Default provider support and a new StepFun Plan preset",
"API Key helper links on the provider edit page (referral links supported)",
"Fix links failing to open the browser",
],
},
{
version: "v0.6.2",
date: "2026-07-30",
items: [
"Dashboard layout rework: daily trend line chart (tokens / cache hit rate / requests)",
"Heatmap gets its own full-width row with larger cells",
"New OpenCode Go / Zen presets",
],
},
{
version: "v0.6.0",
date: "2026-07-29",
items: [
"Provider presets and per-provider billing queries launch",
"Multi-platform packaging: macOS / Linux / Windows CI releases",
],
},
{
version: "v0.5.2",
date: "2026-07-29",
items: ["Usage trends moved into a tab container with provider-model two-level drill-down"],
},
{
version: "v0.5.1",
date: "2026-07-29",
items: ["Window and tray behavior polish; model table and capability editor UI refinements"],
},
],
},
privacy: {
title: "Your Data Stays on Your Machine",
subtitle: "Our privacy and open-source commitments in three sentences.",
items: [
{
id: "local",
title: "Local Storage",
desc: "Configs and usage data live only in ~/.kimi-switch and config.toml. Nothing is uploaded.",
},
{
id: "direct",
title: "Direct Connections",
desc: "Billing queries go straight to each vendor's official API. No middlemen, no tracking.",
},
{
id: "open",
title: "Open & Auditable",
desc: "Fully open source under MIT on GitHub. Reviews and contributions welcome.",
},
],
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.7.3 · Windows, macOS and Linux now released",
autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.",
ready: "Available",
wip: "In development",
githubBtn: "Download from GitHub",
mirrorBtn: "China mirror",
items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned: download install-macos.sh and run bash install-macos.sh for one-click install" },
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
],
},
cta: {
title: "Bring every provider into one app",
subtitle: "Free · Open source MIT · One-minute install",
download: "Free Download",
source: "GitHub Source",
},
seo: {
home: {
title: "Kimi Switch — Unified Management for AI Providers · Usage & Billing",
description:
"Kimi Switch / KimiCodeSwitch — a Windows desktop manager for multiple LLM providers. Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance. Open source MIT.",
},
features: {
title: "Core Features - Kimi Switch",
description:
"Kimi Switch core features: multi-provider management, usage & billing, one-click Kimi login, smart model discovery and auto updates. 22 presets out of the box.",
},
changelog: {
title: "Changelog - Kimi Switch",
description:
"Kimi Switch changelog: notable changes in every release, synced from GitHub Releases.",
},
download: {
title: "Download - Kimi Switch",
description:
"Download Kimi Switch: Windows .msi, macOS .dmg, Linux .deb / .AppImage / .rpm. Free and open source under MIT.",
},
},
footer: {
recommend: "Recommended Providers",
mirror: "China mirror",
creditBefore: "Dashboard & session management adapted from ",
creditAfter: " (MIT, © JochenYang)",
recommendText: {
Kimi: "Sign up with my invite link and we both win rewards, up to a 1-year membership equivalent",
"智谱 GLM":
"Register on BigModel.cn via my invite link and get a 20M token gift pack for the new GLM-5.2 flagship",
DeepSeek: "",
OpenCodeGo: "When a friend subscribes, you get $5 and they get $5.",
MiniMax:
"MiniMax Token Plan: subscribe to unlock the latest models. Friends get 10% off, inviters get 10% rebate",
基元律动: "",
} as Record<string, string>,
},
};
const dicts: Record<Lang, Dict> = { zh, en };
const STORAGE_KEY = "kimi-switch-site-lang";
function detect(): Lang {
if (typeof window === "undefined") return "zh";
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "zh" || saved === "en") return saved;
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
}
const LangContext = createContext<{
lang: Lang;
setLang: (l: Lang) => void;
t: Dict;
}>({ lang: "zh", setLang: () => {}, t: zh });
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(detect);
const setLang = (l: Lang) => {
setLangState(l);
localStorage.setItem(STORAGE_KEY, l);
};
useEffect(() => {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
}, [lang]);
return (
<LangContext.Provider value={{ lang, setLang, t: dicts[lang] }}>
{children}
</LangContext.Provider>
);
}
export function useLang() {
return useContext(LangContext);
}
+139
View File
@@ -0,0 +1,139 @@
@import "@fontsource/geist/400.css";
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist/700.css";
@import "@fontsource/geist/800.css";
@import "@fontsource/fira-code/400.css";
@import "@fontsource/fira-code/500.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Design tokens — Kimi brand: neutral gray + blue accent (matches the app UI).
Light is the default; dark follows prefers-color-scheme. */
@layer base {
:root {
--background: 240 11% 97%;
--foreground: 240 9% 11%;
--card: 0 0% 100%;
--card-foreground: 240 9% 11%;
--muted: 240 6% 94%;
--muted-foreground: 220 9% 46%;
--accent: 240 6% 94%;
--accent-foreground: 240 9% 11%;
--border: 240 6% 90%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--ring: 221 83% 53%;
/* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */
--shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05),
0 8px 24px hsl(240 9% 11% / 0.06);
--shadow-lift: 0 1px 2px hsl(240 9% 11% / 0.05),
0 12px 36px hsl(240 9% 11% / 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--background: 222 47% 6%;
--foreground: 220 20% 92%;
--card: 222 32% 10%;
--card-foreground: 220 20% 92%;
--muted: 222 25% 14%;
--muted-foreground: 220 12% 62%;
--accent: 222 25% 14%;
--accent-foreground: 220 20% 92%;
--border: 222 22% 17%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--ring: 217 91% 60%;
--shadow-soft: 0 1px 2px hsl(222 47% 2% / 0.5),
0 8px 24px hsl(222 47% 2% / 0.45);
--shadow-lift: 0 1px 2px hsl(222 47% 2% / 0.5),
0 12px 36px hsl(222 47% 2% / 0.55);
}
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground antialiased;
}
/* 科技风底纹:固定网格线,极低透明度,不干扰阅读 */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(to right, hsl(var(--primary) / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--primary) / 0.04) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 30%, transparent 75%);
}
#root {
position: relative;
z-index: 1;
}
/* 选中色与键盘焦点(无障碍) */
::selection {
background: hsl(var(--primary) / 0.16);
}
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* 展示标题:更紧字距 + 防孤词 */
h1,
h2 {
text-wrap: balance;
letter-spacing: -0.02em;
}
}
@layer components {
.container-page {
@apply mx-auto w-full max-w-6xl px-6;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium transition-all duration-200;
}
.btn-primary {
@apply btn bg-primary text-primary-foreground hover:bg-primary/90 hover:-translate-y-px active:translate-y-0 active:scale-[0.98];
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
0 6px 16px hsl(var(--primary) / 0.22);
}
.btn-primary:hover {
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
0 10px 24px hsl(var(--primary) / 0.3);
}
.btn-ghost {
@apply btn border border-border bg-card/60 text-foreground hover:border-foreground/25 hover:bg-accent active:scale-[0.98];
}
.card {
@apply rounded-2xl border border-border bg-card p-6;
box-shadow: var(--shadow-soft);
transition:
box-shadow 0.3s ease,
transform 0.3s ease,
border-color 0.3s ease;
}
.card:hover {
box-shadow: var(--shadow-lift);
}
/* 截图容器:限定最大宽高,避免撑满页面 */
.screenshot {
@apply overflow-hidden rounded-2xl border border-border bg-muted;
box-shadow: var(--shadow-lift);
}
.screenshot > img {
@apply block h-auto w-full;
}
/* 图标容器:统一的蓝色徽标容器 */
.icon-chip {
@apply flex h-10 w-10 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary;
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Resolve a public-asset path against Vite's base URL.
* All site assets live under `/KimiSwitch/` in dev and on GitHub Pages,
* so bare absolute paths like `/logos/x.svg` would 404 — always go through here.
*/
export const asset = (p: string) =>
`${import.meta.env.BASE_URL}${p.replace(/^\/+/, "")}`;
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more