Compare commits
13
Commits
v0.6.0-rc1
..
v0.6.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2464500ef5 | ||
|
|
f60305fbeb | ||
|
|
8d734a6507 | ||
|
|
0a36cd0ec4 | ||
|
|
6b867ff6fc | ||
|
|
c6da421d2f | ||
|
|
ace9842556 | ||
|
|
12dcb17771 | ||
|
|
02555daff4 | ||
|
|
b9ea6426d8 | ||
|
|
0889eeba24 | ||
|
|
f35924e15c | ||
|
|
bee981aefa |
No files matched your search
@@ -0,0 +1 @@
|
||||
13
|
||||
@@ -0,0 +1,125 @@
|
||||
# 工作交接:账单查询功能(2026-08-01)
|
||||
|
||||
> 新会话续作入口:先读本文件 + `docs/USAGE-QUERY-ADAPTATION.md`(调研文档),然后按「二、下一步」继续。
|
||||
|
||||
## 一、当前进度总览
|
||||
|
||||
**已提交并发布 v0.6.4**:
|
||||
- `ace9842` — feat: 新增部分套餐账单查询(v0.6.4)(26 文件 +1822/-60,含以下全部 6 个批次 + 版本号三处 0.6.4)
|
||||
- 已推送 origin(git.codingplan.site):分支 `Dev_20260801` + tag `v0.6.4`
|
||||
- 已推送 GitHub(billowliu2/KimiSwitch):tag `v0.6.4`(走 127.0.0.1:7897 代理;git 全局配置 `http.proxy` 指向它,代理关闭时 push 会报 schannel SSL 握手错误,可用 `git -c http.proxy= push` 绕过硬连 origin)
|
||||
- GitHub Actions Release workflow 已触发(三平台构建 → Release draft 自动上传 msi/dmg/AppImage/deb/rpm)
|
||||
- 本地 0.6.4 安装包:`src-tauri/target/release/bundle/{nsis,msi}/`
|
||||
|
||||
**已提交并发布 v0.6.5**:
|
||||
- `feat: 优化更新检查逻辑 + 修复按量余额显示(v0.6.5)`
|
||||
- 检查更新优先私有库(git.codingplan.site),8s 超时自动回退 GitHub
|
||||
- 设置-关于:合并移植声明为一句、新增 cc-switch 链接(openUrl + window.open 兜底)、移除仪表盘/会话页脚重复声明
|
||||
- 修复按量余额不显示:UsageFooter isPlan 判定增加配额字段(total/used)条件——余额条目(DeepSeek/StepFun/SiliconFlow/Novita/Kimi 开放平台)现在正确显示 `💰 ¥x.xx`,套餐类(有 total/used)不受影响。实测 DeepSeek 官方接口返回 200 + balance_infos
|
||||
- 两仓库 main + Dev_20260801 + tag v0.6.5 已同步
|
||||
|
||||
**历史提交**:`12dcb17` — docs: 账单查询适配调研 + OpenCode Zen 预设补推荐链接
|
||||
|
||||
**工作区未提交改动**——用户硬约束:**实现完成不提交,等用户逐批测试确认后才提交**。
|
||||
`git status` 实测清单(注意:`src-tauri/Cargo.toml` 现含**真实改动**——批次 4 新增 tokio 依赖行,另混有用户自己的行尾差异,提交时只保留 tokio 行):
|
||||
|
||||
```
|
||||
M docs/USAGE-QUERY-ADAPTATION.md M src/App.tsx
|
||||
M src-tauri/Cargo.toml(含 tokio 真实改动) M src/components/ProviderList.tsx
|
||||
M src-tauri/src/commands.rs M src/components/UsageFooter.tsx
|
||||
M src-tauri/src/db.rs M src/config/providerPresets.ts
|
||||
M src-tauri/src/kimi_code_io.rs M src/hooks/useConfig.ts
|
||||
M src-tauri/src/lib.rs M src/i18n/en.ts
|
||||
M src-tauri/src/models.rs M src/i18n/zh.ts
|
||||
M src-tauri/src/pi_io.rs M src/types/index.ts
|
||||
M src-tauri/src/services/balance.rs / coding_plan.rs / mod.rs
|
||||
?? docs/HANDOVER-usage-billing.md(本文件)
|
||||
?? src-tauri/src/oauth.rs
|
||||
?? src/components/UsageConfigModal.tsx
|
||||
?? src/lib/usage-display.ts
|
||||
```
|
||||
|
||||
### 批次 1:P0 `balance:kimi` + usageKinds 字段修复
|
||||
- `src-tauri/src/services/balance.rs` — `query_kimi`/`parse_kimi`(`api.moonshot.cn|ai/v1/users/me/balance`,CNY/USD 消歧)+ 5 单测
|
||||
- `src-tauri/src/services/mod.rs` — `UsageKind::BalanceKimi`("balance:kimi")、detect_provider 加 `api.moonshot.cn/.ai`
|
||||
- `src/config/providerPresets.ts` — union/SUPPORTED 加 `balance:kimi`,moonshot 预设挂 usageKinds
|
||||
- `src-tauri/src/models.rs` — **`usage_kinds` 加 `#[serde(rename = "usageKinds")]`**(修复历史 bug:Rust 序列化 snake_case、前端读 camelCase 导致所有账单入口不显示)
|
||||
|
||||
### 批次 2:A/B/C 三阶段(OAuth + 配置面板 + NewAPI)
|
||||
Rust:
|
||||
- `src-tauri/src/oauth.rs`(新增)— 读 `~/.kimi-code/credentials/kimi-code.json` access_token,30s 过期缓冲,**不做 refresh**(避免与 CLI 的 refresh_token 轮换竞态)
|
||||
- `src-tauri/src/models.rs` — `UsageConfig` struct(camelCase)+ `Provider.usage_config`(rename="usageConfig")
|
||||
- `src-tauri/src/services/balance.rs` — `query_newapi`/`parse_newapi`/`fetch_newapi_status`(NewAPI 中转站:`{base}/api/user/self` + Bearer accessToken + `New-Api-User` userId;`/api/status` 拉 quota_per_unit/货币符号,进程内缓存 5min;quota=0 视为无限额度)
|
||||
- `src-tauri/src/services/mod.rs` — `BalanceNewapi`("balance:newapi",ALL=10),`query_kind` 签名加 `usage_config: Option<&UsageConfig>`
|
||||
- `src-tauri/src/commands.rs` — `usage_config_key` + merge_usage_kinds 同时 merge usage_config(SQLite `usage_config:<name>`);save 时持久化;`query_provider_usage`:usageConfig.enabled=false 拒绝、templateType=="newapi" 走 NewAPI 分支、managed 供应商读 OAuth token(api_key 优先,OAuth 兜底)
|
||||
- `src-tauri/src/lib.rs` — 注册 `pub mod oauth;`
|
||||
- `src-tauri/src/db.rs` / `kimi_code_io.rs` / `pi_io.rs` — Provider 构造点补 `usage_config: None`(8 处)
|
||||
|
||||
前端:
|
||||
- `src/types/index.ts` — `UsageConfig` 接口 + `Provider.usageConfig`
|
||||
- `src/components/UsageConfigModal.tsx`(新增)— 配置面板(批次 3 已改全屏)
|
||||
- `src/components/UsageFooter.tsx` — 拆 compact(卡片右侧:主摘要 + `x 分钟前` + 刷新,含自动查询 interval)/ detail(底部:多档明细 + 错误/重试);模块级缓存 + 3 并发信号量
|
||||
- `src/components/ProviderList.tsx` — BarChart3 配置入口按钮;紧凑用量行嵌入按钮组、位于「切换使用」左侧(cc-switch 布局)
|
||||
- `src/App.tsx` — `usageConfigProvider` 状态 + `handleSaveUsageConfig` + modal 挂载
|
||||
- `src/i18n/zh.ts` / `en.ts` — 新增约 34 个 key
|
||||
|
||||
### 批次 3:全屏配置页面 + 超时配置
|
||||
- `src/components/UsageConfigModal.tsx` — **居中弹窗 → 全屏页面**(参考 cc-switch FullScreenPanel):`fixed inset-0 flex flex-col bg-app`,header 64px(ArrowLeft 返回 + 标题),内容 `max-w-3xl` 居中滚动,footer 固定底部;ESC 关闭但输入框聚焦时不关(isTextEditableTarget)
|
||||
- 新增页面元素:**支持的变量区**(`{{baseUrl}}`/`{{apiKey}}` 脱敏 + 眼睛切换明文)、**超时时间输入**(与自动查询间隔并排两列)
|
||||
- 超时接线:`UsageConfig.timeout_seconds`(models.rs camelCase)+ `types/index.ts timeoutSeconds`;`balance.rs::get_json` 及全部 `query_*`、`fetch_newapi_status`、`coding_plan` 3 函数加 `timeout: Duration` 参数;`query_kind` 从 usage_config 计算(0/None 回退 8s),`commands.rs` 无改动
|
||||
- i18n:`usageTimeout`/`usageTimeoutHint`/`usageSupportedVars`/`usageBack`(zh+en)
|
||||
- **测试查询体验修复**(2026-08-01 用户反馈「测试查询没有生效」):
|
||||
- 测试结果框改为 **sticky bottom-0**(在 body 滚动容器内),始终浮在 footer 上方,点了立即可见
|
||||
- 「自动检测 + 无识别类型」短路:handleTest 前置检查 detectedKinds.length===0,直接展示 `usageUnsupportedProvider` 中文提示,不发无效请求
|
||||
- 「未识别查询类型」区改为带「切换到 NewAPI 模板」快捷按钮的 amber 提示框(替换原灰色单行)
|
||||
- 新增 i18n:`usageNoKindsHint`/`usageSwitchToNewapi`/`usageUnsupportedProvider`
|
||||
|
||||
**验证(三批累计)**:`cargo test --lib` 46 全过、`cargo check` 干净、`tsc --noEmit` 零错误。
|
||||
**最新打包产物**(批次 3 + 体验修复 已交付用户测试):
|
||||
- `D:\AIGC\KimiSwitch\src-tauri\target\release\bundle\nsis\Kimi Switch_0.6.3_x64-setup.exe`
|
||||
- `D:\AIGC\KimiSwitch\src-tauri\target\release\bundle\msi\Kimi Switch_0.6.3_x64_en-US.msi`
|
||||
|
||||
### 批次 4(2026-08-01):OAuth refresh + 测试查询短路修复
|
||||
- **背景**:用户反馈「Kimi login 又失效」——根因是 access token 15 分钟过期且 refresh 未实现(§三.3 已拍板实现);refresh 端点与 client_id 已从 kimi.exe 二进制实测确认(`POST https://auth.kimi.com/api/oauth/token`,client_id `17e5f671-d194-4dfb-9706-5516cb48c098`,form-encoded grant_type=refresh_token)
|
||||
- `src-tauri/src/oauth.rs` — `get_valid_access_token()`:过期 → `tokio::sync::Mutex` 单 flight 锁内重读文件(CLI/其他等待者可能已刷新则直接采用)→ `refresh_credentials()` 调 token 端点 → 写回前再重读(CLI 若已轮换则采纳其 token,避免覆盖 CLI 的新 refresh_token)→ `merge_token_response()` 纯函数合并写回(保留未知字段)+ 3 个新单测
|
||||
- `src-tauri/src/commands.rs` — managed 分支改为 `get_valid_access_token().await`,错误信息直接用 refresh 的具体失败原因(含 invalid_grant 时提示重新 `kimi login`)
|
||||
- `src-tauri/Cargo.toml` — 新增 `tokio = { version = "1", features = ["sync"] }`(项目本无 tokio 直接依赖)
|
||||
- `src/components/UsageConfigModal.tsx` — 「无识别类型短路」加 `!isManaged` 守卫(managed 走 OAuth 与 detectedKinds 无关,防误伤)
|
||||
- **验证**:cargo test 48 过(+2 merge 用例)、tsc 零错误
|
||||
|
||||
### 批次 5(2026-08-01):用量显示本地化 + 按钮顺序调整
|
||||
- `src/lib/usage-display.ts`(新增)— `planLabel()`(five_hour→"5小时"/"5-hour"、weekly_limit→"7天"/"Weekly",其余专名透传)+ `localizeUsageError()`(Rust 英文错误串按前缀/正则映射到 i18n,未知错误透传保留诊断信息)
|
||||
- `src/components/UsageFooter.tsx` — compact/detail 的 tier 标签与错误显示接入两个 helper
|
||||
- `src/components/UsageConfigModal.tsx` — 测试结果的 planName/错误同样本地化;catch(瞬时失败)统一显示 usageNetworkError
|
||||
- `src/components/ProviderList.tsx` — BarChart3(配置用量查询)按钮从图标组首位移到 Activity(连通测试)与 Trash(删除)之间
|
||||
- i18n 新增:`usageTier5h`/`usageTierWeekly`/`usageErrNoKey`/`usageErrDisabled`/`usageErrLoginExpired`/`usageErrNoOauth`/`usageErrNewapiCreds`(zh+en)
|
||||
- **验证**:tsc 零错误(纯前端改动,Rust 未动)
|
||||
|
||||
### 批次 6(2026-08-01):测试查询「无结果」根因修复
|
||||
- **根因**:`App.tsx` 顶层 `if (loading || !config) return <loading 页>`——`handleTest` 先 `onSave` → `void save()` → `setLoading(true)` → App 整树换成 loading 页 → **modal 被卸载**;查询结果回来后 setState 打到已卸载实例(无效),save 结束 modal 全新重挂载(testResult=null)。自配置面板引入即存在,任何「先保存再查询」的路径都中招。
|
||||
- `src/hooks/useConfig.ts` — `refresh`/`save` 加 `{ silent?: boolean }` 选项:silent 时不翻 loading 页(不 setLoading)
|
||||
- `src/App.tsx` — `handleSaveUsageConfig` 改为 `return save({ silent: true })`;error 分支重试按钮 `onClick={refresh}` 改箭头包装(MouseEvent 会当 opts 传入)
|
||||
- `src/components/UsageConfigModal.tsx` — `onSave` 类型改 `Promise<void> | void`;`handleTest` **await onSave 完成后再 invoke 查询**(保证后端读到同一份配置)
|
||||
- `src/components/ProviderList.tsx` — **managed(OAuth 登录托管)供应商置顶**:组件内 `[...providers].sort()` 稳定排序,仅展示层,不改 config.toml/SQLite 存储顺序
|
||||
- **验证**:tsc 零错误
|
||||
|
||||
## 二、下一步
|
||||
|
||||
1. **等用户测批次 3 的包**(重点:卡片图表图标 → 全屏配置页 → 变量区/超时输入 → 测试查询 → 保存后紧凑行刷新)。用户反馈优先处理。
|
||||
2. **用户确认后才 `git commit`**(建议拆 3 个 commit 对应批次 1/2/3,提交信息中文 + scope 前缀;排除 `src-tauri/Cargo.toml` 行尾改动)。
|
||||
3. 后续候选(均待用户拍板,见 §三):OAuth refresh、NewAPI 面板自动探测、自定义 JS 提取器模板(P3)。
|
||||
|
||||
## 三、悬而未决(用户已知情,待决定)
|
||||
|
||||
1. **OpenCode Go 账单查询**:已实测全部端点 404(`/zen/go/v1/usages`、`/api/usage`、`/api/billing` 等 12+ 路径),cc-switch 源码也不支持 → **结论:无公开 API,不可查**。文档已记录。
|
||||
2. **Kimi login 配 API key**:可以,代码已支持(api_key 优先于 OAuth)。但 managed 供应商编辑页隐藏 key 输入框(`ProviderEdit.tsx:301` `{!provider.managed && ...}`)。选项:① 用户新建 kimi-coding 预设供应商填 key;② 改 UI 让 managed 显示可选 key 输入。
|
||||
3. **OAuth refresh**:**已实现**(批次 4)。过期自动用 refresh_token 换新并写回凭据文件;单 flight 锁 + 写回前重读防 CLI 竞态。已知边界:若 access token 未过期但被服务端吊销(罕见),401 不会触发重读重试——暂不处理。
|
||||
4. **NewAPI 面板自动探测**:打开配置面板时探测 `{base}/api/status` 含 `quota_per_unit` → 自动预选 NewAPI 模板。已提议,未确认。
|
||||
|
||||
## 四、关键环境信息
|
||||
|
||||
- 项目:`D:\AIGC\KimiSwitch`(Tauri 2 + React 18 + TS + Tailwind + Rust/rusqlite/reqwest),分支 `Dev_20260801`
|
||||
- 参考项目:`D:\AIGC\cc-switch`(MIT,账单查询功能源头;全屏面板参考其 `src/components/common/FullScreenPanel.tsx`)
|
||||
- 构建:`npm run tauri-build`(前后端一起,产出 msi+nsis);`cargo test --lib`(src-tauri 下);`npx tsc --noEmit`
|
||||
- 提交信息风格:中文 + scope 前缀(`docs:`/`build:`/`fix:`/`feat:`)
|
||||
- 用户 NewAPI 站点:`https://ai.codingplan.site`(quota_per_unit=500000,符号 ¤);Access Token 需网页后台生成(sk- 推理 key 查不了 `/api/user/self`,实测 401)
|
||||
@@ -0,0 +1,343 @@
|
||||
# 账单查询(用量查询)适配调研报告
|
||||
|
||||
> 调研日期:2026-07-31
|
||||
> 调研范围:`D:\AIGC\cc-switch`(参考实现)、`D:\AIGC\KimiSwitch`(当前项目)、OpenCode(sst/opencode)订阅套餐定义、Kimi / 智谱官方用量查询 API
|
||||
> 状态:调研完成,未动代码
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
- **KimiSwitch 的账单查询不是空白**:已移植 cc-switch 的「余额查询(Balance)+ 套餐查询(Coding Plan)」两大块,共 **8 种 `usageKinds`**,Rust 侧闭环 + `UsageFooter` 展示,错误通道、keep-last-good、缓存、`detect_provider` 自动识别等核心设计均与 cc-switch 对齐。
|
||||
- **OpenCode 本身不提供账单/额度查询**。它只负责"定义"供应商与订阅套餐的接入方式(provider id、base URL、认证、模型清单,数据源为 models.dev),"套餐还剩下多少"需要查各家官方 API——这正是 cc-switch(及 KimiSwitch 已移植部分)做的事。
|
||||
- **本次调研发现 2 个值得补的能力缺口**:
|
||||
1. **Kimi 开放平台余额查询**(`GET https://api.moonshot.cn/v1/users/me/balance`,Kimi 官方 2026-07 新增公开 API,cc-switch 尚未实现)→ **KimiSwitch 也缺**。
|
||||
2. **OpenCode Go 订阅套餐额度**(5h / 周 / 月 三窗口)→ 无公开查询 API,只有控制台,暂不可程序化。
|
||||
- 其余缺口(ZenMux / 火山方舟 / 智谱团队版 / JS 脚本引擎 / 官方 OAuth 订阅)为 cc-switch 独有能力,与"做好现有账单查询"的目标匹配度分层,见 §6 路线。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状盘点:KimiSwitch 已实现的账单查询
|
||||
|
||||
### 2.1 架构
|
||||
|
||||
```
|
||||
ProviderList (前端)
|
||||
└─ UsageFooter ── invoke("query_provider_usage", { agent, providerName, forceRefresh })
|
||||
└─ Rust commands.rs:590 ── services::query_kind(kind, base_url, api_key)
|
||||
├─ balance.rs (余额,5 家)
|
||||
└─ coding_plan.rs (套餐,3 家)
|
||||
```
|
||||
|
||||
- 前端**永不持有 API key、不直连 HTTP**;所有请求在 Rust 侧用 reqwest 完成(无浏览器 CORS 问题)。
|
||||
- `Provider.usageKinds`(前端 `src/config/providerPresets.ts` 预设字段)→ 保存时写入 SQLite settings `usage_kinds:<provider_name>`(`commands.rs:152`)→ 加载时 `merge_usage_kinds`(`commands.rs:33`),缺失时按 base_url host 自动 `detect_provider`。
|
||||
- TS/Rust 双端枚举防漂移:`SUPPORTED_USAGE_KINDS` 运行时断言(`providerPresets.ts:563`)检查预设的 `usageKinds` 都在 Rust `UsageKind` 覆盖内。
|
||||
|
||||
### 2.2 已有能力对照表
|
||||
|
||||
| UsageKind | 供应商 | 端点 | 认证 | 实现 |
|
||||
|---|---|---|---|---|
|
||||
| `balance:deepseek` | DeepSeek | `GET https://api.deepseek.com/user/balance` | Bearer | ✅ |
|
||||
| `balance:siliconflow` | SiliconFlow (.cn/.com) | `GET https://api.siliconflow.cn\|.com/v1/user/info` | Bearer | ✅ |
|
||||
| `balance:openrouter` | OpenRouter | `GET https://openrouter.ai/api/v1/credits` | Bearer | ✅ |
|
||||
| `balance:stepfun` | StepFun | `GET https://api.stepfun.com/v1/accounts` | Bearer | ✅ |
|
||||
| `balance:novita` | Novita AI | `GET https://api.novita.ai/v3/user/balance`(÷10000 转 USD) | Bearer | ✅ |
|
||||
| `plan:kimi_coding` | **Kimi For Coding** | `GET https://api.kimi.com/coding/v1/usages` | Bearer | ✅ |
|
||||
| `plan:zhipu` | **GLM Coding Plan**(bigmodel.cn / z.ai) | `GET {open.bigmodel.cn\|api.z.ai}/api/monitor/usage/quota/limit` | **Raw key(无 Bearer)** | ✅ |
|
||||
| `plan:minimax` | MiniMax Token Plan (.com/.io) | `GET https://api.minimaxi.com\|.io/v1/api/openplatform/coding_plan/remains` | Bearer | ✅ |
|
||||
|
||||
已有预设(`providerPresets.ts`):`kimi-coding`(`plan:kimi_coding`)、`zhipu-coding` / `zai-coding`(`plan:zhipu`)、`minimax` / `minimax-token-plan`(`plan:minimax`)、`deepseek` / `stepfun` / `siliconflow` / `novita` / `openrouter`(余额)。
|
||||
|
||||
### 2.3 统一返回契约(Rust → 前端)
|
||||
|
||||
`src-tauri/src/services/usage_types.rs`(camelCase serde):
|
||||
|
||||
```ts
|
||||
interface UsageData {
|
||||
planName?: string | null; // 套餐名:five_hour / weekly_limit
|
||||
remaining?: number | null; // 余额:金额(balance)或剩余百分比(plan)
|
||||
total?: number | null; // 总量(plan 恒为 100)
|
||||
used?: number | null; // 已用百分比 0-100(plan)
|
||||
unit?: string | null; // CNY / USD / %
|
||||
isValid?: boolean | null;
|
||||
resetsAt?: string | null; // ISO 8601 重置时间
|
||||
}
|
||||
interface UsageResult { success: boolean; data?: UsageData[] | null; error?: string | null; }
|
||||
```
|
||||
|
||||
错误通道语义(与 cc-switch 一致):`Err`(invoke reject)= 瞬时失败(网络/超时,前端重试 + 保留 last-good);`Ok(success:false)` = 确定性失败(无 key / 401 / 非 2xx / 坏 JSON,立即透出并清 last-good)。实现要点:**先 `bytes()` 再 `serde_json::from_slice`** 以区分读体失败(瞬时)与解析失败(确定性)。
|
||||
|
||||
---
|
||||
|
||||
## 3. OpenCode 如何"定义" Kimi / 智谱订阅套餐(重点)
|
||||
|
||||
### 3.1 OpenCode 的 provider 体系
|
||||
|
||||
OpenCode(sst/opencode)通过 `~/.config/opencode/opencode.json` 的 `provider` 段注册供应商,用 AI SDK + **Models.dev** 提供 75+ 提供商。一个"订阅套餐"在 OpenCode 里不是一个独立概念,而是**一组 provider 定义 + 模型引用**:
|
||||
|
||||
| provider id | 对应产品 | 默认 base URL(API 端点) | 说明 |
|
||||
|---|---|---|---|
|
||||
| `zhipuai` | **GLM Coding Plan**(订阅) | `https://open.bigmodel.cn/api/coding/paas/v4` | 套餐专属端点,与按量 PaaS 端点不同 |
|
||||
| `zai` | z.ai(按量) | `https://api.z.ai/api/paas/v4` | |
|
||||
| `kimi` | **Kimi For Coding**(订阅) | `https://api.kimi.com/coding/v1` | OpenAI 兼容;Anthropic 兼容为 `https://api.kimi.com/coding/` |
|
||||
| `moonshot` | Moonshot / Kimi 开放平台(按量) | `https://api.moonshot.ai/v1`(国际) | 国内站 `api.moonshot.cn/v1` |
|
||||
| `opencode-zen` | OpenCode Zen(充值余额) | `https://opencode.ai/zen/v1` | 按量 |
|
||||
| `opencode-go` | **OpenCode Go**($10/月订阅) | `https://opencode.ai/zen/go/v1` | 5h/周/月 额度 |
|
||||
|
||||
关键事实:
|
||||
- **套餐与按量是不同 provider id、不同 base URL**(`zhipuai` vs `zai`;`kimi` vs `moonshot`)。`/connect` 时按选择的 provider 存 key。
|
||||
- 认证均为 API Key(多数 `Authorization: Bearer`);智谱 Coding Plan 的额度查询**不含 Bearer**(见 §4.2)。
|
||||
- **模型清单来自 Models.dev**(OpenCode 编译内置),模型 id 形如 `zhipuai/glm-5.2`、`moonshotai/kimi-k2.7-code`;OpenCode Go 的模型走独立命名空间 `opencode-go/<model>`(如 `opencode-go/kimi-k3`),其额度不消耗各家套餐,只消耗 Go 的 5h/周/月窗口。
|
||||
|
||||
### 3.2 OpenCode Go 套餐额度(当前 opencode.ai 官方数据)
|
||||
|
||||
来源:[opencode.ai/docs/go](https://opencode.ai/docs/go/)
|
||||
|
||||
| 窗口 | 额度 |
|
||||
|---|---|
|
||||
| 5 小时 | $12 用量 |
|
||||
| 每周 | $30 用量 |
|
||||
| 每月 | $60 用量 |
|
||||
|
||||
额度按美元计费价值计算(不同模型折算请求数不同)。超出限额后可选"Use balance"回退到 Zen 余额。
|
||||
|
||||
**查询方式:仅控制台(console),无公开 REST API**。OpenCode 项目本身不做用量查询(无 usage/billing 命令),社区工具(如 cc-switch 的 `usage_script`)也无法覆盖 Go 套餐——这是目前的技术边界,适配时只能提示用户"网页控制台查看"或把 Go 归入"不可查询"类。
|
||||
|
||||
### 3.3 OpenCode 定义 → KimiSwitch 预设的对应关系
|
||||
|
||||
KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCode 一致):
|
||||
|
||||
| KimiSwitch 预设 | baseUrl | billingMode | usageKinds | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `kimi-coding` | `https://api.kimi.com/coding/v1` | subscription | `plan:kimi_coding` | ✅ 可查套餐 |
|
||||
| `zhipu-coding` | `https://open.bigmodel.cn/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 |
|
||||
| `zai-coding` | `https://api.z.ai/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 |
|
||||
| `opencode-go` | `https://opencode.ai/zen/go/v1` | subscription | **(空)** | ❌ 不可查,见 §4.4 |
|
||||
| `opencode-zen` | `https://opencode.ai/zen/v1` | pay_as_you_go | **(空)** | ❌ 不可查 |
|
||||
| `moonshot` | `https://api.moonshot.ai/v1` | pay_as_you_go | **(空)** | ❌ 未挂余额查询,见 §4.3 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 各订阅 / 余额查询 API 详细方案
|
||||
|
||||
### 4.1 Kimi For Coding 套餐(✅ 已实现,无需改动)
|
||||
|
||||
- **端点**:`GET https://api.kimi.com/coding/v1/usages`
|
||||
- **认证**:`Authorization: Bearer <kimi-code-api-key>`(Kimi Code 控制台签发,与开放平台 key 不互通)
|
||||
- **响应**(cc-switch 实测 + 社区验证):
|
||||
|
||||
```json
|
||||
{
|
||||
"limits": [
|
||||
{ "detail": { "limit": 100, "remaining": 40, "resetTime": 1754000000000 } }
|
||||
],
|
||||
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
|
||||
}
|
||||
```
|
||||
|
||||
- **解析**(`coding_plan.rs::parse_kimi_coding`):`limits[].detail` → 5 小时窗口(5h 滚动,`resetTime` 毫秒);`usage` → 周限额。`used = limit - remaining`,换算百分比。
|
||||
- 官方端点确认:Kimi Code 文档服务地址 `https://api.kimi.com/coding/v1`(OpenAI 协议),模型 ID `kimi-for-coding`。
|
||||
|
||||
### 4.2 GLM Coding Plan 套餐(✅ 已实现,无需改动)
|
||||
|
||||
- **端点**:`GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`(国内)/ `GET https://api.z.ai/api/monitor/usage/quota/limit`(海外)
|
||||
- **认证**:`Authorization: <api-key>` —— **裸 key,不带 `Bearer` 前缀**(cc-switch 实测;智谱官方 `glm-plan-usage` 插件 `query-usage.mjs` 同样如此)
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"level": "pro",
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 35, "unit": 3, "nextResetTime": 1754000000000 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 80, "unit": 6, "nextResetTime": 1754500000000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **解析**(`coding_plan.rs::parse_zhipu`):只取 `type == "TOKENS_LIMIT"` 的条目;`unit: 3` → 5 小时窗口,`unit: 6` → 每周窗口(缺失时按 `nextResetTime` 升序启发式兜底);`percentage` 即已用百分比。
|
||||
- **套餐等级**(Lite / Pro 等)在 `data.level`,当前实现未透出(`UsageData` 无该字段,可后续扩展)。
|
||||
|
||||
### 4.3 Kimi 开放平台余额(❌ 未实现 —— 建议新增,优先级 P0)
|
||||
|
||||
**这是本次调研最重要的新发现**。Kimi 官方 2026-07 新增公开余额 API([platform.kimi.com/docs/api/balance](https://platform.kimi.com/docs/api/balance)),cc-switch 也尚未实现(issue [#4455](https://github.com/farion1231/cc-switch/issues/4455) 提议中)。
|
||||
|
||||
- **端点**:`GET https://api.moonshot.cn/v1/users/me/balance`
|
||||
- **认证**:`Authorization: Bearer <MOONSHOT_API_KEY>`
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"available_balance": 49.58894, // 可用余额 CNY(= 现金 + 代金券)
|
||||
"voucher_balance": 46.58893, // 代金券余额
|
||||
"cash_balance": 3.00001 // 现金余额(可为负,欠费)
|
||||
},
|
||||
"scode": "0x0",
|
||||
"status": true
|
||||
}
|
||||
```
|
||||
|
||||
- 业务错误:`code != 0` 或 `status == false` → 确定性失败。
|
||||
- **适配点**:`moonshot` 预设 baseUrl 为 `https://api.moonshot.ai/v1`(国际站),而该端点固定 `api.moonshot.cn`(国内站)。需按 base_url 消歧:host 含 `moonshot.cn` → 查 CN 端点;`moonshot.ai` → 查 `https://api.moonshot.ai/v1/users/me/balance`(国际站对应端点,待实测确认;官方文档仅给出 CN 示例)。
|
||||
- 新增 `balance:kimi` kind 即可复用现有 balance 路径,前端 `UsageFooter` 自动生效(余额形态显示 `💰 余额 ¥49.59`)。
|
||||
|
||||
### 4.4 OpenCode Go / Zen(❌ 未实现 —— 无公开 API,建议标注"不可查")
|
||||
|
||||
- **Go 套餐**:额度窗口见 §3.2,仅控制台可看;`opencode.ai/zen/go/v1` 只有推理端点(`/chat/completions`、`/responses`、`/models`),**无 usage/balance 端点**。
|
||||
- **Zen 余额**:充值制,同样仅控制台。
|
||||
- **可选替代(不推荐投入)**:通过调用推理端点时服务端返回的 402/429/额度错误头做被动感知,无法拿到数值,价值低。
|
||||
- **适配结论**:`opencode-go` / `opencode-zen` 预设维持 `usageKinds` 为空;可在预设 `note`/UI 上提示"额度请在 opencode.ai 控制台查看"。
|
||||
|
||||
### 4.5 其他 cc-switch 独有、KimiSwitch 未移植的能力(按目标匹配度排序)
|
||||
|
||||
| 能力 | cc-switch 实现 | KimiSwitch | 匹配度与建议 |
|
||||
|---|---|---|---|
|
||||
| 通用 JS 脚本引擎(custom/general/newapi) | `usage_script.rs`(rquickjs)+ `UsageScriptModal` | ❌ | 覆盖"任意中转站",工程量大(新增 rquickjs 依赖 + ~1000 行引擎);若"现有账单查询"只服务已知供应商则非必须。**若要支持 new-api/one-api 类中转站,可优先用声明式配置方案替代**(见 §6 P2) |
|
||||
| ZenMux 套餐 | `coding_plan.rs::query_zenmux`(base_url 即查询端点,Bearer) | ❌ | 简单可搬(~50 行 + 测试),P1 |
|
||||
| 火山方舟 Coding Plan | `coding_plan.rs::query_volcengine`(AK/SK 签名 V4 变体,控制面网关) | ❌ | 中等(~200 行签名逻辑,照搬注释坑位),P1 |
|
||||
| 智谱团队版 | `query_zhipu_team`(`bigmodel-organization`/`bigmodel-project` header) | ❌ | 需前端表单存组织/项目 ID + Rust 凭据扩展,P2 |
|
||||
| 官方订阅额度(Claude/Codex/Gemini/Grok OAuth) | `subscription.rs` / `subscription_grok.rs`(读 CLI 凭据文件) | ❌ | 与 KimiSwitch 目标 CLI(Kimi Code / Pi)不匹配,**不建议** |
|
||||
| GitHub Copilot / Codex-OAuth / xAI-OAuth 配额 | 自管 OAuth | ❌ | 同上,**不建议** |
|
||||
| 托盘用量摘要 + UsageCache 事件桥 | `tray.rs` + `usage_cache.rs` | ❌ | KimiSwitch 托盘无用量入口,低价值 |
|
||||
|
||||
### 4.6 盘点中新发现的「订阅预设但不可查」项
|
||||
|
||||
| 预设 | billingMode | 现状 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `stepfun-plan`(StepFun Plan) | subscription | `usageKinds` 为空 | StepFun Plan 走 `https://api.stepfun.com/step_plan/v1` 推理端点;**未发现公开的套餐用量查询 API**(cc-switch 也无)。`balance:stepfun` 查的是按量账户余额(`/v1/accounts`),两者不互通。标注"不可查"即可 |
|
||||
| `bailian`(阿里云百炼) | pay_as_you_go | `usageKinds` 为空 | 百炼 2026-07 上线 Coding Plan(专属 key 格式 `sk-sp-`,按**调用次数**扣额度,与 token 无关);官方文档仅提供"控制台 Coding Plan 页面查看",**未发现公开查询 API**。预留为待跟踪项(套餐上线早期,API 可能随后开放) |
|
||||
| `moonshot`(Kimi 开放平台) | pay_as_you_go | `usageKinds` 为空 | → 即 P0 的 `balance:kimi`,见 §4.3 |
|
||||
|
||||
### 4.7 NewAPI / OneAPI 类中转站账单查询(本次重点调研)
|
||||
|
||||
**背景**:用户使用自建 NewAPI 中转站(实测 `https://ai.codingplan.site`,`sk-` API Key 验证有效)。结论先行:
|
||||
|
||||
1. **`sk-` API Key 查不了余额**(实测 401)。NewAPI 站点的**管理接口只认「登录 Access Token」**,不认推理令牌。cc-switch 也是这个结论——它的 NEW_API 模板填的不是 `apiKey`,而是 `accessToken` + `userId` 两个独立字段。
|
||||
2. **参考实现(cc-switch,完整链路)**:
|
||||
- 模板代码(`UsageScriptModal.tsx:90`):`GET {{baseUrl}}/api/user/self`,头 `Authorization: Bearer {{accessToken}}` + **`New-Api-User: {{userId}}`**;extractor 取 `data.quota`、`data.used_quota`,余额 = `quota ÷ 500000`,已用 = `used_quota ÷ 500000`(单位按站点 `quota_per_unit`,实测本站为 500000;`custom_currency_symbol` 决定是 ¥ 还是 $)。
|
||||
- 凭据模型(`types.ts UsageScript`):`accessToken?` / `userId?`,与 `apiKey`、`baseUrl` 并列,存供应商 `meta.usage_script`。
|
||||
- 后端占位符替换(`usage_script.rs:413-416`):`{{accessToken}}` / `{{userId}}` 仅在脚本含对应占位符时替换;查询凭证由 `resolve_script_credentials` 决定(显式值优先,回退供应商配置)。
|
||||
- 安全校验(`usage_script.rs`):**非 custom 模板强制 HTTPS + 与 base_url 同源(host+port)**;custom 模板放开(可任意 HTTPS 域名/HTTP)。
|
||||
- 前端表单(`UsageScriptModal.tsx:1154-1230`):NEW_API 模板下显示 Base URL / Access Token(密码框带显隐)/ User ID 三个输入;模板切换联动清理多余字段。
|
||||
3. **本站在 cc-switch 下的配置方式**:新建 Claude/Codex 供应商 → 开启用量查询 → 选 NEW_API 模板 → 填 `https://ai.codingplan.site`(Base URL)+ 网页后台 Access Token + 数字用户 ID。实测确认该站 `/api/status` 返回 `quota_per_unit: 500000`、`display_in_currency: true`、`custom_currency_symbol: ¤`(金额符号需按站确认)。
|
||||
|
||||
### 4.8 NewAPI 适配 KimiSwitch 的落地方案
|
||||
|
||||
KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的路子,不适合任意 NewAPI 站。对照 cc-switch,推荐**声明式配置(不引入 JS 引擎)**:
|
||||
|
||||
- **存储**:`Provider` 增加可选 `usageConfig?: { accessToken?: string; userId?: string }`(不写进 TOML,与 `usageKinds` 一样存 SQLite settings `usage_config:<provider_name>`,避免污染 Kimi Code 原生配置)。
|
||||
- **查询**:新增一个 `UsageKind::BalanceNewApi`(`"balance:newapi"`),Rust 侧 `query_kind` 分支:
|
||||
- 端点:`{base_url}/api/user/self`(同源,复用 provider 的 base_url);
|
||||
- 头:`Authorization: Bearer {accessToken}` + `New-Api-User: {userId}`;
|
||||
- 解析:`data.quota / data.used_quota` ÷ `quota_per_unit`(从 `/api/status` 拉取,避免硬编码 500000;失败时回退 500000);
|
||||
- 金额符号:`custom_currency_symbol`(未取到默认 ¥)。
|
||||
- **错误通道**:遵循现有约定(`bytes()` 再解析、瞬时/确定性分离)。
|
||||
- **前端**:`UsageFooter` 无需改;`ProviderEdit` 在启用 `usageKinds` 含 `balance:newapi` 时显示 Access Token / User ID 输入(参考 cc-switch 表单),或做成独立小弹窗。
|
||||
- **风险**:`sk-` 用户拿不到 Access Token 时无解(需登录网页后台);同源校验天然防误查其他站。
|
||||
|
||||
## 5. 新增能力实现清单(改动点)
|
||||
|
||||
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
|
||||
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
|
||||
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
|
||||
> 待办:用户确认后 `cargo test` + `tsc` 验证,验证通过再提交。
|
||||
> 遗留风险:国际站 `api.moonshot.ai/v1/users/me/balance` 端点与币种(按 USD 处理)未实测,见 §4.3。
|
||||
|
||||
> 实施状态更新(2026-07-31 晚):**A + B + C 已全部实现(打包验证中,未提交)**。
|
||||
> - **A(OAuth)**:新增 `src-tauri/src/oauth.rs`(读 `~/.kimi-code/credentials/kimi-code.json` access_token,过期 30s 缓冲,不做 refresh),`query_provider_usage` 的 managed 分支从"报 API key 错误"改为读 OAuth 凭据走 `plan:kimi_coding` 查询;`managed:kimi-code` 卡片不再显示"API Key 无效"。
|
||||
> - **B(NewAPI)**:`services/balance.rs` 加 `query_newapi`(`{base_url}/api/user/self` + Bearer accessToken + `New-Api-User` userId,`/api/status` 运行态拉 `quota_per_unit` 与 `custom_currency_symbol`,进程内缓存);`UsageKind` 加 `BalanceNewApi`(`"balance:newapi"`,ALL 变 10);`query_kind` 签名加 `usage_config` 参数。
|
||||
> - **C(配置面板 + 布局)**:新增 `UsageConfigModal.tsx`(启用开关 / 自动检测 vs NewAPI 模板 / 凭据输入 / 自动查询间隔 / 测试按钮,参考 cc-switch UsageScriptModal 但裁剪);`UsageFooter` 拆分 compact(卡片右上:主摘要 + `x 前` + 刷新)与 detail(底部:多档明细 + 错误/重试);`ProviderList` 加 BarChart3 配置入口按钮;`usageConfig` 存 SQLite settings(`usage_config:<provider_name>`),不进 config.toml。
|
||||
> - **验证**:`cargo test` 46 通过(新增 oauth 凭据解析/过期、newapi 解析 5 个用例、UsageKind roundtrip 10 项)、`tsc --noEmit` 零错误;打包供用户测试。
|
||||
> - **改动文件**:Rust 6 个(oauth.rs 新增、models.rs、commands.rs、services/mod.rs、services/balance.rs、lib.rs)+ 前端 7 个(types/index.ts、UsageConfigModal.tsx 新增、UsageFooter.tsx、ProviderList.tsx、App.tsx、i18n/zh.ts、i18n/en.ts)。
|
||||
|
||||
以 **P0:Kimi 开放平台余额(`balance:kimi`)** 为例,完整改动点如下(严格套用现有四步模式,前端展示零改动):
|
||||
|
||||
1. **Rust** `src-tauri/src/services/balance.rs`
|
||||
- 新增 `pub async fn query_kimi(api_key: &str, is_cn: bool) -> Result<UsageResult, String>`:
|
||||
- 端点:`https://api.moonshot.cn/v1/users/me/balance`(CN)/ `https://api.moonshot.ai/v1/users/me/balance`(EN,待实测)
|
||||
- 复用 `get_json(url, key, AuthStyle::Bearer)`;新增 `parse_kimi`:`code == 0 && status == true` 时取 `data.available_balance`,`unit: "CNY"`;`code != 0` 走确定性失败。
|
||||
- 单元测试:正常解析 / `code!=0` / 缺字段。
|
||||
2. **Rust** `src-tauri/src/services/mod.rs`
|
||||
- `UsageKind` 加 `BalanceKimi`;`as_str` → `"balance:kimi"`;`ALL` 变 9 项;`FromStr` 匹配;`query_kind` 路由按 host 消歧(`moonshot.cn` → CN,`moonshot.ai` → EN);`detect_provider` 加 `api.moonshot.cn` / `api.moonshot.ai` host 匹配 + 测试。
|
||||
3. **前端** `src/config/providerPresets.ts`
|
||||
- `UsageKind` union 与 `SUPPORTED_USAGE_KINDS` 各加 `"balance:kimi"`;`moonshot` 预设加 `usageKinds: ["balance:kimi"]`(防漂移断言会强制两侧同步)。
|
||||
4. **i18n**:无需新 key(`usageBalance` 等已存在)。
|
||||
|
||||
若同时做 P1 的 ZenMux / 火山,则除第 3 步外再加预设(`zenmux`、火山套餐基 URL 或 AK/SK 凭据存储)。注意:**火山 / 智谱团队需要 Provider 模型新增凭据字段**(AK/SK、组织/项目 ID),会触及 `models.rs`、`db.rs` 的 SQLite 表结构与前端 `ProviderEdit` 表单,改动面明显更大,建议单独排期。
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议路线
|
||||
|
||||
| 优先级 | 事项 | 改动面 | 工作量 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | Kimi 开放平台余额 `balance:kimi` | balance.rs + mod.rs + providerPresets.ts | 小(~1 天) | 低;EN 端点待实测 |
|
||||
| P1 | ZenMux 套餐(照搬 cc-switch) | coding_plan.rs + mod.rs + 预设 | 小 | 低 |
|
||||
| P1 | 火山方舟 Coding Plan(AK/SK 签名 V4) | coding_plan.rs + 凭据存储 + 表单 | 中 | 签名细节易错,需单测 |
|
||||
| P2 | 智谱团队版 | coding_plan.rs + 凭据存储 + 表单 | 中 | 需扩展 Provider 凭据 |
|
||||
| **P1** | **NewAPI 中转站余额 `balance:newapi`**(§4.8 声明式方案:`usageConfig{accessToken,userId}` + `/api/user/self`) | balance.rs + mod.rs + db 存储 + ProviderEdit 表单 | 中 | 需用户提供 Access Token;站点 quota_per_unit/符号需运行态拉取 |
|
||||
| P3 | JS 脚本引擎(custom/general) | usage_script.rs 移植(rquickjs) | 大 | 覆盖面广但工程量大;被 `balance:newapi` 声明式方案覆盖主场景后可暂缓 |
|
||||
| P3 | 官方 OAuth 订阅 / 托盘用量 | — | 大 | 与目标 CLI 不匹配,建议不做 |
|
||||
| P3 | 查询请求走代理(见 §6.1) | balance.rs 共用 Client 改造 | 小-中 | 依赖全局代理设置是否已存在 |
|
||||
|
||||
### 6.1 遗漏盘点(首轮调研未覆盖,本次补查确认)
|
||||
|
||||
1. **代理支持缺失(建议 P1-P2)**:KimiSwitch 的 `balance.rs::get_json` 每次新建 `reqwest::Client`,**没有配置代理**;cc-switch 的 `crate::proxy::http_client::get()` 继承全局代理。用户若经代理访问海外站(SiliconFlow.com / z.ai / OpenRouter / Novita / api.moonshot.ai),账单查询会直连失败。适配时建议把 Client 构造提升为共用函数并支持代理(项目已依赖 reqwest,无新增依赖)。
|
||||
2. **无自动轮询(P3 可选)**:cc-switch 有 `autoQueryInterval` + react-query 轮询 + 托盘摘要;KimiSwitch 的 `UsageFooter` 仅在挂载时查询 + 手动刷新。低价值(窗口额度重置倒计时可手动刷新),不做不影响正确性。
|
||||
3. **智谱套餐等级未透出(P3 可选)**:`data.level`(Lite/Pro)已在响应中但 `UsageData` 无字段承接;如需展示需扩契约。
|
||||
4. **多 agent 已覆盖**:`UsageFooter` 挂在两个 agent(Kimi Code / Pi)的 `ProviderList`,cacheKey 含 agent,无遗漏。
|
||||
5. **相关文档衔接**:项目已有 `docs/PROPOSAL-presets-and-usage.md`、`docs/VERIFICATION-CHECKLIST.md` 随功能迭代更新;本文档为独立调研稿,实施时按项目约定把改动项回写上述文档。
|
||||
|
||||
> 说明:以上为调研结论与建议,尚未实现任何代码;若确认方向,可先落 P0。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与坑位备忘
|
||||
|
||||
1. **智谱裸 key**:`monitor/usage/quota/limit` 的 `Authorization` 不带 `Bearer`,且要带 `Content-Type: application/json`、`Accept-Language`(cc-switch 实测)。
|
||||
2. **Kimi 三套凭据不互通**:Kimi 开放平台(开放平台 key)、Kimi Code(`api.kimi.com/coding` 专属 key)、Kimi 会员是三个独立产品,余额/权益/key 均不通用(官方 FAQ 确认)——UI 文案需区分"开放平台余额"与"For Coding 套餐"。
|
||||
3. **base_url 消歧**:SiliconFlow `.cn/.com`、MiniMax `.com/.io`、智谱 `bigmodel.cn/z.ai`、Kimi 开放平台 `moonshot.cn/.ai` 均靠 host 子串区分;`detect_provider` 命中即注入,注意别把 `api.kimi.com`(非 `/coding` 路径)误判为套餐。
|
||||
4. **响应读体与解析分离**:所有查询先 `bytes()` 再 parse,保持瞬时/确定性错误通道不混淆(已统一在 `balance.rs::get_json`)。
|
||||
5. **金额单位**:Novita ×0.0001 USD;Kimi 余额为 CNY;Go 套餐按 USD 计。
|
||||
6. **TS/Rust 枚举漂移**:新增 kind 必须同时改 `UsageKind` union + `SUPPORTED_USAGE_KINDS` + Rust 枚举三处,否则 dev 断言报错。
|
||||
7. **NewAPI 特有点(§4.7 实测)**:`sk-` API Key 与 Access Token 是两套凭据,前者**查不了** `/api/user/self`;`New-Api-User` 头必须带;余额 = `quota/used_quota ÷ quota_per_unit`(本站 500000);`quota` 为 0 常表示该令牌"无限额度"(后台设置),不要显示成余额为 0。
|
||||
8. **NewAPI 同源**:`/api/user/self` 与推理同源(同一 base_url host),天然可复用车基地址;若经代理访问站点,`/api/status` 也要走代理(见 §6.1 代理改造)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 附:OpenCode 预设"获取 API Key"推荐链接检查(2026-07-31)
|
||||
|
||||
用户反馈:OpenCode 预设的"获取 API Key"链接没有走自己的推荐链接。排查结果如下:
|
||||
|
||||
| 预设 | 字段 | 当前值 | 是否带推荐码 |
|
||||
|---|---|---|---|
|
||||
| `opencode-go` | `websiteUrl` | `https://opencode.ai/go?ref=DFCNADQCEM` | ✅ 带 `ref=DFCNADQCEM` |
|
||||
| `opencode-go` | `apiKeyUrl` | `https://opencode.ai/go?ref=DFCNADQCEM` | ✅ 带 `ref=DFCNADQCEM` |
|
||||
| `opencode-zen` | `websiteUrl` | `https://opencode.ai/zen/` | ❌ **不带 ref** |
|
||||
| `opencode-zen` | `apiKeyUrl` | `https://opencode.ai/zen/` | ❌ **不带 ref** |
|
||||
|
||||
- 位置:`src/config/providerPresets.ts:417-458`(`opencode-go` / `opencode-zen` 两个预设)。
|
||||
- 渲染链路:`src/components/ProviderEdit.tsx:325` —— 编辑页"获取 API Key"按钮 = `preset?.apiKeyUrl ?? provider.official_url`;预设选择后即带出该 URL。
|
||||
- 历史:`ref=DFCNADQCEM` 于 v0.6.2(提交 `bee981a` "OpenCode Go/Zen 预设")加入 `opencode-go`;`opencode-zen` 自始未带 ref。
|
||||
- 结论:推荐链接确认为 `https://opencode.ai/go?ref=DFCNADQCEM`(用户确认)。`opencode-go` 预设两处均已带该 ref,无需改动;**`opencode-zen` 的 `apiKeyUrl` 已修复**为推荐链接(`websiteUrl` 保持 `https://opencode.ai/zen/` 官网展示不变),这样两个 OpenCode 预设的"获取 API Key"入口都走推荐链接。
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考资料
|
||||
|
||||
- Kimi 开放平台余额 API:https://platform.kimi.com/docs/api/balance
|
||||
- Kimi Code 文档(服务地址 / 环境变量 / 错误参考):https://www.kimi.com/code/docs/ 、 https://www.kimi.com/code/docs/kimi-code-cli/configuration/env-vars.html
|
||||
- Kimi 帮助中心(余额与用量、会员权益):https://www.kimi.com/zh-cn/help/kimi-api/api-balance-and-usage 、 https://www.kimi.com/zh-cn/help/kimi-code/membership-guide
|
||||
- 智谱 GLM Coding Plan 额度 API(cc-switch issue 社区确认):https://github.com/farion1231/cc-switch/issues/1588 、 https://github.com/seakee/CPA-Manager-Plus/issues/379
|
||||
- NewAPI / OneAPI `/api/user/self` 接口(社区文档):https://juejin.cn/post/7493007002832551988 、 https://allinone.apifox.cn/399558528e0 、 https://docs.laozhang.ai/faq/balance-query-api
|
||||
- NewAPI 用户管理文档:https://www.newapi.ai/zh/docs/guide/feature-guide/admin/user
|
||||
- 实测站点:`https://ai.codingplan.site`(`/api/status`:quota_per_unit=500000、display_in_currency=true)
|
||||
- Kimi 余额查询(cc-switch issue,官方 API 引入):https://github.com/farion1231/cc-switch/issues/4455
|
||||
- OpenCode Go 套餐额度:https://opencode.ai/docs/go/
|
||||
- OpenCode providers / 配置:https://opencode.ai/docs/providers/
|
||||
- cc-switch 源码(MIT):`src-tauri/src/services/balance.rs`、`src-tauri/src/services/coding_plan.rs`、`src-tauri/src/usage_script.rs`
|
||||
- KimiSwitch 现有实现:`src-tauri/src/services/{mod,balance,coding_plan,usage_types}.rs`、`src/config/providerPresets.ts`、`src/components/UsageFooter.tsx`
|
||||
@@ -0,0 +1,144 @@
|
||||
# 用户自定义预设方案(User Presets)
|
||||
|
||||
## 动机
|
||||
|
||||
当前内置预设(`providerPresets.ts`)硬编码在源码中,用户无法自行添加自定义供应商预设或修改已有预设的 `apiKeyUrl`(获取 Key / 推广链接)。每次新增或修改都需要修改源码、重新编译、重新打包,缺乏灵活性。
|
||||
|
||||
## 目标
|
||||
|
||||
用户可通过外部配置文件**零代码**添加/修改预设供应商,包含:
|
||||
- 自定义供应商预设(名称、Base URL、API 格式、图标、套餐类型)
|
||||
- 自定义获取 API Key 入口 / 推广链接(`apiKeyUrl`,可直接放带 ref 的链接)
|
||||
- 自定义模型列表(模型 ID、显示名称、上下文大小、能力)
|
||||
- 覆盖内置预设(同一 `id` 时用户预设优先)
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 1. 配置文件位置
|
||||
|
||||
```
|
||||
Windows: %APPDATA%/KimiSwitch/user-presets.json
|
||||
macOS: ~/Library/Application Support/KimiSwitch/user-presets.json
|
||||
Linux: ~/.config/KimiSwitch/user-presets.json
|
||||
```
|
||||
|
||||
统一使用应用数据目录(与 `app_config_dir` 一致),不扫描项目根目录——发行版是编译后的二进制,终端用户没有"项目根目录"。
|
||||
|
||||
首次启动时若文件不存在,自动创建一个空 `[]` 模板文件,方便用户直接编辑。
|
||||
PresetPickerModal / 设置页提供一个"打开预设文件夹"按钮,让用户能找到文件位置。
|
||||
|
||||
### 2. 文件格式
|
||||
|
||||
> 支持 `//` 行注释(jsonc 风格):加载时先剥离 `//` 注释再 `JSON.parse`,方便用户写备注。
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
// 唯一标识,与内置预设 id 重名则覆盖内置预设
|
||||
"id": "my-custom-vendor",
|
||||
// 显示名称(fallback 英文)
|
||||
"name": "My Custom Vendor",
|
||||
// i18n key(可选,有则优先用 t(nameKey))
|
||||
"nameKey": "presetNameMyCustom",
|
||||
// 官网链接
|
||||
"websiteUrl": "https://example.com",
|
||||
// 获取 API Key 入口 / 推广链接(可直接放带 ref 的链接,如 ?ref=CODE)
|
||||
"apiKeyUrl": "https://example.com?ref=MYCODE",
|
||||
// 分类:official | cn_official | third_party | aggregator | custom
|
||||
"category": "third_party",
|
||||
// API 格式:openai | anthropic | google-genai | vertexai | kimi | openai_responses
|
||||
"providerType": "openai",
|
||||
// Base URL(null 表示使用默认)
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
// 图标 key(从 src/icons/extracted 中选,或空字符串用首字母)
|
||||
"icon": "",
|
||||
// 图标颜色(可选,hex 格式)
|
||||
"iconColor": "",
|
||||
// 计费模式:subscription | pay_as_you_go
|
||||
"billingMode": "pay_as_you_go",
|
||||
// 用量查询类型(可选,参考内置预设)
|
||||
"usageKinds": [],
|
||||
// 模型列表
|
||||
"models": [
|
||||
{
|
||||
"model": "my-model-1",
|
||||
"displayName": "My Model 1",
|
||||
"maxContextSize": 128000,
|
||||
"capabilities": ["thinking"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 加载逻辑(伪代码)
|
||||
|
||||
```
|
||||
function loadAllPresets(): ProviderPreset[] {
|
||||
const builtin = providerPresets; // 内置预设
|
||||
const user = readUserPresetsFile(); // 读取用户预设文件
|
||||
if (!user || user.length === 0) return builtin;
|
||||
|
||||
const merged = new Map<string, ProviderPreset>();
|
||||
for (const p of builtin) merged.set(p.id, p); // 内置先进
|
||||
for (const p of user) merged.set(p.id, p); // 用户覆盖
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 需要修改的代码文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `src/config/providerPresets.ts` | 导出 `loadAllPresets()` 函数替代直接使用 `providerPresets` 数组 |
|
||||
| `src/config/userPresets.ts`(新建) | 实现 `readUserPresetsFile()`:通过 Tauri 命令(Rust 侧 `read_text_file`)读取 + 剥离注释 + `JSON.parse` + 校验结构。用 Rust 命令而非前端 FS 插件,避免 scope 配置且跨平台路径由 Rust `app_config_dir` 统一解析 |
|
||||
| `src/components/PresetPickerModal.tsx` | 将 `providerPresets` 引用改为 `loadAllPresets()` |
|
||||
| `src/config/providerPresets.ts`(`findPresetForProvider`) | 同样需要从合并后的预设中查找 |
|
||||
|
||||
### 5. 校验逻辑
|
||||
|
||||
用户 JSON 文件可能写错,需要做基础校验:
|
||||
|
||||
```typescript
|
||||
interface UserPresetValidation {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
presets: ProviderPreset[];
|
||||
}
|
||||
|
||||
function validateUserPreset(raw: unknown): UserPresetValidation {
|
||||
// 1. 必须是数组
|
||||
// 2. 每项必须有 id、name、providerType、billingMode
|
||||
// 3. providerType 必须是合法值之一(openai | openai_responses | anthropic | google-genai | vertexai | kimi)
|
||||
// 4. billingMode 必须是 "subscription" | "pay_as_you_go"
|
||||
// 5. models 数组每项必须有 model 字段
|
||||
// 6. usageKinds 若提供,每项必须在 SUPPORTED_USAGE_KINDS 内,否则丢弃并警告
|
||||
// (Rust 侧运行时也会静默丢弃未知 kind,这里提前拦截给用户明确反馈)
|
||||
// 7. 忽略未知字段不报错(仅警告)
|
||||
// 8. 单条严重错误时跳过该条,不阻塞其他预设
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 用户交互
|
||||
|
||||
- PresetPickerModal 中用户预设顶部显示,与内置预设分栏展示(或加"自定义"标签)
|
||||
- 若 JSON 文件解析失败,界面上显示一个黄色警告条 + 错误详情
|
||||
- 提供一个"刷新用户预设"按钮,不必重启应用
|
||||
|
||||
### 7. 安全问题
|
||||
|
||||
- 不读取系统敏感路径,仅限于应用数据目录
|
||||
- 不执行 JSON 中的任何代码;`JSON.parse` 产出的是纯对象,无原型污染风险,校验只需关注字段类型与取值
|
||||
- 对 URL 字段(websiteUrl / apiKeyUrl)做基本 `http(s)://` 协议校验,`openUrl` 调用前过滤非法 scheme
|
||||
|
||||
### 8. 向后兼容
|
||||
|
||||
- 没有 `user-presets.json` 时行为完全不变
|
||||
- 用户预设只影响 PresetPickerModal 中的选项,不影响已保存的 Provider
|
||||
- 已保存的 Provider 不受用户预设删除影响
|
||||
|
||||
## 未解决的问题
|
||||
|
||||
1. **图标来源**:用户用内置图标集不存在的 key(含空字符串/null)时,统一降级为首字母显示。是否支持用户自定义 SVG 图标?—— 当前首字母降级足够,先不做。
|
||||
2. **热加载**:文件修改后是否需要自动检测并刷新?—— 初期手动刷新按钮即可,后续可加 `fs::watch`。
|
||||
3. **i18n**:用户预设的 `nameKey` 如果对应内置 i18n key 则显示翻译,否则 fallback 到 `name`。用户不能添加自定义 i18n key(涉及翻译文件修改,违背零代码原则)。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+2
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.0"
|
||||
version = "0.6.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1975,6 +1975,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.0"
|
||||
version = "0.6.5"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -25,6 +25,7 @@ futures-util = "0.3"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
||||
[lib]
|
||||
name = "kimiswitch_lib"
|
||||
|
||||
+118
-26
@@ -1,5 +1,5 @@
|
||||
use crate::db;
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType, UsageConfig};
|
||||
use crate::pi_io;
|
||||
use crate::services::{self, UsageKind, UsageResult};
|
||||
use indexmap::IndexMap;
|
||||
@@ -27,6 +27,10 @@ fn usage_kinds_key(provider_name: &str) -> String {
|
||||
format!("usage_kinds:{provider_name}")
|
||||
}
|
||||
|
||||
fn usage_config_key(provider_name: &str) -> String {
|
||||
format!("usage_config:{provider_name}")
|
||||
}
|
||||
|
||||
/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite
|
||||
/// settings first, host-based detection as fallback so existing installs get
|
||||
/// billing support automatically. The field never enters config.toml.
|
||||
@@ -45,6 +49,11 @@ fn merge_usage_kinds(config: &mut Config) {
|
||||
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
|
||||
}
|
||||
});
|
||||
// Same pattern for the panel-edited usage config.
|
||||
p.usage_config = db::get_setting_pub(&usage_config_key(&p.name))
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str::<UsageConfig>(&s).ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +167,14 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
|
||||
}
|
||||
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
|
||||
}
|
||||
let cfg_key = usage_config_key(&provider.name);
|
||||
match &provider.usage_config {
|
||||
Some(cfg) => {
|
||||
let json = serde_json::to_string(cfg).map_err(|e| e.to_string())?;
|
||||
db::set_setting_pub(&cfg_key, &json).map_err(fmt_anyhow)?;
|
||||
}
|
||||
None => db::delete_setting_pub(&cfg_key).map_err(fmt_anyhow)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -614,9 +631,20 @@ pub async fn query_provider_usage(
|
||||
)));
|
||||
};
|
||||
|
||||
// Panel toggle: user disabled usage queries for this provider.
|
||||
if let Some(cfg) = &provider.usage_config {
|
||||
if !cfg.enabled {
|
||||
return Ok(UsageResult::failure(
|
||||
"usage query disabled in config panel".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The api_key only ever goes into request headers — never into logs,
|
||||
// error messages, or the cache key.
|
||||
let api_key = provider
|
||||
// error messages, or the cache key. Managed (OAuth-login) providers have
|
||||
// no static key; their credential comes from the Kimi Code OAuth session
|
||||
// file (refreshed on demand when the 15-min access token expires).
|
||||
let mut api_key = provider
|
||||
.api_key
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
@@ -627,15 +655,48 @@ pub async fn query_provider_usage(
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
});
|
||||
let mut oauth_err: Option<String> = None;
|
||||
if api_key.is_none() && provider.managed {
|
||||
match crate::oauth::get_valid_access_token().await {
|
||||
Ok(token) => api_key = Some(token),
|
||||
Err(e) => oauth_err = Some(e),
|
||||
}
|
||||
}
|
||||
let Some(api_key) = api_key else {
|
||||
return Ok(UsageResult::failure(if provider.managed {
|
||||
"provider uses managed OAuth; usage query requires an API key".to_string()
|
||||
oauth_err.unwrap_or_else(|| {
|
||||
"no Kimi Code OAuth credentials found; run `kimi login` first".to_string()
|
||||
})
|
||||
} else {
|
||||
"no API key configured".to_string()
|
||||
}));
|
||||
};
|
||||
|
||||
let base_url = resolve_base_url(provider);
|
||||
|
||||
// NewAPI template: query the gateway's own /api/user/self with the
|
||||
// web-console access token, bypassing usage_kinds entirely.
|
||||
if let Some(cfg) = &provider.usage_config {
|
||||
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
|
||||
// Err = transient (network) → propagate for retry, same semantics
|
||||
// as the kinds loop below. Config errors surface as Ok(failure).
|
||||
let result = services::query_kind(
|
||||
UsageKind::BalanceNewapi,
|
||||
&base_url,
|
||||
&api_key,
|
||||
Some(cfg),
|
||||
)
|
||||
.await?;
|
||||
if result.success {
|
||||
usage_cache()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(cache_key, (Instant::now(), result.clone()));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
let kinds: Vec<UsageKind> = provider
|
||||
.usage_kinds
|
||||
.as_ref()
|
||||
@@ -660,7 +721,7 @@ pub async fn query_provider_usage(
|
||||
let mut transient: Vec<String> = Vec::new();
|
||||
let mut any_success = false;
|
||||
for kind in kinds {
|
||||
match services::query_kind(kind, &base_url, &api_key).await {
|
||||
match services::query_kind(kind, &base_url, &api_key, provider.usage_config.as_ref()).await {
|
||||
Ok(result) if result.success => {
|
||||
any_success = true;
|
||||
if let Some(d) = result.data {
|
||||
@@ -740,25 +801,55 @@ fn version_lt(current: &str, latest: &str) -> bool {
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update() -> Result<UpdateInfo, String> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
// GitHub Releases API for billowliu2/KimiSwitch. Historical versions
|
||||
// (v0.5.x and earlier) live on git.codingplan.site and are not exposed
|
||||
// here — only v0.6.0+ is published via GitHub.
|
||||
let url = "https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1";
|
||||
// Prefer the private Gitea repo (primary release channel, all versions
|
||||
// since v0.1.0). Fall back to GitHub Releases when it is unreachable
|
||||
// (e.g. off the LAN/VPN) — v0.6.0+ is mirrored there.
|
||||
let sources = [
|
||||
"https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1",
|
||||
"https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1",
|
||||
];
|
||||
|
||||
// GitHub returns 403 without a User-Agent header. Bundle the app version
|
||||
// so the source is identifiable in any rate-limit / abuse reports.
|
||||
// Both hosts require a User-Agent header (GitHub returns 403 without one).
|
||||
// Bundle the app version so the source is identifiable in any rate-limit
|
||||
// / abuse reports. A short timeout keeps the fallback responsive when the
|
||||
// private server is unreachable.
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| format!("client build failed: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for url in sources {
|
||||
match fetch_latest_release(&client, url).await {
|
||||
Ok((latest, release_url, download_url)) => {
|
||||
let update_available = version_lt(¤t, &latest);
|
||||
return Ok(UpdateInfo {
|
||||
current,
|
||||
latest,
|
||||
update_available,
|
||||
release_url,
|
||||
download_url,
|
||||
});
|
||||
}
|
||||
Err(e) => errors.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("all update sources failed: {}", errors.join("; ")))
|
||||
}
|
||||
|
||||
/// GET a releases API (Gitea or GitHub, same array shape) and extract the
|
||||
/// latest tag, release page URL and the installer asset for the current OS.
|
||||
async fn fetch_latest_release(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<(String, String, Option<String>), String> {
|
||||
let releases: serde_json::Value = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
let releases: serde_json::Value = resp
|
||||
.map_err(|e| format!("request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("parse failed: {e}"))?;
|
||||
@@ -785,15 +876,7 @@ pub async fn check_for_update() -> Result<UpdateInfo, String> {
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|a| pick_asset_for_current_os(a));
|
||||
|
||||
let update_available = version_lt(¤t, &latest);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
current,
|
||||
latest,
|
||||
update_available,
|
||||
release_url,
|
||||
download_url,
|
||||
})
|
||||
Ok((latest, release_url, download_url))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -866,8 +949,6 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Pick the GitHub release asset matching the given OS by file extension.
|
||||
/// Preference for Linux is AppImage (portable, no install). If the preferred
|
||||
/// extension is not present, returns None so the UI can fall back to
|
||||
/// "open release page" instead of guessing a less-preferred format.
|
||||
@@ -964,4 +1045,15 @@ mod asset_picker_tests {
|
||||
let url = pick_asset_for_os(&assets, "freebsd");
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an external https/http URL in the system default browser.
|
||||
/// Uses the Rust opener directly (bypassing the JS plugin scope) so it
|
||||
/// works regardless of capability scope configuration.
|
||||
#[tauri::command]
|
||||
pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -159,6 +159,7 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
.unwrap_or(Value::Null),
|
||||
// Merged from the settings table by load_agent_config_command.
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
icon_color,
|
||||
raw_other,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -508,6 +509,7 @@ api_key = ""
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let mut models = IndexMap::new();
|
||||
@@ -570,6 +572,7 @@ api_key = ""
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let mut models = IndexMap::new();
|
||||
@@ -676,6 +679,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
@@ -695,6 +699,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
@@ -734,6 +739,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: serde_json::json!({"default_model": "some-alias"}),
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
||||
pub mod db;
|
||||
pub mod kimi_code_io;
|
||||
pub mod models;
|
||||
pub mod oauth;
|
||||
pub mod pi_io;
|
||||
pub mod services;
|
||||
|
||||
@@ -104,6 +105,7 @@ pub fn run() {
|
||||
commands::check_for_update,
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
commands::open_external_url,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
+46
-2
@@ -96,9 +96,17 @@ pub struct Provider {
|
||||
/// `usage_kinds:<provider_name>`, NOT in the agent's config.toml — both
|
||||
/// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are
|
||||
/// explicit and never serialize this field, while the IPC payload to the
|
||||
/// frontend does carry it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// frontend does carry it. Renamed to camelCase to match the TS
|
||||
/// `Provider.usageKinds` field — without the rename the frontend reads
|
||||
/// `undefined` and no usage footer ever renders.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageKinds")]
|
||||
pub usage_kinds: Option<Vec<String>>,
|
||||
/// Usage query configuration (template, credentials, auto interval),
|
||||
/// edited via the "配置用量查询" panel. Same persistence strategy as
|
||||
/// `usage_kinds`: SQLite settings `usage_config:<provider_name>`, never
|
||||
/// config.toml; IPC carries it as camelCase.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageConfig")]
|
||||
pub usage_config: Option<UsageConfig>,
|
||||
}
|
||||
|
||||
impl PartialEq for Provider {
|
||||
@@ -117,6 +125,7 @@ impl PartialEq for Provider {
|
||||
&& self.icon_color == other.icon_color
|
||||
&& self.raw_other == other.raw_other
|
||||
&& self.usage_kinds == other.usage_kinds
|
||||
&& self.usage_config == other.usage_config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +216,41 @@ impl PartialEq for Config {
|
||||
|
||||
impl Eq for Config {}
|
||||
|
||||
/// Usage query configuration edited via the "配置用量查询" panel.
|
||||
/// Mirrors cc-switch's `UsageScript` but trimmed to what Kimi Switch supports:
|
||||
/// auto-detected kinds plus the NewAPI/OneAPI template. Serialized camelCase
|
||||
/// to match the TS `UsageConfig` interface; persisted in SQLite settings, never
|
||||
/// in the agent's config.toml.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageConfig {
|
||||
/// Whether usage queries run for this provider at all.
|
||||
pub enabled: bool,
|
||||
/// "auto" = query the kinds in `usage_kinds` / host detection;
|
||||
/// "newapi" = query a NewAPI/OneAPI gateway with accessToken + userId.
|
||||
pub template_type: String,
|
||||
/// NewAPI query base URL (falls back to the provider's base_url).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// NewAPI web-console access token (NOT the sk- inference key).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub access_token: Option<String>,
|
||||
/// NewAPI user id, sent as the `New-Api-User` header.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_id: Option<String>,
|
||||
/// Auto query interval in minutes; 0/None = manual refresh only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_query_interval_minutes: Option<u32>,
|
||||
/// Per-request timeout in seconds; 0/None = default (8s).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl UsageConfig {
|
||||
pub const TEMPLATE_AUTO: &'static str = "auto";
|
||||
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Config {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Config")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Kimi Code OAuth credentials (login session) for usage queries.
|
||||
//!
|
||||
//! The official `kimi` CLI stores its OAuth session in
|
||||
//! `~/.kimi-code/credentials/kimi-code.json` after `kimi login`:
|
||||
//! ```json
|
||||
//! {
|
||||
//! "access_token": "eyJ...", // JWT, 15 min TTL (expires_in 900)
|
||||
//! "refresh_token": "eyJ...", // 30 day TTL, rotated on refresh
|
||||
//! "expires_at": 1785510484, // unix seconds
|
||||
//! "scope": "kimi-code",
|
||||
//! "token_type": "Bearer",
|
||||
//! "expires_in": 900
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! v2 refreshes expired tokens via the OAuth refresh_token grant and writes
|
||||
//! the rotated tokens back to the credentials file. Concurrency safety:
|
||||
//! - a process-wide single-flight mutex serializes refreshes, and the file is
|
||||
//! re-read under the lock so a refresh done by the running `kimi` CLI (or
|
||||
//! another waiter) is adopted instead of duplicated;
|
||||
//! - right before writing back, the file is re-read once more — if the CLI
|
||||
//! refreshed meanwhile its newer tokens win (refresh tokens rotate on use,
|
||||
//! so clobbering the CLI's write would break its next refresh).
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::kimi_code_io::kimi_code_config_dir;
|
||||
|
||||
/// Seconds of leeway when checking expiry, so a token that is about to die
|
||||
/// mid-request counts as expired.
|
||||
const EXPIRY_LEEWAY_SECS: i64 = 30;
|
||||
|
||||
/// OAuth token endpoint (confirmed in the official kimi.exe binary).
|
||||
const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token";
|
||||
/// Public OAuth client id used by the official CLI (from kimi.exe).
|
||||
const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
/// Refresh request timeout; refresh is rare, a bit more headroom than the 8s
|
||||
/// query default is fine.
|
||||
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OAuthCredentials {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<i64>,
|
||||
#[allow(dead_code)]
|
||||
pub scope: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub token_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
impl OAuthCredentials {
|
||||
/// True when the token is expired or expires within the leeway window.
|
||||
/// Missing `expires_at` is treated as valid (fail open to the API call,
|
||||
/// which returns a definitive 401 if the token is bad).
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let Some(expires_at) = self.expires_at else {
|
||||
return false;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
now >= expires_at - EXPIRY_LEEWAY_SECS
|
||||
}
|
||||
}
|
||||
|
||||
fn credentials_path() -> std::path::PathBuf {
|
||||
kimi_code_config_dir()
|
||||
.join("credentials")
|
||||
.join("kimi-code.json")
|
||||
}
|
||||
|
||||
/// Load the Kimi Code OAuth session. Errors are deterministic (missing file /
|
||||
/// unreadable JSON), never transient — the caller turns them into
|
||||
/// `Ok(success:false)`.
|
||||
pub fn load_kimi_code_credentials() -> Result<OAuthCredentials, String> {
|
||||
let path = credentials_path();
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| {
|
||||
format!(
|
||||
"Kimi Code OAuth credentials not found at {}: {e}. Run `kimi login` first.",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse Kimi Code OAuth credentials: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<i64>,
|
||||
scope: Option<String>,
|
||||
token_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Process-wide single-flight lock for token refresh. Refresh tokens rotate
|
||||
/// on use, so two concurrent refreshes would invalidate one of them.
|
||||
fn refresh_lock() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
/// Return a usable access token, refreshing via the refresh_token grant when
|
||||
/// the stored one is expired. Errors are deterministic (missing/dead session)
|
||||
/// — the caller surfaces them as `Ok(success:false)`.
|
||||
pub async fn get_valid_access_token() -> Result<String, String> {
|
||||
let creds = load_kimi_code_credentials()?;
|
||||
if !creds.is_expired() {
|
||||
return Ok(creds.access_token);
|
||||
}
|
||||
|
||||
let _guard = refresh_lock().lock().await;
|
||||
// Re-read under the lock: the CLI or a previous waiter may have refreshed
|
||||
// while we were waiting.
|
||||
let creds = load_kimi_code_credentials()?;
|
||||
if !creds.is_expired() {
|
||||
return Ok(creds.access_token);
|
||||
}
|
||||
refresh_credentials(&creds).await
|
||||
}
|
||||
|
||||
/// Merge a token endpoint response into the existing credentials JSON,
|
||||
/// preserving unrelated fields. Pure function for testability.
|
||||
fn merge_token_response(current_json: &str, resp: &TokenResponse) -> String {
|
||||
let mut merged: serde_json::Value =
|
||||
serde_json::from_str(current_json).unwrap_or_else(|_| serde_json::json!({}));
|
||||
let obj = match merged.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return current_json.to_string(),
|
||||
};
|
||||
obj.insert(
|
||||
"access_token".to_string(),
|
||||
serde_json::Value::String(resp.access_token.clone()),
|
||||
);
|
||||
if let Some(rt) = &resp.refresh_token {
|
||||
obj.insert(
|
||||
"refresh_token".to_string(),
|
||||
serde_json::Value::String(rt.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(expires_in) = resp.expires_in {
|
||||
obj.insert(
|
||||
"expires_in".to_string(),
|
||||
serde_json::Value::Number(expires_in.into()),
|
||||
);
|
||||
obj.insert(
|
||||
"expires_at".to_string(),
|
||||
serde_json::Value::Number((chrono::Utc::now().timestamp() + expires_in).into()),
|
||||
);
|
||||
}
|
||||
if let Some(scope) = &resp.scope {
|
||||
obj.insert(
|
||||
"scope".to_string(),
|
||||
serde_json::Value::String(scope.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(token_type) = &resp.token_type {
|
||||
obj.insert(
|
||||
"token_type".to_string(),
|
||||
serde_json::Value::String(token_type.clone()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string_pretty(&merged).unwrap_or_else(|_| current_json.to_string())
|
||||
}
|
||||
|
||||
/// Call the token endpoint with the refresh_token grant and persist the
|
||||
/// rotated tokens. Caller must hold [`refresh_lock`].
|
||||
async fn refresh_credentials(creds: &OAuthCredentials) -> Result<String, String> {
|
||||
let refresh_token = creds
|
||||
.refresh_token
|
||||
.clone()
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Kimi Code session has no refresh token; run `kimi login`".to_string()
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REFRESH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
// Tokens only ever go into the request body — never into logs or errors.
|
||||
let resp = client
|
||||
.post(TOKEN_ENDPOINT)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", CLIENT_ID),
|
||||
("refresh_token", refresh_token.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Token refresh request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// OAuth error bodies are tiny ({"error":"invalid_grant", ...}); keep
|
||||
// the first 200 chars for diagnosability. They never contain tokens.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body: String = body.chars().take(200).collect();
|
||||
return Err(format!(
|
||||
"Kimi Code login expired and refresh failed (HTTP {status}): {body}. Run `kimi login` to sign in again"
|
||||
));
|
||||
}
|
||||
|
||||
let token: TokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse token refresh response: {e}"))?;
|
||||
|
||||
// Re-read right before writing: if the CLI refreshed meanwhile, its newer
|
||||
// (rotated) tokens win — overwriting them would kill its next refresh.
|
||||
let path = credentials_path();
|
||||
let current = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if let Ok(latest) = serde_json::from_str::<OAuthCredentials>(¤t) {
|
||||
if !latest.is_expired() && latest.access_token != creds.access_token {
|
||||
return Ok(latest.access_token);
|
||||
}
|
||||
}
|
||||
|
||||
let merged = merge_token_response(¤t, &token);
|
||||
if let Err(e) = std::fs::write(&path, merged) {
|
||||
// Non-fatal: the new access token is still usable for this query; the
|
||||
// next one will just refresh again.
|
||||
eprintln!("[oauth] failed to write refreshed credentials: {e}");
|
||||
}
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expired_token_detected_with_leeway() {
|
||||
let past = chrono::Utc::now().timestamp() - 60;
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(past),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_token_not_expired() {
|
||||
let future = chrono::Utc::now().timestamp() + 3600;
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(future),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(!creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_expires_at_fails_open() {
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(!creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_file_shape_parses() {
|
||||
let json = serde_json::json!({
|
||||
"access_token": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"expires_at": 1785510484i64,
|
||||
"scope": "kimi-code",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900
|
||||
});
|
||||
let creds: OAuthCredentials = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(creds.access_token, "eyJhbGciOiJFUzI1NiIs...");
|
||||
assert_eq!(creds.expires_at, Some(1785510484));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_unrelated_fields_and_rotates() {
|
||||
let current = r#"{
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"expires_at": 1,
|
||||
"scope": "kimi-code",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"custom_field": "keep-me"
|
||||
}"#;
|
||||
let resp = TokenResponse {
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: Some("new-refresh".to_string()),
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
};
|
||||
let merged = merge_token_response(current, &resp);
|
||||
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(v["access_token"], "new-access");
|
||||
assert_eq!(v["refresh_token"], "new-refresh");
|
||||
assert_eq!(v["custom_field"], "keep-me");
|
||||
assert_eq!(v["scope"], "kimi-code"); // resp.scope None → 原值保留
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires_at = v["expires_at"].as_i64().unwrap();
|
||||
assert!(expires_at > now + 800 && expires_at <= now + 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_without_rotated_refresh_keeps_old_refresh_token() {
|
||||
let current = r#"{"access_token": "old", "refresh_token": "old-refresh", "expires_at": 1}"#;
|
||||
let resp = TokenResponse {
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: None,
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
};
|
||||
let merged = merge_token_response(current, &resp);
|
||||
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(v["refresh_token"], "old-refresh");
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
icon_color: None,
|
||||
raw_other: provider_raw,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
};
|
||||
|
||||
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// 鉴权头形式:绝大多数供应商用 `Bearer <key>`;智谱套餐接口不加前缀(见 coding_plan)。
|
||||
pub(crate) enum AuthStyle {
|
||||
@@ -41,9 +41,10 @@ pub(crate) async fn get_json(
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
auth: AuthStyle,
|
||||
timeout: Duration,
|
||||
) -> Result<Fetched, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
@@ -96,8 +97,8 @@ pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f6
|
||||
// GET https://api.deepseek.com/user/balance
|
||||
// Response: { balance_infos: [{ currency, total_balance, ... }], is_available }
|
||||
|
||||
pub async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? {
|
||||
pub async fn query_deepseek(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
@@ -132,8 +133,8 @@ fn parse_deepseek(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// GET https://api.stepfun.com/v1/accounts
|
||||
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
||||
|
||||
pub async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? {
|
||||
pub async fn query_stepfun(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
@@ -153,14 +154,14 @@ fn parse_stepfun(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// GET https://api.siliconflow.cn/v1/user/info (.cn;海外站 .com 单位 USD)
|
||||
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
||||
|
||||
pub async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||
pub async fn query_siliconflow(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.siliconflow.cn"
|
||||
} else {
|
||||
"api.siliconflow.com"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/user/info");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer).await? {
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
@@ -196,11 +197,12 @@ fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageD
|
||||
// GET https://openrouter.ai/api/v1/credits
|
||||
// Response: { data: { total_credits, total_usage } }
|
||||
|
||||
pub async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_openrouter(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -230,11 +232,12 @@ fn parse_openrouter(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// GET https://api.novita.ai/v3/user/balance
|
||||
// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。
|
||||
|
||||
pub async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_novita(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.novita.ai/v3/user/balance",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -254,6 +257,251 @@ fn parse_novita(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
}]
|
||||
}
|
||||
|
||||
// ── Kimi (Moonshot) 开放平台 ─────────────────────────────────
|
||||
// GET https://api.moonshot.cn/v1/users/me/balance(国内站;国际站 api.moonshot.ai)
|
||||
// 官方文档:https://platform.kimi.com/docs/api/balance
|
||||
// Response: { code, data: { available_balance, voucher_balance, cash_balance }, scode, status }
|
||||
// code != 0 或 status == false 为业务失败(确定性);available_balance 单位人民币元。
|
||||
// 注:国际站货币单位未见于官方文档,先按 USD 处理,待实测确认(与 SiliconFlow 做法一致)。
|
||||
|
||||
pub async fn query_kimi(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.moonshot.cn"
|
||||
} else {
|
||||
"api.moonshot.ai"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/users/me/balance");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_kimi(&body, is_cn) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
}),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kimi(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageData>, UsageResult> {
|
||||
// 业务级失败:code != 0 / status == false
|
||||
if body.get("code").and_then(|v| v.as_i64()) != Some(0)
|
||||
|| body.get("status").and_then(|v| v.as_bool()) == Some(false)
|
||||
{
|
||||
let msg = body
|
||||
.get("scode")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(UsageResult::failure(format!("API error: {msg}")));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(UsageResult::failure(
|
||||
"Missing 'data' field in response".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let available = parse_f64_field(data, "available_balance").unwrap_or(0.0);
|
||||
Ok(vec![UsageData {
|
||||
plan_name: Some("Kimi".to_string()),
|
||||
remaining: Some(available),
|
||||
unit: Some(if is_cn {
|
||||
"CNY".to_string()
|
||||
} else {
|
||||
"USD".to_string()
|
||||
}),
|
||||
is_valid: Some(available > 0.0),
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
// ── NewAPI / OneAPI 中转站 ────────────────────────────────────
|
||||
// GET {base_url}/api/user/self
|
||||
// 头:Authorization: Bearer <access_token>(网页后台 Access Token,非 sk- key)
|
||||
// New-Api-User: <user_id>
|
||||
// Response: { success, message, data: { quota, used_quota, username, ... } }
|
||||
// 金额换算:quota / quota_per_unit(从 {base_url}/api/status 拉取,站点可配,
|
||||
// 典型 500000 = 1 货币单位)。display_in_currency=true 时单位按
|
||||
// custom_currency_symbol(¥/$/¤),此处统一记 "credit" 由前端按 symbol 显示。
|
||||
//
|
||||
// 坑位(cc-switch 实测 + 本站验证):
|
||||
// - sk- 推理令牌不能查本接口(401 invalid access token),必须网页 Access Token。
|
||||
// - quota == 0 常表示"无限额度"(后台设置),不能显示成余额 0。
|
||||
|
||||
const DEFAULT_QUOTA_PER_UNIT: f64 = 500_000.0;
|
||||
|
||||
/// /api/status 的进程内缓存(key = base_url)。站点配置几乎不变,TTL 与
|
||||
/// usage 缓存一致(5 分钟)足够。
|
||||
static QUOTA_PER_UNIT_CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, f64, String)>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
fn quota_cache() -> &'static std::sync::Mutex<
|
||||
std::collections::HashMap<String, (std::time::Instant, f64, String)>,
|
||||
> {
|
||||
QUOTA_PER_UNIT_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// 拉取站点的 quota_per_unit 与货币符号;失败回退默认值。
|
||||
async fn fetch_newapi_status(base_url: &str, timeout: Duration) -> (f64, String) {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
{
|
||||
let cache = quota_cache().lock().unwrap();
|
||||
if let Some((ts, qpu, symbol)) = cache.get(base) {
|
||||
if ts.elapsed() < std::time::Duration::from_secs(300) {
|
||||
return (*qpu, symbol.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return (DEFAULT_QUOTA_PER_UNIT, "USD".to_string()),
|
||||
};
|
||||
let url = format!("{base}/api/status");
|
||||
let fallback = || (DEFAULT_QUOTA_PER_UNIT, "USD".to_string());
|
||||
let result = match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(raw) => match serde_json::from_slice::<serde_json::Value>(&raw) {
|
||||
Ok(v) => {
|
||||
let qpu = v
|
||||
.get("data")
|
||||
.and_then(|d| d.get("quota_per_unit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|&n| n > 0.0)
|
||||
.unwrap_or(DEFAULT_QUOTA_PER_UNIT);
|
||||
let symbol = v
|
||||
.get("data")
|
||||
.and_then(|d| d.get("custom_currency_symbol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("$")
|
||||
.to_string();
|
||||
(qpu, symbol)
|
||||
}
|
||||
Err(_) => fallback(),
|
||||
},
|
||||
Err(_) => fallback(),
|
||||
},
|
||||
_ => fallback(),
|
||||
};
|
||||
|
||||
let mut cache = quota_cache().lock().unwrap();
|
||||
cache.insert(
|
||||
base.to_string(),
|
||||
(std::time::Instant::now(), result.0, result.1.clone()),
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn query_newapi(
|
||||
base_url: &str,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<UsageResult, String> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/api/user/self");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("New-Api-User", user_id)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Ok(UsageResult::failure(format!(
|
||||
"Authentication failed (HTTP {status})"
|
||||
)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Ok(UsageResult::failure(format!(
|
||||
"API error (HTTP {status}): {body}"
|
||||
)));
|
||||
}
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return Ok(UsageResult::failure(format!(
|
||||
"Failed to parse response: {e}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let (quota_per_unit, symbol) = fetch_newapi_status(base_url, timeout).await;
|
||||
Ok(match parse_newapi(&body, quota_per_unit, &symbol) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_newapi(
|
||||
body: &serde_json::Value,
|
||||
quota_per_unit: f64,
|
||||
symbol: &str,
|
||||
) -> Result<Vec<UsageData>, UsageResult> {
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(UsageResult::failure(format!("API error: {msg}")));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(UsageResult::failure(
|
||||
"Missing 'data' field in response".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let quota_raw = parse_f64_field(data, "quota").unwrap_or(0.0);
|
||||
let used_raw = parse_f64_field(data, "used_quota").unwrap_or(0.0);
|
||||
|
||||
// quota == 0 && used == 0:站点把该令牌设为"无限额度",没有数值可显示。
|
||||
if quota_raw == 0.0 && used_raw == 0.0 {
|
||||
return Ok(vec![UsageData {
|
||||
plan_name: Some("NewAPI".to_string()),
|
||||
unit: Some(symbol.to_string()),
|
||||
is_valid: Some(true),
|
||||
..Default::default()
|
||||
}]);
|
||||
}
|
||||
|
||||
let remaining = quota_raw / quota_per_unit;
|
||||
let used = used_raw / quota_per_unit;
|
||||
let total = remaining + used;
|
||||
Ok(vec![UsageData {
|
||||
plan_name: Some("NewAPI".to_string()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total),
|
||||
used: Some(used),
|
||||
unit: Some(symbol.to_string()),
|
||||
is_valid: Some(remaining > 0.0),
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -323,4 +571,102 @@ mod tests {
|
||||
let zero = parse_novita(&json!({ "availableBalance": 0 }));
|
||||
assert_eq!(zero[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_parses_balance() {
|
||||
let body = json!({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"available_balance": 49.58894,
|
||||
"voucher_balance": 46.58893,
|
||||
"cash_balance": 3.00001
|
||||
},
|
||||
"scode": "0x0",
|
||||
"status": true
|
||||
});
|
||||
let data = parse_kimi(&body, true).unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("Kimi"));
|
||||
assert_eq!(data[0].remaining, Some(49.58894));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_en_uses_usd_unit() {
|
||||
let body = json!({ "code": 0, "data": { "available_balance": 12.5 }, "status": true });
|
||||
let data = parse_kimi(&body, false).unwrap();
|
||||
assert_eq!(data[0].unit.as_deref(), Some("USD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "code": 401, "scode": "0x191", "status": false });
|
||||
let err = parse_kimi(&body, true).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("0x191"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_missing_data_is_deterministic_failure() {
|
||||
let body = json!({ "code": 0, "status": true });
|
||||
let err = parse_kimi(&body, true).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("Missing 'data'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_zero_balance_is_invalid() {
|
||||
let body = json!({ "code": 0, "data": { "available_balance": 0.0 }, "status": true });
|
||||
let data = parse_kimi(&body, true).unwrap();
|
||||
assert_eq!(data[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_parses_quota_with_unit_division() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "quota": 36_125_000, "used_quota": 2_500_000, "username": "alice" }
|
||||
});
|
||||
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("NewAPI"));
|
||||
assert_eq!(data[0].remaining, Some(72.25));
|
||||
assert_eq!(data[0].used, Some(5.0));
|
||||
assert_eq!(data[0].total, Some(77.25));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("¥"));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "success": false, "message": "Unauthorized, invalid access token" });
|
||||
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("invalid access token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_missing_data_is_deterministic_failure() {
|
||||
let body = json!({ "success": true });
|
||||
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
|
||||
assert!(err.error.unwrap().contains("Missing 'data'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_zero_quota_means_unlimited() {
|
||||
let body = json!({ "success": true, "data": { "quota": 0, "used_quota": 0 } });
|
||||
let data = parse_newapi(&body, 500_000.0, "$").unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].remaining, None);
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_accepts_string_numbers() {
|
||||
let body = json!({ "success": true, "data": { "quota": "500000", "used_quota": "100000" } });
|
||||
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
|
||||
assert_eq!(data[0].remaining, Some(1.0));
|
||||
assert_eq!(data[0].used, Some(0.2));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
use super::balance::{get_json, AuthStyle, Fetched};
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增)
|
||||
// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定
|
||||
// 做本地化映射——新增 tier id 时必须同步加映射。
|
||||
const TIER_FIVE_HOUR: &str = "five_hour";
|
||||
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
@@ -66,11 +70,12 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
|
||||
// usage: { limit, remaining, resetTime } }
|
||||
|
||||
pub async fn query_kimi_coding(api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -196,12 +201,12 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_zhipu(base_url: &str, api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
let url = format!(
|
||||
"{}/api/monitor/usage/quota/limit",
|
||||
zhipu_quota_base(base_url)
|
||||
);
|
||||
match get_json(&url, api_key, AuthStyle::Raw).await? {
|
||||
match get_json(&url, api_key, AuthStyle::Raw, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
@@ -215,14 +220,14 @@ pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, S
|
||||
// (海外站 api.minimax.io)
|
||||
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
|
||||
|
||||
pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||
pub async fn query_minimax(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.minimaxi.com"
|
||||
} else {
|
||||
"api.minimax.io"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer).await? {
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_minimax(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum UsageKind {
|
||||
BalanceOpenrouter,
|
||||
BalanceStepfun,
|
||||
BalanceNovita,
|
||||
BalanceKimi,
|
||||
BalanceNewapi,
|
||||
PlanKimiCoding,
|
||||
PlanZhipu,
|
||||
PlanMinimax,
|
||||
@@ -35,18 +37,22 @@ impl UsageKind {
|
||||
UsageKind::BalanceOpenrouter => "balance:openrouter",
|
||||
UsageKind::BalanceStepfun => "balance:stepfun",
|
||||
UsageKind::BalanceNovita => "balance:novita",
|
||||
UsageKind::BalanceKimi => "balance:kimi",
|
||||
UsageKind::BalanceNewapi => "balance:newapi",
|
||||
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
||||
UsageKind::PlanZhipu => "plan:zhipu",
|
||||
UsageKind::PlanMinimax => "plan:minimax",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [UsageKind; 8] = [
|
||||
pub const ALL: [UsageKind; 10] = [
|
||||
UsageKind::BalanceDeepseek,
|
||||
UsageKind::BalanceSiliconflow,
|
||||
UsageKind::BalanceOpenrouter,
|
||||
UsageKind::BalanceStepfun,
|
||||
UsageKind::BalanceNovita,
|
||||
UsageKind::BalanceKimi,
|
||||
UsageKind::BalanceNewapi,
|
||||
UsageKind::PlanKimiCoding,
|
||||
UsageKind::PlanZhipu,
|
||||
UsageKind::PlanMinimax,
|
||||
@@ -63,6 +69,8 @@ impl std::str::FromStr for UsageKind {
|
||||
"balance:openrouter" => UsageKind::BalanceOpenrouter,
|
||||
"balance:stepfun" => UsageKind::BalanceStepfun,
|
||||
"balance:novita" => UsageKind::BalanceNovita,
|
||||
"balance:kimi" => UsageKind::BalanceKimi,
|
||||
"balance:newapi" => UsageKind::BalanceNewapi,
|
||||
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
||||
"plan:zhipu" => UsageKind::PlanZhipu,
|
||||
"plan:minimax" => UsageKind::PlanMinimax,
|
||||
@@ -91,6 +99,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
if url.contains("api.novita.ai") {
|
||||
kinds.push(UsageKind::BalanceNovita);
|
||||
}
|
||||
// Kimi 开放平台(Moonshot):国内站 api.moonshot.cn / 国际站 api.moonshot.ai。
|
||||
// 注意别与 api.kimi.com(Kimi Code 官方端点)混淆:后者只有 /coding 路径命中套餐。
|
||||
if url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai") {
|
||||
kinds.push(UsageKind::BalanceKimi);
|
||||
}
|
||||
if url.contains("api.kimi.com") && url.contains("/coding") {
|
||||
kinds.push(UsageKind::PlanKimiCoding);
|
||||
}
|
||||
@@ -105,24 +118,53 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
|
||||
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
|
||||
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
|
||||
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
|
||||
/// 其余分支忽略;调用方在 templateType=="newapi" 时保证其存在。
|
||||
pub async fn query_kind(
|
||||
kind: UsageKind,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
usage_config: Option<&crate::models::UsageConfig>,
|
||||
) -> Result<UsageResult, String> {
|
||||
let lower = base_url.to_lowercase();
|
||||
// 用户配置的超时(秒);0/未配置回退默认 8s。
|
||||
let timeout = usage_config
|
||||
.and_then(|c| c.timeout_seconds)
|
||||
.filter(|&s| s > 0)
|
||||
.map(std::time::Duration::from_secs)
|
||||
.unwrap_or(balance::REQUEST_TIMEOUT);
|
||||
match kind {
|
||||
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await,
|
||||
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key, timeout).await,
|
||||
UsageKind::BalanceSiliconflow => {
|
||||
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await
|
||||
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com"), timeout).await
|
||||
}
|
||||
UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key).await,
|
||||
UsageKind::BalanceStepfun => balance::query_stepfun(api_key).await,
|
||||
UsageKind::BalanceNovita => balance::query_novita(api_key).await,
|
||||
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key).await,
|
||||
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key).await,
|
||||
UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key, timeout).await,
|
||||
UsageKind::BalanceStepfun => balance::query_stepfun(api_key, timeout).await,
|
||||
UsageKind::BalanceNovita => balance::query_novita(api_key, timeout).await,
|
||||
UsageKind::BalanceKimi => {
|
||||
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
|
||||
}
|
||||
UsageKind::BalanceNewapi => {
|
||||
let (token, uid) = usage_config
|
||||
.and_then(|c| {
|
||||
c.access_token
|
||||
.as_deref()
|
||||
.zip(c.user_id.as_deref())
|
||||
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"newapi template requires accessToken and userId".to_string()
|
||||
})?;
|
||||
let url = usage_config
|
||||
.and_then(|c| c.base_url.as_deref())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(base_url);
|
||||
balance::query_newapi(url, token, uid, timeout).await
|
||||
}
|
||||
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key, timeout).await,
|
||||
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key, timeout).await,
|
||||
UsageKind::PlanMinimax => {
|
||||
coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await
|
||||
coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,12 +175,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detect_provider_maps_known_hosts() {
|
||||
let cases: [(&str, UsageKind); 8] = [
|
||||
let cases: [(&str, UsageKind); 9] = [
|
||||
("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek),
|
||||
("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow),
|
||||
("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter),
|
||||
("https://api.stepfun.com/v1", UsageKind::BalanceStepfun),
|
||||
("https://api.novita.ai/v3", UsageKind::BalanceNovita),
|
||||
("https://api.moonshot.cn/v1", UsageKind::BalanceKimi),
|
||||
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
|
||||
(
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
@@ -165,7 +208,7 @@ mod tests {
|
||||
assert!(detect_provider("https://api.openai.com/v1").is_empty());
|
||||
assert!(detect_provider("https://example.com").is_empty());
|
||||
assert!(detect_provider("").is_empty());
|
||||
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询
|
||||
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询,也不命中 Moonshot 余额
|
||||
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.5",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["msi", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+39
-2
@@ -8,6 +8,7 @@ import { ProviderEdit } from "./components/ProviderEdit";
|
||||
import { DashboardPage } from "./components/dashboard/DashboardPage";
|
||||
import { SessionsPage } from "./components/sessions/SessionsPage";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import { UsageConfigModal } from "./components/UsageConfigModal";
|
||||
import { PresetPickerModal } from "./components/PresetPickerModal";
|
||||
import { useTranslation } from "./i18n";
|
||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
type ProviderPreset,
|
||||
} from "./config/providerPresets";
|
||||
import { validateProviders } from "./lib/validation";
|
||||
import type { Agent, Model, Provider } from "./types";
|
||||
import type { Agent, Model, Provider, UsageConfig } from "./types";
|
||||
|
||||
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
||||
|
||||
@@ -75,6 +76,8 @@ export default function App() {
|
||||
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showPresetPicker, setShowPresetPicker] = useState(false);
|
||||
/** Provider whose usage-config panel is open; null = closed. */
|
||||
const [usageConfigProvider, setUsageConfigProvider] = useState<string | null>(null);
|
||||
// Provider names that have been added to the in-memory config but not
|
||||
// yet committed via save(). Pressing "back" in the edit form for one of
|
||||
// these drops it silently (no confirm); all other dirty navigation
|
||||
@@ -306,6 +309,24 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUsageConfig = (name: string, usageConfig: UsageConfig) => {
|
||||
updateConfig((cfg) => {
|
||||
const target = cfg.providers[name];
|
||||
if (!target) return cfg;
|
||||
return {
|
||||
...cfg,
|
||||
providers: {
|
||||
...cfg.providers,
|
||||
[name]: { ...target, usageConfig },
|
||||
},
|
||||
};
|
||||
});
|
||||
// Silent save: a normal save flips App to its loading screen, which
|
||||
// unmounts this panel and swallows the in-flight test query's result.
|
||||
// Returned so the panel's test can await persistence before querying.
|
||||
return save({ silent: true });
|
||||
};
|
||||
|
||||
const handleDuplicateProvider = async (name: string) => {
|
||||
updateConfig((cfg) => {
|
||||
const src = cfg.providers[name];
|
||||
@@ -524,7 +545,7 @@ export default function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
|
||||
onClick={refresh}
|
||||
onClick={() => refresh()}
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
@@ -633,6 +654,7 @@ export default function App() {
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onAdd={handleAddProvider}
|
||||
onSwitchProvider={handleSwitchProvider}
|
||||
onConfigureUsage={(name) => setUsageConfigProvider(name)}
|
||||
agent={agent}
|
||||
/>
|
||||
) : currentProvider ? (
|
||||
@@ -703,6 +725,11 @@ export default function App() {
|
||||
updateConfig((cfg) => {
|
||||
const updated = { ...cfg.models };
|
||||
for (const m of models) {
|
||||
// Skip if this model id already exists for the same provider
|
||||
const dup = Object.values(updated).some(
|
||||
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
|
||||
);
|
||||
if (dup) continue;
|
||||
let alias = m.alias;
|
||||
let n = 1;
|
||||
while (alias in updated) {
|
||||
@@ -797,6 +824,16 @@ export default function App() {
|
||||
onSelect={handleSelectPreset}
|
||||
onCustom={handleAddCustomProvider}
|
||||
/>
|
||||
|
||||
{usageConfigProvider && config.providers[usageConfigProvider] && (
|
||||
<UsageConfigModal
|
||||
open={usageConfigProvider != null}
|
||||
agent={agent}
|
||||
provider={config.providers[usageConfigProvider]}
|
||||
onClose={() => setUsageConfigProvider(null)}
|
||||
onSave={handleSaveUsageConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
|
||||
import {
|
||||
providerPresets,
|
||||
type BillingMode,
|
||||
type PresetCategory,
|
||||
type ProviderPreset,
|
||||
} from "../config/providerPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
interface PresetPickerModalProps {
|
||||
@@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
|
||||
aggregator: "presetCategoryAggregator",
|
||||
};
|
||||
|
||||
const BILLING_BADGE: Record<BillingMode, string> = {
|
||||
subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400",
|
||||
pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400",
|
||||
};
|
||||
|
||||
const BILLING_LABEL: Record<BillingMode, TranslationKey> = {
|
||||
subscription: "billingBadgeSubscription",
|
||||
pay_as_you_go: "billingBadgePayAsYouGo",
|
||||
};
|
||||
|
||||
type SortMode = "original" | "alpha";
|
||||
type BillingTab = "all" | BillingMode;
|
||||
|
||||
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
|
||||
|
||||
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("original");
|
||||
const [billingTab, setBillingTab] = useState<BillingTab>("all");
|
||||
|
||||
// Esc to close
|
||||
useEffect(() => {
|
||||
@@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSort("original");
|
||||
setBillingTab("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Counts per billing tab — shown in the tab labels so users see how many
|
||||
// presets are in each bucket without having to click through.
|
||||
const billingCounts = useMemo(() => {
|
||||
let sub = 0;
|
||||
let payg = 0;
|
||||
for (const p of providerPresets) {
|
||||
if (p.billingMode === "subscription") sub++;
|
||||
else payg++;
|
||||
}
|
||||
return { subscription: sub, pay_as_you_go: payg, all: sub + payg };
|
||||
}, []);
|
||||
|
||||
const presetName = (p: ProviderPreset) =>
|
||||
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let list = providerPresets;
|
||||
if (billingTab !== "all") {
|
||||
list = list.filter((p) => p.billingMode === billingTab);
|
||||
}
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
|
||||
@@ -77,7 +112,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
}
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, sort, t]);
|
||||
}, [query, sort, billingTab, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -110,39 +145,67 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + sort */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("presetSearchPlaceholder")}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center rounded border border-border bg-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("original")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "original"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortOriginal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("alpha")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "alpha"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortAlpha")}
|
||||
</button>
|
||||
{/* Billing-mode tabs + search + sort */}
|
||||
<div className="flex flex-col gap-2 border-b border-border px-5 py-3">
|
||||
<div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
|
||||
{BILLING_TAB_ORDER.map((tab) => {
|
||||
const labelKey: TranslationKey =
|
||||
tab === "all"
|
||||
? "billingTabAll"
|
||||
: tab === "subscription"
|
||||
? "billingTabSubscription"
|
||||
: "billingTabPayAsYouGo";
|
||||
const count = billingCounts[tab];
|
||||
const active = billingTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setBillingTab(tab)}
|
||||
className={`px-2.5 py-1 text-xs rounded transition-colors ${
|
||||
active
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t(labelKey)} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("presetSearchPlaceholder")}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center rounded border border-border bg-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("original")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "original"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortOriginal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("alpha")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "alpha"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortAlpha")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,13 +231,20 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
||||
<div className="w-full truncate text-sm font-medium text-content-primary">
|
||||
{presetName(p)}
|
||||
</div>
|
||||
{p.category !== "custom" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.category !== "custom" && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||
>
|
||||
{t(CATEGORY_LABEL[p.category])}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${BILLING_BADGE[p.billingMode]}`}
|
||||
>
|
||||
{t(CATEGORY_LABEL[p.category])}
|
||||
{t(BILLING_LABEL[p.billingMode])}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { findPresetForProvider } from "../config/providerPresets";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
@@ -117,6 +118,10 @@ export function ProviderEdit({
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
const preset = findPresetForProvider(provider);
|
||||
|
||||
useEffect(() => {
|
||||
const def = defaultBaseUrl(agent, provider.provider_type);
|
||||
if (!def) return;
|
||||
@@ -215,7 +220,7 @@ export function ProviderEdit({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("providerName")}
|
||||
{t("providerName")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
@@ -296,7 +301,7 @@ export function ProviderEdit({
|
||||
{!provider.managed && (
|
||||
<div>
|
||||
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("apiKey")}
|
||||
{t("apiKey")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -316,12 +321,29 @@ export function ProviderEdit({
|
||||
{showApiKey ? t("hide") : t("show")}
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
|
||||
if (!linkUrl) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center gap-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
invoke("open_external_url", { url: linkUrl }).catch(() => {})
|
||||
}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{preset?.apiKeyUrl ? t("getApiKeyLink") : t("officialUrl")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("requestUrl")}
|
||||
{t("requestUrl")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={baseUrlId}
|
||||
@@ -459,6 +481,9 @@ function ModelMapping({
|
||||
provider,
|
||||
});
|
||||
setDiscovered(result);
|
||||
// Auto-select models that already exist in the current provider's list
|
||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||
setSelected(new Set(result.filter((dm) => existingIds.has(dm.id)).map((dm) => dm.id)));
|
||||
} catch (err) {
|
||||
setDiscoverError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -477,9 +502,11 @@ function ModelMapping({
|
||||
|
||||
const handleAddSelected = () => {
|
||||
if (!discovered || selected.size === 0) return;
|
||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||
const toAdd: Model[] = [];
|
||||
for (const dm of discovered) {
|
||||
if (!selected.has(dm.id)) continue;
|
||||
if (existingIds.has(dm.id)) continue; // skip models already in this provider
|
||||
const safeProvider = provider.name.replace(/\//g, "-");
|
||||
const safeModelId = dm.id.replace(/\//g, "-");
|
||||
const alias = `${safeProvider}/${safeModelId}`;
|
||||
@@ -555,7 +582,9 @@ function ModelMapping({
|
||||
{t("fetchEnableThinking")}
|
||||
</label>
|
||||
<div className="max-h-48 overflow-auto space-y-1">
|
||||
{discovered.map((m) => (
|
||||
{discovered.map((m) => {
|
||||
const exists = models.some((mm) => mm.model === m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
|
||||
@@ -569,8 +598,12 @@ function ModelMapping({
|
||||
{m.display_name && (
|
||||
<span className="text-content-muted">({m.display_name})</span>
|
||||
)}
|
||||
{exists && (
|
||||
<span className="ml-auto text-[10px] text-emerald-500 shrink-0">{t("alreadyAdded")}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { UsageFooter } from "./UsageFooter";
|
||||
@@ -28,6 +28,7 @@ interface ProviderListProps {
|
||||
onDuplicate: (name: string) => void;
|
||||
onAdd: () => void;
|
||||
onSwitchProvider: (name: string) => void;
|
||||
onConfigureUsage: (name: string) => void;
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
@@ -43,11 +44,17 @@ export function ProviderList({
|
||||
onDuplicate,
|
||||
onAdd,
|
||||
onSwitchProvider,
|
||||
onConfigureUsage,
|
||||
agent,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [testState, setTestState] = useState<Record<string, TestState>>({});
|
||||
|
||||
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
|
||||
const sortedProviders = [...providers].sort(
|
||||
(a, b) => Number(b.managed === true) - Number(a.managed === true)
|
||||
);
|
||||
|
||||
const handleTest = async (provider: Provider) => {
|
||||
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
|
||||
try {
|
||||
@@ -132,7 +139,7 @@ export function ProviderList({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 w-full">
|
||||
{providers.map((provider) => {
|
||||
{sortedProviders.map((provider) => {
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
);
|
||||
@@ -217,6 +224,19 @@ export function ProviderList({
|
||||
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
||||
</span>
|
||||
)}
|
||||
{/* compact usage summary — sits left of the switch button,
|
||||
mirroring cc-switch's card layout (usage → action buttons) */}
|
||||
{(provider.usageKinds?.length ?? 0) > 0 && (
|
||||
<UsageFooter
|
||||
agent={agent}
|
||||
providerName={provider.name}
|
||||
usageKinds={provider.usageKinds}
|
||||
variant="compact"
|
||||
autoIntervalMinutes={
|
||||
provider.usageConfig?.autoQueryIntervalMinutes
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -283,6 +303,18 @@ export function ProviderList({
|
||||
<Activity className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigureUsage(provider.name);
|
||||
}}
|
||||
title={t("usageConfigBtn")}
|
||||
className={iconBtn}
|
||||
aria-label={t("usageConfigBtn")}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -301,6 +333,7 @@ export function ProviderList({
|
||||
agent={agent}
|
||||
providerName={provider.name}
|
||||
usageKinds={provider.usageKinds}
|
||||
variant="detail"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -110,6 +110,15 @@ export function SettingsModal({
|
||||
invoke("open_installer", { path: downloadedPath }).catch(() => {});
|
||||
};
|
||||
|
||||
// Open an external link in the system browser. Falls back to a new tab
|
||||
// when the opener plugin call fails, so a broken click never stays silent.
|
||||
const openExternal = (url: string) => {
|
||||
openUrl(url).catch(() => {
|
||||
const w = window.open(url, "_blank");
|
||||
if (!w) console.error(`failed to open ${url}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
|
||||
@@ -253,26 +262,66 @@ export function SettingsModal({
|
||||
type="button"
|
||||
onClick={onCheckUpdate}
|
||||
disabled={checking}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={checking ? "animate-spin" : ""}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
{checking ? t("checking") : t("checkUpdate")}
|
||||
</button>
|
||||
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{t("downloadUpdate")} ↓
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{t("downloadUpdate")}
|
||||
</button>
|
||||
)}
|
||||
{downloadedPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{t("install")} ⏎
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
{t("install")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,18 +372,25 @@ export function SettingsModal({
|
||||
<div className="font-medium text-content-primary">Kimi Switch</div>
|
||||
<div>{t("appDescription")}</div>
|
||||
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
|
||||
<div className="pt-2">
|
||||
<span className="text-content-muted">{t("referenceProject")}: </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openUrl("https://github.com/JochenYang/kimicode-dashboard")
|
||||
}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>
|
||||
<span className="text-content-muted"> (MIT, © JochenYang)</span>
|
||||
<div className="pt-2 space-y-1">
|
||||
<div>
|
||||
{t("attributionPorted")}{" "}
|
||||
<span className="font-medium text-content-primary">kimicode-dashboard</span>
|
||||
<span className="text-content-muted"> {t("attributionSuffix")}</span>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="text-content-muted">{t("referenceProject")}: </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openExternal("https://github.com/farion1231/cc-switch")
|
||||
}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
cc-switch
|
||||
</button>
|
||||
<span className="text-content-muted"> (MIT, © Jason Young)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
success: boolean;
|
||||
data?: Array<{
|
||||
planName?: string | null;
|
||||
remaining?: number | null;
|
||||
total?: number | null;
|
||||
used?: number | null;
|
||||
unit?: string | null;
|
||||
}> | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface UsageConfigModalProps {
|
||||
open: boolean;
|
||||
agent: Agent;
|
||||
provider: Provider;
|
||||
onClose: () => void;
|
||||
/** Persist the edited config (goes through updateConfig → silent save).
|
||||
* Awaited by the panel's test query so the backend sees the same config. */
|
||||
onSave: (providerName: string, config: UsageConfig) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const TEMPLATES: ReadonlyArray<{
|
||||
id: "auto" | "newapi";
|
||||
labelKey: TranslationKey;
|
||||
hintKey: TranslationKey;
|
||||
}> = [
|
||||
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
||||
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
||||
];
|
||||
|
||||
function defaultConfig(provider: Provider): UsageConfig {
|
||||
return (
|
||||
provider.usageConfig ?? {
|
||||
enabled: true,
|
||||
templateType: "auto",
|
||||
baseUrl: provider.base_url ?? undefined,
|
||||
autoQueryIntervalMinutes: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** cc-switch FullScreenPanel 行为:焦点在输入框/文本域时 ESC 不关闭面板。 */
|
||||
function isTextEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageConfigModal({ open, agent, provider, onClose, onSave }: UsageConfigModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState<UsageConfig>(() => defaultConfig(provider));
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testOk, setTestOk] = useState<boolean | null>(null);
|
||||
|
||||
// Re-seed when switching provider or reopening.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCfg(defaultConfig(provider));
|
||||
setTestResult(null);
|
||||
setTestOk(null);
|
||||
setShowToken(false);
|
||||
setShowApiKey(false);
|
||||
}
|
||||
}, [open, provider.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isTextEditableTarget(e.target)) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const isManaged = provider.managed === true;
|
||||
const detectedKinds = useMemo(() => provider.usageKinds ?? [], [provider.usageKinds]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(provider.name, {
|
||||
...cfg,
|
||||
templateType: isManaged ? "auto" : cfg.templateType,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
setTestOk(null);
|
||||
|
||||
// 短路:自动模板 + 无识别类型 → 不发请求,直接给出本地化提示 + 切换入口。
|
||||
// managed 供应商除外:它们走 OAuth 查询,与 detectedKinds 无关。
|
||||
if (!isManaged && cfg.templateType === "auto" && detectedKinds.length === 0) {
|
||||
setTestOk(false);
|
||||
setTestResult(t("usageUnsupportedProvider"));
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Persist first (awaited) so the backend query sees the same config
|
||||
// the user is testing.
|
||||
await onSave(provider.name, cfg);
|
||||
const result = await invoke<UsageResult>("query_provider_usage", {
|
||||
agent,
|
||||
providerName: provider.name,
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (result.success) {
|
||||
setTestOk(true);
|
||||
const summary = (result.data ?? [])
|
||||
.map((d) => {
|
||||
const parts: string[] = [];
|
||||
if (d.planName) parts.push(planLabel(d.planName, t));
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
|
||||
return parts.join(" ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
setTestResult(summary || t("usageQueryOk"));
|
||||
} else {
|
||||
setTestOk(false);
|
||||
setTestResult(result.error ? localizeUsageError(result.error, t) : t("usageQueryFailed"));
|
||||
}
|
||||
} catch {
|
||||
setTestOk(false);
|
||||
// Transient failure (network/timeout) — same label as the card footer.
|
||||
setTestResult(t("usageNetworkError"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col bg-app text-content-primary">
|
||||
{/* header */}
|
||||
<div className="h-16 shrink-0 flex items-center gap-4 px-6 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("usageBack")}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-content-muted hover:text-content-primary hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("usageConfigTitle", { name: provider.name })}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 space-y-5">
|
||||
{/* enable toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-hover/40 px-4 py-3">
|
||||
<span className="text-sm text-content-primary">{t("usageEnable")}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={cfg.enabled}
|
||||
onClick={() => setCfg({ ...cfg, enabled: !cfg.enabled })}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
cfg.enabled ? "bg-blue-600" : "bg-hover-2"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
cfg.enabled ? "left-5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cfg.enabled && (
|
||||
<>
|
||||
{/* template picker */}
|
||||
<div className="rounded-lg border border-border px-4 py-4 space-y-3">
|
||||
<label className="block text-sm font-medium text-content-primary">{t("usageTemplate")}</label>
|
||||
{isManaged ? (
|
||||
<div className="text-sm text-content-muted rounded-lg border border-border bg-hover/40 px-4 py-3">
|
||||
{t("usageOAuthManagedHint")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => setCfg({ ...cfg, templateType: tpl.id })}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
cfg.templateType === tpl.id
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "border-border text-content-primary hover:bg-hover-2"
|
||||
}`}
|
||||
>
|
||||
{t(tpl.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">
|
||||
{cfg.templateType === "newapi"
|
||||
? t("usageNewapiHint")
|
||||
: t("usageAutoDetectHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* supported variables (read-only, cc-switch 风格) */}
|
||||
<div className="pt-1 space-y-1.5">
|
||||
<span className="block text-xs text-content-muted">{t("usageSupportedVars")}</span>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
||||
<span className="text-content-muted">=</span>
|
||||
<span className="text-content-primary break-all">
|
||||
{cfg.templateType === "newapi"
|
||||
? cfg.baseUrl || provider.base_url || "-"
|
||||
: provider.base_url || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-blue-500">{"{{apiKey}}"}</span>
|
||||
<span className="text-content-muted">=</span>
|
||||
<span className="text-content-primary break-all">
|
||||
{provider.api_key
|
||||
? showApiKey
|
||||
? provider.api_key
|
||||
: "••••••••"
|
||||
: "-"}
|
||||
</span>
|
||||
{provider.api_key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* auto-detect: show resolved kinds as read-only chips */}
|
||||
{!isManaged && cfg.templateType === "auto" && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-content-muted">{t("usageDetectedKinds")}</label>
|
||||
{detectedKinds.length === 0 ? (
|
||||
<div className="rounded-lg border border-amber-300/40 bg-amber-50/40 dark:bg-amber-500/10 px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-content-primary">{t("usageNoKindsHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCfg({ ...cfg, templateType: "newapi" })}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t("usageSwitchToNewapi")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{detectedKinds.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border font-mono"
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* newapi creds */}
|
||||
{!isManaged && cfg.templateType === "newapi" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.baseUrl ?? ""}
|
||||
placeholder={provider.base_url ?? "https://your-newapi-site.com"}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">
|
||||
{t("usageAccessToken")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.accessToken ?? ""}
|
||||
placeholder={t("usageAccessTokenPlaceholder")}
|
||||
onChange={(e) => setCfg({ ...cfg, accessToken: e.target.value || undefined })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary"
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageAccessTokenHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">
|
||||
{t("usageUserId")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.userId ?? ""}
|
||||
placeholder={t("usageUserIdPlaceholder")}
|
||||
onChange={(e) => setCfg({ ...cfg, userId: e.target.value || undefined })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageUserIdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* timeout + auto query interval */}
|
||||
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.timeoutSeconds ?? 0}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
|
||||
setCfg({ ...cfg, timeoutSeconds: n });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageTimeoutHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageAutoInterval")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.autoQueryIntervalMinutes ?? 0}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
|
||||
setCfg({ ...cfg, autoQueryIntervalMinutes: n });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* test result - sticky bottom inside scroll container so it's always visible above the footer */}
|
||||
</div>
|
||||
{testResult && (
|
||||
<div className="sticky bottom-0">
|
||||
<div className="max-w-3xl mx-auto px-6 pb-6">
|
||||
<div
|
||||
className={`text-sm rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm ${
|
||||
testOk
|
||||
? "border-green-300 dark:border-green-500/30 bg-green-50/95 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "border-red-300 dark:border-red-500/30 bg-red-50/95 dark:bg-red-900/30 text-red-600 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !cfg.enabled}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
{t("usageTestQuery")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t("saveConfig")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent } from "../types";
|
||||
|
||||
interface UsageData {
|
||||
@@ -31,7 +32,8 @@ interface CacheEntry {
|
||||
}
|
||||
|
||||
// Module-level cache shared across mounts: re-entering the list within the
|
||||
// stale TTL shows the last result without firing new requests.
|
||||
// stale TTL shows the last result without firing new requests. Both the
|
||||
// compact (card header) and detail (footer) variants read this same cache.
|
||||
const STALE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
@@ -54,9 +56,21 @@ interface UsageFooterProps {
|
||||
agent: Agent;
|
||||
providerName: string;
|
||||
usageKinds?: string[];
|
||||
/** "detail" (default) renders the multi-line footer; "compact" renders a
|
||||
* one-line summary + last-updated + refresh button for the card header. */
|
||||
variant?: "detail" | "compact";
|
||||
/** Auto query interval in minutes; only wired on the compact variant so
|
||||
* both variants do not double-fire. 0/undefined = manual only. */
|
||||
autoIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
|
||||
export function UsageFooter({
|
||||
agent,
|
||||
providerName,
|
||||
usageKinds,
|
||||
variant = "detail",
|
||||
autoIntervalMinutes,
|
||||
}: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
@@ -67,6 +81,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
const [data, setData] = useState<UsageData[]>([]);
|
||||
/** undefined = no error; null = network error; string = Rust error text. */
|
||||
const [error, setError] = useState<string | null | undefined>(undefined);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// Generation counter: stale responses (unmounted / superseded query) are ignored.
|
||||
const genRef = useRef(0);
|
||||
@@ -82,6 +97,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
setStatus(entry.status);
|
||||
setData(entry.data);
|
||||
setError(entry.error);
|
||||
setUpdatedAt(entry.updatedAt);
|
||||
};
|
||||
try {
|
||||
await acquireSlot();
|
||||
@@ -133,6 +149,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
setStatus(cached.status);
|
||||
setData(cached.data);
|
||||
setError(cached.error);
|
||||
setUpdatedAt(cached.updatedAt);
|
||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||
}
|
||||
void runQuery(false);
|
||||
@@ -145,7 +162,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tick the reset countdown once a minute while showing data.
|
||||
// Auto query interval (compact variant only, to avoid double-firing).
|
||||
useEffect(() => {
|
||||
if (variant !== "compact" || !supported) return;
|
||||
const mins = autoIntervalMinutes ?? 0;
|
||||
if (mins <= 0) return;
|
||||
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [variant, supported, autoIntervalMinutes, runQuery]);
|
||||
|
||||
// Tick relative times once a minute while showing data.
|
||||
useEffect(() => {
|
||||
if (status !== "success") return;
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000);
|
||||
@@ -185,6 +211,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
|
||||
};
|
||||
|
||||
const formatAgo = (ts: number | null): string | null => {
|
||||
if (ts == null) return null;
|
||||
const mins = Math.floor((now - ts) / 60_000);
|
||||
if (mins < 1) return t("usageUpdatedAgo", { time: "1m" });
|
||||
if (mins < 60) return t("usageMinAgo", { n: mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t("usageHourAgo", { n: hours });
|
||||
return t("usageDayAgo", { n: Math.floor(hours / 24) });
|
||||
};
|
||||
|
||||
const refreshBtn = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -195,7 +231,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
disabled={status === "loading"}
|
||||
title={t("usageRefresh")}
|
||||
aria-label={t("usageRefresh")}
|
||||
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@@ -205,6 +241,79 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
</button>
|
||||
);
|
||||
|
||||
/** One-line summary of the primary entry (first tier / balance). */
|
||||
const compactSummary = (): { icon: string; text: string; color: string } | null => {
|
||||
const d = data[0];
|
||||
if (!d) return null;
|
||||
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
|
||||
// entries carry only remaining + unit — their plan_name is a currency /
|
||||
// brand label and must render as an amount, not a percentage.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
if (d.isValid === false) {
|
||||
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
|
||||
}
|
||||
if (d.planName === "NewAPI" && d.remaining == null && d.total == null) {
|
||||
return { icon: "💰", text: `${t("usageBalance")} ∞`, color: "text-green-600 dark:text-green-400" };
|
||||
}
|
||||
if (isPlan) {
|
||||
const pct = percentOf(d);
|
||||
const color =
|
||||
pct == null
|
||||
? "text-content-muted"
|
||||
: pct < 70
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: pct < 90
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-500 dark:text-red-400";
|
||||
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} ` : "";
|
||||
return {
|
||||
icon: "⚡",
|
||||
text: pct != null ? `${label}${Math.round(pct)}%` : `${label}—`,
|
||||
color,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: "💰",
|
||||
text: formatAmount(d.remaining, d.unit),
|
||||
color: "text-content-primary",
|
||||
};
|
||||
};
|
||||
|
||||
if (variant === "compact") {
|
||||
if (status === "loading" && data.length === 0) {
|
||||
return <div className="h-3.5 w-20 rounded bg-hover-2 animate-pulse" />;
|
||||
}
|
||||
if (status === "error" && data.length === 0) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400">
|
||||
<span className="truncate max-w-[120px]" title={error ?? t("usageNetworkError")}>
|
||||
{error == null ? t("usageNetworkError") : localizeUsageError(error, t)}
|
||||
</span>
|
||||
{refreshBtn}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const s = compactSummary();
|
||||
const ghost = status !== "success";
|
||||
const ago = formatAgo(updatedAt);
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 text-xs tabular-nums ${ghost ? "opacity-50" : ""}`}>
|
||||
{s && (
|
||||
<>
|
||||
<span aria-hidden="true">{s.icon}</span>
|
||||
<span className={`font-medium ${s.color}`}>{s.text}</span>
|
||||
</>
|
||||
)}
|
||||
{ago && <span className="text-content-muted">{ago}</span>}
|
||||
{refreshBtn}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── detail variant ──
|
||||
// Loading with no prior data: skeleton bar.
|
||||
if (status === "loading" && data.length === 0) {
|
||||
return (
|
||||
@@ -224,7 +333,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
: /401|api[ -]?key|invalid key/i.test(error)
|
||||
? t("usageInvalidKey")
|
||||
: error
|
||||
? `${t("usageQueryFailed")} · ${error}`
|
||||
? `${t("usageQueryFailed")} · ${localizeUsageError(error, t)}`
|
||||
: t("usageQueryFailed");
|
||||
return (
|
||||
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
|
||||
@@ -246,6 +355,9 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
}
|
||||
|
||||
// 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;
|
||||
const ghost = status !== "success";
|
||||
return (
|
||||
<div
|
||||
@@ -254,7 +366,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
}`}
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const isPlan = !!d.planName;
|
||||
// Same plan-vs-balance rule as compactSummary: balance rows have no
|
||||
// quota fields (total/used), so they render as an amount.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
const pct = isPlan ? percentOf(d) : null;
|
||||
const color =
|
||||
pct == null
|
||||
@@ -270,7 +387,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
if (d.isValid === false) {
|
||||
main = t("usageInvalidKey");
|
||||
} else if (isPlan) {
|
||||
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
|
||||
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} · ` : "";
|
||||
main =
|
||||
pct != null
|
||||
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
|
||||
@@ -295,14 +412,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
{resetText && (
|
||||
<span className="text-content-muted shrink-0">{resetText}</span>
|
||||
)}
|
||||
{i === data.length - 1 && refreshBtn}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.length === 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-content-muted">—</span>
|
||||
{refreshBtn}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
|
||||
@@ -12,18 +12,20 @@ function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
<div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="flex-1 p-4">{children}</div>
|
||||
<div className="flex flex-1 flex-col p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
@@ -187,18 +189,29 @@ export function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||
{/* Heatmap — full row */}
|
||||
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
||||
<Heatmap heatmap={heatmap} />
|
||||
</Card>
|
||||
|
||||
{/* Charts + Model side table */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tabs + Model table side by side */}
|
||||
<div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
{/* Usage-trend tab container */}
|
||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||
{/* Tab bar header */}
|
||||
<div className="flex items-end justify-between border-b border-border px-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("daily")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "daily"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabDailyTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("model")}
|
||||
@@ -223,21 +236,29 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
{trendTab === "daily"
|
||||
? t("trendLineSub", { n: trendDaily.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Active tab content */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
{trendTab === "daily" ? (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<TrendLineChart daily={trendDaily} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<DailyBars
|
||||
daily={trendDaily}
|
||||
dimension={trendTab}
|
||||
names={trendTab === "model" ? modelNames : providerNames}
|
||||
unknownProviderLabel={t("providerUnknown")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +267,7 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
||||
@@ -260,7 +281,7 @@ export function DashboardPage() {
|
||||
{visibleModels.map((m) => (
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-border/50 hover:bg-hover"
|
||||
className="border-b border-border hover:bg-hover"
|
||||
>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -273,10 +294,10 @@ export function DashboardPage() {
|
||||
: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
|
||||
{m.costEstimated && (
|
||||
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
|
||||
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,8 +337,8 @@ export function DashboardPage() {
|
||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-auto max-h-[860px]">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-panel">
|
||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
||||
@@ -330,7 +351,7 @@ export function DashboardPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentPageRows.map((r, i) => (
|
||||
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
|
||||
<tr key={recentStart + i} className="border-b border-border hover:bg-hover">
|
||||
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
|
||||
{fmtTime(r.time)}
|
||||
</td>
|
||||
@@ -398,20 +419,12 @@ export function DashboardPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Attribution footer */}
|
||||
{/* Footer — meta at the end of the content flow */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
<br />
|
||||
{t("dashboardAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
<div>
|
||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||
{data.meta.home}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ const LEVEL_BG = [
|
||||
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const CELL = 12; // px
|
||||
const CELL = 16; // px
|
||||
const GAP = 3; // px
|
||||
const LABEL_W = 28; // dow label column width
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtTokens } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
|
||||
interface TrendLineChartProps {
|
||||
/** Per-day totals, already capped to the desired window by the caller. */
|
||||
daily: DailyRow[];
|
||||
}
|
||||
|
||||
const W = 800;
|
||||
const H = 130;
|
||||
const PAD_L = 56;
|
||||
const PAD_R = 56;
|
||||
const PAD_T = 10;
|
||||
const PAD_B = 22;
|
||||
const PLOT_W = W - PAD_L - PAD_R;
|
||||
const PLOT_H = H - PAD_T - PAD_B;
|
||||
|
||||
const COLOR_TOKENS = "#3b82f6"; // blue
|
||||
const COLOR_CACHE = "#22c55e"; // green
|
||||
const COLOR_REQUESTS = "#f97316"; // orange
|
||||
|
||||
/** Human-friendly token count for axis labels (K / M / B). */
|
||||
function fmtTokenAxis(value: number): string {
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
function niceMax(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const exp = Math.floor(Math.log10(value));
|
||||
const base = Math.pow(10, exp);
|
||||
const norm = value / base;
|
||||
let nice: number;
|
||||
if (norm <= 1) nice = 1;
|
||||
else if (norm <= 2) nice = 2;
|
||||
else if (norm <= 5) nice = 5;
|
||||
else nice = 10;
|
||||
return nice * base;
|
||||
}
|
||||
|
||||
export function TrendLineChart({ daily }: TrendLineChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showRequests, setShowRequests] = useState(true);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const maxTokens = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const maxRequests = useMemo(() => {
|
||||
if (!daily?.length) return 1;
|
||||
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
|
||||
}, [daily]);
|
||||
|
||||
const n = daily?.length ?? 0;
|
||||
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
|
||||
|
||||
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
|
||||
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
|
||||
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
|
||||
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
|
||||
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
|
||||
|
||||
const pathTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxTokens]);
|
||||
|
||||
const pathCache = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily]);
|
||||
|
||||
const pathRequests = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
return daily
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
|
||||
.join(" ");
|
||||
}, [daily, maxRequests]);
|
||||
|
||||
const areaTokens = useMemo(() => {
|
||||
if (!daily?.length) return "";
|
||||
const lastX = pointX(daily.length - 1);
|
||||
return (
|
||||
pathTokens +
|
||||
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
|
||||
);
|
||||
}, [pathTokens, daily]);
|
||||
|
||||
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
|
||||
const xTicks = useMemo(() => {
|
||||
if (!daily?.length) return [];
|
||||
if (n <= 6) return daily.map((_, i) => i);
|
||||
const step = Math.max(1, Math.round(n / 6));
|
||||
const set = new Set<number>([0, n - 1]);
|
||||
for (let i = step; i < n - 1; i += step) set.add(i);
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}, [daily, n]);
|
||||
|
||||
// Hover handling
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
if (!daily?.length) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const scale = W / rect.width;
|
||||
const x = (e.clientX - rect.left) * scale - PAD_L;
|
||||
if (x < 0 || x > PLOT_W) {
|
||||
setHoverIndex(null);
|
||||
return;
|
||||
}
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
|
||||
setHoverIndex(idx);
|
||||
};
|
||||
|
||||
const onLeave = () => setHoverIndex(null);
|
||||
|
||||
if (!daily?.length) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
|
||||
{t("trendLineNoData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = hoverIndex != null ? daily[hoverIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{/* Legend + toggle */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
|
||||
{t("trendLineLegendTokens")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
|
||||
{t("trendLineLegendCacheHit")}
|
||||
</span>
|
||||
{showRequests && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
|
||||
{t("trendLineLegendRequests")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRequests}
|
||||
onChange={(e) => setShowRequests(e.target.checked)}
|
||||
className="h-3 w-3 accent-blue-500"
|
||||
/>
|
||||
{t("trendLineShowRequests")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* Gradient definitions */}
|
||||
<defs>
|
||||
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const y = PAD_T + PLOT_H * (1 - p);
|
||||
return (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
y1={y}
|
||||
x2={W - PAD_R}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={p === 0 ? "" : "2 3"}
|
||||
opacity={p === 0 ? 0.9 : 0.4}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fill for tokens */}
|
||||
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
|
||||
|
||||
{/* Tokens line (area + line) */}
|
||||
<path
|
||||
d={pathTokens}
|
||||
fill="none"
|
||||
stroke={COLOR_TOKENS}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Cache hit line */}
|
||||
<path
|
||||
d={pathCache}
|
||||
fill="none"
|
||||
stroke={COLOR_CACHE}
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Requests line (optional) */}
|
||||
{showRequests && (
|
||||
<path
|
||||
d={pathRequests}
|
||||
fill="none"
|
||||
stroke={COLOR_REQUESTS}
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover crosshair + dot */}
|
||||
{hover && (
|
||||
<g pointerEvents="none">
|
||||
<line
|
||||
x1={pointX(hoverIndex!)}
|
||||
y1={PAD_T}
|
||||
x2={pointX(hoverIndex!)}
|
||||
y2={PAD_T + PLOT_H}
|
||||
stroke="currentColor"
|
||||
className="text-content-muted"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYTokens(hover.totalTokens || 0)}
|
||||
r={3.5}
|
||||
fill={COLOR_TOKENS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYCache(hover.cacheHitRate || 0)}
|
||||
r={3}
|
||||
fill={COLOR_CACHE}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{showRequests && (
|
||||
<circle
|
||||
cx={pointX(hoverIndex!)}
|
||||
cy={pointYReq(hover.requests || 0)}
|
||||
r={2.5}
|
||||
fill={COLOR_REQUESTS}
|
||||
stroke="currentColor"
|
||||
className="text-panel"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
|
||||
{/* Left Y-axis (tokens) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yl-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_L - 4) / W) * 100}%`,
|
||||
textAlign: "right",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{fmtTokenAxis(maxTokens * p)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* Right Y-axis (cache hit %) */}
|
||||
{[0, 0.5, 1].map((p) => {
|
||||
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||
return (
|
||||
<span
|
||||
key={`yr-${p}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
right: 0,
|
||||
top: `${top}%`,
|
||||
width: `${((PAD_R - 4) / W) * 100}%`,
|
||||
textAlign: "left",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{Math.round(p * 100)}%
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{xTicks.map((i) => (
|
||||
<span
|
||||
key={`x-${i}`}
|
||||
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||
style={{
|
||||
left: `${(pointX(i) / W) * 100}%`,
|
||||
bottom: 0,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{daily[i].date.slice(5)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Tooltip (HTML overlay for readability) */}
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
|
||||
style={{
|
||||
left: `${(pointX(hoverIndex!) / W) * 100}%`,
|
||||
top: 4,
|
||||
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{hover.date}</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
|
||||
</div>
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
{showRequests && (
|
||||
<div className="text-content-muted">
|
||||
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useSessions } from "../../hooks/useSessions";
|
||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@@ -547,16 +546,6 @@ export function SessionsPage() {
|
||||
{/* Attribution */}
|
||||
<div className="text-xs text-content-muted pb-2">
|
||||
{data?.home && <>{data.home}</>}
|
||||
<br />
|
||||
{t("sessionsAttribution")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
|
||||
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</button>{" "}
|
||||
{t("attributionSuffix")}
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
|
||||
@@ -13,10 +13,14 @@ export type UsageKind =
|
||||
| "balance:openrouter"
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "balance:kimi"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax";
|
||||
|
||||
/** Billing model for a preset. Drives the tab filter in PresetPickerModal. */
|
||||
export type BillingMode = "subscription" | "pay_as_you_go";
|
||||
|
||||
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
||||
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:deepseek",
|
||||
@@ -24,6 +28,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:openrouter",
|
||||
"balance:stepfun",
|
||||
"balance:novita",
|
||||
"balance:kimi",
|
||||
"plan:kimi_coding",
|
||||
"plan:zhipu",
|
||||
"plan:minimax",
|
||||
@@ -45,6 +50,7 @@ export interface ProviderPreset {
|
||||
nameKey?: string;
|
||||
/** Official website / console URL. */
|
||||
websiteUrl?: string;
|
||||
/** Where to create / fetch an API key; shown as a link under the key input. */
|
||||
apiKeyUrl?: string;
|
||||
/** Category: sorting & badge. */
|
||||
category: PresetCategory;
|
||||
@@ -71,6 +77,12 @@ export interface ProviderPreset {
|
||||
/** Defaults to ["thinking"]. */
|
||||
capabilities?: string[];
|
||||
}>;
|
||||
/**
|
||||
* Billing model — drives the 套餐/按量 tab in PresetPickerModal.
|
||||
* - "subscription": coding-plan / token-plan style subscription (quota-based)
|
||||
* - "pay_as_you_go": per-token API billing
|
||||
*/
|
||||
billingMode: BillingMode;
|
||||
/**
|
||||
* Billing/usage query kinds; omitted means "do not query".
|
||||
* Persisted to SQLite settings by the Rust side on save, never to
|
||||
@@ -95,12 +107,14 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "claude-sonnet-4-5" },
|
||||
{ model: "claude-haiku-4-5" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For Coding",
|
||||
nameKey: "presetNameKimiCoding",
|
||||
websiteUrl: "https://www.kimi.com/code",
|
||||
apiKeyUrl: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
|
||||
category: "official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
@@ -111,6 +125,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
||||
{ model: "kimi-k2.7-code" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:kimi_coding"],
|
||||
},
|
||||
{
|
||||
@@ -127,6 +142,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "kimi-k2-thinking-turbo" },
|
||||
{ model: "kimi-k2.6" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
// Kimi 开放平台余额:国内站 api.moonshot.cn / 国际站 api.moonshot.ai,
|
||||
// Rust detect 按 host 消歧(api.moonshot.ai → USD,api.moonshot.cn → CNY)。
|
||||
usageKinds: ["balance:kimi"],
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
@@ -138,12 +157,13 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
icon: "deepseek",
|
||||
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["balance:deepseek"],
|
||||
},
|
||||
{
|
||||
id: "zhipu",
|
||||
name: "Zhipu GLM",
|
||||
nameKey: "presetNameZhipu",
|
||||
id: "zhipu-api",
|
||||
name: "Zhipu GLM (API)",
|
||||
nameKey: "presetNameZhipuApi",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
@@ -154,18 +174,51 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "zhipu-coding",
|
||||
name: "Zhipu GLM Coding Plan",
|
||||
nameKey: "presetNameZhipuCoding",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Coding Plan endpoint is separate from the pay-as-you-go PaaS API.
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [
|
||||
{ model: "glm-4.7" },
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
name: "z.ai",
|
||||
nameKey: "presetNameZai",
|
||||
id: "zai-api",
|
||||
name: "z.ai (API)",
|
||||
nameKey: "presetNameZaiApi",
|
||||
websiteUrl: "https://z.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "zai-coding",
|
||||
name: "z.ai Coding Plan",
|
||||
nameKey: "presetNameZaiCoding",
|
||||
websiteUrl: "https://z.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
// Coding Plan endpoint (mirror of Zhipu GLM Coding Plan). If the path
|
||||
// differs on z.ai, fall back to a known mirror.
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
@@ -182,6 +235,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "qwen3-coder-plus" },
|
||||
{ model: "qwen3.5-plus" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
@@ -193,6 +247,26 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
id: "minimax-token-plan",
|
||||
name: "MiniMax Token Plan",
|
||||
nameKey: "presetNameMinimaxTokenPlan",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Token Plan is sold against the same platform endpoint as the
|
||||
// pay-as-you-go API — only the API key differs. The Rust detect
|
||||
// routes api.minimaxi.com to plan:minimax, so this preset picks up
|
||||
// the same query path; the `subscription` tag helps the user pick
|
||||
// the right entry based on which key they hold.
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
billingMode: "subscription",
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
@@ -205,8 +279,24 @@ export const providerPresets: ProviderPreset[] = [
|
||||
baseUrl: "https://api.stepfun.com/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["balance:stepfun"],
|
||||
},
|
||||
{
|
||||
id: "stepfun-plan",
|
||||
name: "StepFun Plan",
|
||||
nameKey: "presetNameStepfunPlan",
|
||||
websiteUrl: "https://platform.stepfun.com/step-plan",
|
||||
apiKeyUrl: "https://platform.stepfun.com/?invite_code=HSIGHTPS",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Step Plan endpoint — same host, different path prefix than the
|
||||
// pay-as-you-go API (/v1). Serves both OpenAI and Anthropic protocols.
|
||||
baseUrl: "https://api.stepfun.com/step_plan/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
billingMode: "subscription",
|
||||
},
|
||||
{
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow",
|
||||
@@ -229,6 +319,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
],
|
||||
usageKinds: ["balance:siliconflow"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "novita",
|
||||
@@ -251,6 +342,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "zai-org/glm-4.6" },
|
||||
],
|
||||
usageKinds: ["balance:novita"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
@@ -273,6 +365,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "google/gemini-3-pro-preview" },
|
||||
],
|
||||
usageKinds: ["balance:openrouter"],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
@@ -288,6 +381,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "gpt-5.1-codex-max" },
|
||||
{ model: "gpt-5-mini" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "google-genai",
|
||||
@@ -303,6 +397,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{ model: "gemini-3-flash-preview" },
|
||||
{ model: "gemini-2.5-flash" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "volcengine",
|
||||
@@ -321,6 +416,53 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
nameKey: "presetNameOpencodeGo",
|
||||
// Referral link — supports the OpenCode project
|
||||
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
icon: "opencode",
|
||||
models: [
|
||||
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||
{ model: "glm-5.2", displayName: "GLM-5.2" },
|
||||
{ model: "glm-5.1", displayName: "GLM-5.1" },
|
||||
{ model: "kimi-k3", displayName: "Kimi K3" },
|
||||
{ model: "kimi-k2.7-code", displayName: "Kimi K2.7 Code" },
|
||||
{ model: "kimi-k2.6", displayName: "Kimi K2.6" },
|
||||
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
|
||||
],
|
||||
billingMode: "subscription",
|
||||
},
|
||||
{
|
||||
id: "opencode-zen",
|
||||
name: "OpenCode Zen",
|
||||
nameKey: "presetNameOpencodeZen",
|
||||
websiteUrl: "https://opencode.ai/zen/",
|
||||
// Referral program only exists on the /go landing page; the Zen console
|
||||
// page itself carries no ref parameter, so point the "get API key" action
|
||||
// at the referral link.
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
icon: "opencode",
|
||||
models: [
|
||||
{ model: "claude-opus-5", displayName: "Claude Opus 5" },
|
||||
{ model: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
|
||||
{ model: "gpt-5.5", displayName: "GPT 5.5" },
|
||||
{ model: "gemini-3.6-flash", displayName: "Gemini 3.6 Flash" },
|
||||
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||
],
|
||||
billingMode: "pay_as_you_go",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -405,6 +547,21 @@ export function presetToProviderAndModels(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reverse lookup: which preset (if any) a Provider was created
|
||||
* from. Matches the provider name with any "-2"/"-3" dedup suffix stripped,
|
||||
* falling back to an exact official_url match. Used to surface preset-only
|
||||
* metadata (apiKeyUrl / referralUrl) in the edit form without persisting
|
||||
* extra fields on Provider.
|
||||
*/
|
||||
export function findPresetForProvider(provider: Provider): ProviderPreset | undefined {
|
||||
const base = provider.name.replace(/-\d+$/, "");
|
||||
return (
|
||||
providerPresets.find((p) => p.id === base) ??
|
||||
providerPresets.find((p) => p.websiteUrl != null && p.websiteUrl === provider.official_url)
|
||||
);
|
||||
}
|
||||
|
||||
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
||||
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
||||
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
||||
|
||||
+12
-9
@@ -8,8 +8,8 @@ interface UseConfigReturn {
|
||||
dirty: boolean;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
save: () => Promise<void>;
|
||||
refresh: (opts?: { silent?: boolean }) => Promise<void>;
|
||||
save: (opts?: { silent?: boolean }) => Promise<void>;
|
||||
updateConfig: (updater: (config: Config) => Config) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ export function useConfig(agent: Agent): UseConfigReturn {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const refresh = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
// silent: skip the loading flip — App returns a bare loading screen while
|
||||
// `loading` is true, which would unmount any open panel (and swallow its
|
||||
// in-flight async results).
|
||||
if (!opts?.silent) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const loadedConfig = await invoke<Config>("load_agent_config_command", {
|
||||
@@ -39,7 +42,7 @@ export function useConfig(agent: Agent): UseConfigReturn {
|
||||
setError(message);
|
||||
console.error("useConfig refresh failed:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!opts?.silent) setLoading(false);
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
@@ -61,19 +64,19 @@ export function useConfig(agent: Agent): UseConfigReturn {
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const save = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
const current = configRef.current;
|
||||
if (!current) return;
|
||||
setLoading(true);
|
||||
if (!opts?.silent) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("save_agent_config_command", { agent, config: current });
|
||||
await refresh();
|
||||
await refresh({ silent: opts?.silent });
|
||||
} catch (err) {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
setError(raw);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!opts?.silent) setLoading(false);
|
||||
}
|
||||
}, [refresh, t, agent]);
|
||||
|
||||
|
||||
+70
-4
@@ -60,6 +60,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
apiFormat: "API Format",
|
||||
authField: "Auth Field",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "Get API Key",
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
requestUrl: "Request URL",
|
||||
@@ -73,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
fetchingModels: "Fetching...",
|
||||
discoveredModels: "Discovered {count} models",
|
||||
addSelected: "Add selected",
|
||||
alreadyAdded: "Added",
|
||||
displayName: "Display name",
|
||||
actualModel: "Actual model",
|
||||
contextSize: "Context size",
|
||||
@@ -283,6 +285,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
tabDailyTrend: "Daily usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
cardModelUsage: "Model usage",
|
||||
@@ -315,8 +318,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "Scanned {n} files · {m} records",
|
||||
dashboardAttribution: "Dashboard ported from",
|
||||
sessionsAttribution: "Session manager ported from",
|
||||
attributionPorted: "Dashboard & session manager ported from",
|
||||
attributionSuffix: "(MIT, © JochenYang)",
|
||||
|
||||
// Sessions — preview
|
||||
@@ -339,17 +341,23 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot Platform",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "Zhipu GLM",
|
||||
presetNameZai: "z.ai (Zhipu Global)",
|
||||
presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)",
|
||||
presetNameZhipuCoding: "Zhipu GLM Coding Plan",
|
||||
presetNameZaiApi: "z.ai (Pay-as-you-go)",
|
||||
presetNameZaiCoding: "z.ai Coding Plan",
|
||||
presetNameBailian: "Alibaba Bailian",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "StepFun",
|
||||
presetNameStepfunPlan: "StepFun Plan",
|
||||
presetNameSiliconflow: "SiliconFlow",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameOpenai: "OpenAI",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "Volcengine Ark",
|
||||
presetNameOpencodeGo: "OpenCode Go",
|
||||
presetNameOpencodeZen: "OpenCode Zen",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "Balance",
|
||||
@@ -363,4 +371,62 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
usageResetIn: "resets in {time}",
|
||||
usageResetDone: "reset",
|
||||
usageLoading: "Querying…",
|
||||
usageQueryOk: "Query succeeded",
|
||||
// Usage config panel (cc-switch style)
|
||||
usageConfigTitle: "Configure Usage Query - {name}",
|
||||
usageConfigBtn: "Configure usage query",
|
||||
usageEnable: "Enable usage query",
|
||||
usageTemplate: "Query template",
|
||||
usageTemplateAuto: "Auto-detect",
|
||||
usageTemplateNewapi: "NewAPI gateway",
|
||||
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
|
||||
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
|
||||
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
|
||||
usageDetectedKinds: "Detected query kinds",
|
||||
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
|
||||
usageBaseUrl: "Query URL (Base URL)",
|
||||
usageAccessToken: "Access Token",
|
||||
usageAccessTokenPlaceholder: "Access Token from the web console",
|
||||
usageAccessTokenHint: "Generated in the NewAPI web console (Personal Settings → Access Token) — NOT the sk- inference key",
|
||||
usageUserId: "User ID",
|
||||
usageUserIdPlaceholder: "NewAPI user id (numeric)",
|
||||
usageUserIdHint: "User id from the web console profile page, sent as the New-Api-User header",
|
||||
usageAutoInterval: "Auto query interval (minutes)",
|
||||
usageAutoIntervalHint: "0 = query only on page open or manual refresh",
|
||||
usageTimeout: "Timeout (seconds)",
|
||||
usageTimeoutHint: "0 = use the default (8 seconds)",
|
||||
usageSupportedVars: "Supported variables",
|
||||
usageBack: "Back",
|
||||
usageNoKindsHint: "No auto-detected query type for this provider. Switch to the NewAPI template to configure manually, or add a custom query script (coming soon).",
|
||||
usageSwitchToNewapi: "Switch to NewAPI template",
|
||||
usageUnsupportedProvider: "This provider is not supported by auto query",
|
||||
usageTier5h: "5-hour",
|
||||
usageTierWeekly: "Weekly",
|
||||
usageErrNoKey: "No API key configured",
|
||||
usageErrDisabled: "Usage query is disabled in the config panel",
|
||||
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
|
||||
usageErrNoOauth: "No Kimi Code OAuth credentials found; run `kimi login` first",
|
||||
usageErrNewapiCreds: "The NewAPI template requires Access Token and User ID",
|
||||
usageTestQuery: "Test query",
|
||||
usageUpdatedAgo: "{time} ago",
|
||||
usageMinAgo: "{n} min ago",
|
||||
usageHourAgo: "{n} h ago",
|
||||
usageDayAgo: "{n} d ago",
|
||||
|
||||
// Billing mode (subscription / pay-as-you-go)
|
||||
billingTabSubscription: "Subscription",
|
||||
billingTabPayAsYouGo: "Pay-as-you-go",
|
||||
billingTabAll: "All",
|
||||
billingBadgeSubscription: "Plan",
|
||||
billingBadgePayAsYouGo: "PAYG",
|
||||
|
||||
// Usage trend line chart
|
||||
trendLineTitle: "Usage Trend",
|
||||
trendLineSub: "last {n} days",
|
||||
trendLineLegendTokens: "Token usage",
|
||||
trendLineLegendCacheHit: "Cache hit rate",
|
||||
trendLineLegendRequests: "Requests",
|
||||
trendLineShowRequests: "Show requests",
|
||||
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
|
||||
trendLineNoData: "No data",
|
||||
};
|
||||
+70
-4
@@ -58,6 +58,7 @@ export const zhTranslations = {
|
||||
apiFormat: "API 格式",
|
||||
authField: "认证字段",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "获取 API Key",
|
||||
show: "显示",
|
||||
hide: "隐藏",
|
||||
requestUrl: "请求地址",
|
||||
@@ -71,6 +72,7 @@ export const zhTranslations = {
|
||||
fetchingModels: "获取中...",
|
||||
discoveredModels: "发现 {count} 个模型",
|
||||
addSelected: "添加选中的",
|
||||
alreadyAdded: "已添加",
|
||||
displayName: "显示名称",
|
||||
actualModel: "实际请求模型",
|
||||
contextSize: "上下文长度",
|
||||
@@ -278,6 +280,7 @@ export const zhTranslations = {
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
tabDailyTrend: "每日用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
cardModelUsage: "模型用量",
|
||||
@@ -310,8 +313,7 @@ export const zhTranslations = {
|
||||
|
||||
// Dashboard — footer / attribution
|
||||
footerScanned: "扫描 {n} 个文件 · {m} 条记录",
|
||||
dashboardAttribution: "仪表盘功能基于",
|
||||
sessionsAttribution: "会话管理功能基于",
|
||||
attributionPorted: "仪表盘与会话管理功能基于",
|
||||
attributionSuffix: "(MIT,© JochenYang)移植",
|
||||
|
||||
// Sessions — preview
|
||||
@@ -334,17 +336,23 @@ export const zhTranslations = {
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot 平台",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "智谱 GLM",
|
||||
presetNameZai: "z.ai(智谱海外)",
|
||||
presetNameZhipuApi: "智谱 GLM(按量)",
|
||||
presetNameZhipuCoding: "智谱 GLM Coding Plan",
|
||||
presetNameZaiApi: "z.ai(按量)",
|
||||
presetNameZaiCoding: "z.ai Coding Plan",
|
||||
presetNameBailian: "阿里百炼",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "阶跃星辰",
|
||||
presetNameStepfunPlan: "阶跃星辰 Step Plan",
|
||||
presetNameSiliconflow: "硅基流动",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameOpenai: "OpenAI 官方",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "火山方舟",
|
||||
presetNameOpencodeGo: "OpenCode Go",
|
||||
presetNameOpencodeZen: "OpenCode Zen",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "余额",
|
||||
@@ -358,6 +366,64 @@ export const zhTranslations = {
|
||||
usageResetIn: "{time} 后重置",
|
||||
usageResetDone: "已重置",
|
||||
usageLoading: "查询中…",
|
||||
usageQueryOk: "查询成功",
|
||||
// Usage config panel (cc-switch style)
|
||||
usageConfigTitle: "配置用量查询 - {name}",
|
||||
usageConfigBtn: "配置用量查询",
|
||||
usageEnable: "启用用量查询",
|
||||
usageTemplate: "查询模板",
|
||||
usageTemplateAuto: "自动检测",
|
||||
usageTemplateNewapi: "NewAPI 中转站",
|
||||
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
|
||||
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
|
||||
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
|
||||
usageDetectedKinds: "检测到的查询类型",
|
||||
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
|
||||
usageBaseUrl: "查询地址 (Base URL)",
|
||||
usageAccessToken: "Access Token",
|
||||
usageAccessTokenPlaceholder: "网页后台生成的 Access Token",
|
||||
usageAccessTokenHint: "在 NewAPI 站点网页后台「个人设置 → Access Token」生成,不是 sk- 开头的推理 Key",
|
||||
usageUserId: "User ID",
|
||||
usageUserIdPlaceholder: "NewAPI 用户 ID(数字)",
|
||||
usageUserIdHint: "网页后台「个人中心」页面的用户 ID,用于 New-Api-User 请求头",
|
||||
usageAutoInterval: "自动查询间隔(分钟)",
|
||||
usageAutoIntervalHint: "0 表示不自动查询,仅在打开页面或手动刷新时查询",
|
||||
usageTimeout: "超时时间(秒)",
|
||||
usageTimeoutHint: "0 表示使用默认值(8 秒)",
|
||||
usageSupportedVars: "支持的变量",
|
||||
usageBack: "返回",
|
||||
usageNoKindsHint: "未识别此供应商的自动查询类型,请切换到 NewAPI 模板手动配置,或添加自定义查询脚本(即将支持)",
|
||||
usageSwitchToNewapi: "切换到 NewAPI 模板",
|
||||
usageUnsupportedProvider: "此供应商暂不支持自动查询",
|
||||
usageTier5h: "5小时",
|
||||
usageTierWeekly: "7天",
|
||||
usageErrNoKey: "未配置 API Key",
|
||||
usageErrDisabled: "用量查询已在配置面板中停用",
|
||||
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
|
||||
usageErrNoOauth: "未找到 Kimi Code 登录凭据,请先运行 `kimi login`",
|
||||
usageErrNewapiCreds: "NewAPI 模板需要填写 Access Token 和 User ID",
|
||||
usageTestQuery: "测试查询",
|
||||
usageUpdatedAgo: "{time}前",
|
||||
usageMinAgo: "{n} 分钟前",
|
||||
usageHourAgo: "{n} 小时前",
|
||||
usageDayAgo: "{n} 天前",
|
||||
|
||||
// Billing mode (套餐 / 按量)
|
||||
billingTabSubscription: "套餐",
|
||||
billingTabPayAsYouGo: "按量",
|
||||
billingTabAll: "全部",
|
||||
billingBadgeSubscription: "套餐",
|
||||
billingBadgePayAsYouGo: "按量",
|
||||
|
||||
// Usage trend line chart
|
||||
trendLineTitle: "用量趋势",
|
||||
trendLineSub: "最近 {n} 天",
|
||||
trendLineLegendTokens: "Token 用量",
|
||||
trendLineLegendCacheHit: "缓存命中率",
|
||||
trendLineLegendRequests: "请求数",
|
||||
trendLineShowRequests: "显示请求数",
|
||||
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
|
||||
trendLineNoData: "暂无数据",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof zhTranslations;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
|
||||
type TranslateFn = (
|
||||
key: TranslationKey,
|
||||
vars?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
|
||||
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
|
||||
*
|
||||
* Coverage note: ALL plan-type (套餐) queries flow through
|
||||
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
|
||||
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT — so this mapping covers every
|
||||
* current and future plan provider automatically. If a new tier id is ever
|
||||
* added there, add a case here too.
|
||||
*/
|
||||
export function planLabel(name: string, t: TranslateFn): string {
|
||||
switch (name) {
|
||||
case "five_hour":
|
||||
return t("usageTier5h");
|
||||
case "weekly_limit":
|
||||
return t("usageTierWeekly");
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map known Rust error strings to localized text. Unknown errors pass
|
||||
* through unchanged (they are diagnostic details like HTTP status + body).
|
||||
* Keep patterns aligned with the exact prefixes emitted in
|
||||
* src-tauri/src/commands.rs and src-tauri/src/services/*.
|
||||
*/
|
||||
export function localizeUsageError(error: string, t: TranslateFn): string {
|
||||
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
|
||||
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
|
||||
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
|
||||
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
|
||||
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
|
||||
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
|
||||
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
|
||||
return error;
|
||||
}
|
||||
@@ -34,6 +34,11 @@ export interface Provider {
|
||||
* and persisted back to SQLite on save; never written to config.toml.
|
||||
*/
|
||||
usageKinds?: string[];
|
||||
/**
|
||||
* Usage query configuration edited via the "配置用量查询" panel.
|
||||
* Same persistence as usageKinds (SQLite settings, never config.toml).
|
||||
*/
|
||||
usageConfig?: UsageConfig;
|
||||
/** Extra agent-specific provider fields preserved across edits. */
|
||||
raw_other?: unknown;
|
||||
}
|
||||
@@ -59,6 +64,25 @@ export interface Config {
|
||||
raw_other?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
|
||||
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
|
||||
* NewAPI/OneAPI gateway with accessToken + userId.
|
||||
*/
|
||||
export interface UsageConfig {
|
||||
enabled: boolean;
|
||||
templateType: "auto" | "newapi";
|
||||
baseUrl?: string;
|
||||
/** NewAPI web-console access token (NOT the sk- inference key). */
|
||||
accessToken?: string;
|
||||
/** NewAPI user id, sent as the New-Api-User header. */
|
||||
userId?: string;
|
||||
/** Auto query interval in minutes; 0/undefined = manual refresh only. */
|
||||
autoQueryIntervalMinutes?: number;
|
||||
/** Per-request timeout in seconds; 0/undefined = default (8s). */
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/** A model discovered from a provider's API endpoint. */
|
||||
export interface DiscoveredModel {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user