Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00f6b6591 | ||
|
|
f643802f82 |
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
||||
- platform: macos-latest
|
||||
args: --bundles app,dmg
|
||||
- platform: windows-latest
|
||||
args: --bundles msi
|
||||
args: --bundles nsis
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cat <<'TAIL_EOF'
|
||||
### Downloads by platform
|
||||
|
||||
- **Windows**: `.msi` installer
|
||||
- **Windows**: `.exe`(NSIS 安装包)——自 v0.8.3 起 Windows 以 NSIS 为主,**MSI 不再主动发布**(历史版本的 `.msi` 仍可下载)
|
||||
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
|
||||
- **Linux**: `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.8.4 (2026-10-15)
|
||||
|
||||
用量查询增强:NewAPI 中转站现在同时展示**订阅额度占比**与**钱包余额**两套数据,并修掉配置面板把钱包金额误算成百分比的问题。
|
||||
|
||||
- **NewAPI 订阅额度行(新增)**:此前只查询 `/api/user/self`(钱包),订阅套餐的额度完全看不到;现在同时查询 `/api/subscription/self`,把订阅作为独立一行展示(如 `⚡ 订阅 · 41% 已使用 · 16d 后重置`),与钱包行(`💰 余额 500`)分开——两者是互相独立的账本
|
||||
- **多个活跃订阅全部展示**:站点返回多个订阅时逐行列出
|
||||
- **订阅行参与预警阈值**:订阅是配额占比行,达到阈值时同样高亮提醒
|
||||
- **修掉配置面板的错误百分比**:此前对「金额行」也无条件拼 `used/total%`,把钱包余额算成「已用 29%」这类无意义的累计消耗率;现在只有真正的配额行才显示百分比(与卡片口径统一)
|
||||
- **老版本 NewAPI 兼容**:站点无 `/api/subscription/self` 接口(404)或无订阅时静默降级,只显示钱包行,行为与之前完全一致
|
||||
- models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商)
|
||||
- **Windows 安装包改为以 NSIS(`.exe`)为主,MSI 不再主动发布**:CI 的 Windows 构建由 `--bundles msi` 改为 `--bundles nsis`,`tauri.conf.json` 默认打包目标同步改为仅 `nsis`;应用内「检查更新」优先下载 NSIS 安装包(无 exe 的历史版本自动回落 `.msi`)。历史版本的 `.msi` 仍可下载,需要 MSI 可本地 `npx tauri build --bundles msi` 自行构建
|
||||
- 测试:前端 150 项(新增 4 项)、Rust 205 项(新增 5 项)
|
||||
|
||||
## v0.8.3 (2026-10-15)
|
||||
|
||||
配置一致性修复版(含 kimi-code CLI 2.2.0 适配核验)。
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
## 下载
|
||||
|
||||
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
|
||||
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.exe`(NSIS 安装包;**自 v0.8.3 起 Windows 以 NSIS 为主,MSI 不再主动发布**,历史版本 `.msi` 仍可下载)
|
||||
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名**,仅 Apple Silicon / M 系列芯片)。安装请下载 Release 附带的 `install-macos.sh` 一键处理:
|
||||
|
||||
```bash
|
||||
@@ -496,10 +496,10 @@ npm run tauri-build
|
||||
产物位置:
|
||||
|
||||
```
|
||||
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
|
||||
src-tauri/target/release/bundle/nsis/Kimi.Switch_<version>_x64-setup.exe
|
||||
```
|
||||
|
||||
Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。
|
||||
Windows 安装包(NSIS setup exe),含 WebView2 bootstrapper 自动下载。自 v0.8.3 起 Windows **只主动发布 NSIS 安装包,MSI 不再主动发布**(如需 MSI 可本地 `npx tauri build --bundles msi` 自行构建)。
|
||||
|
||||
### 首次打包
|
||||
|
||||
@@ -565,7 +565,7 @@ A: 对 Kimi Code,`config.toml` 本身就是全量权威,直接备份即可
|
||||
A: Vertex 需要 GCP project / location 凭证,当前实现留 TODO,等 GCP SDK 集成后补。
|
||||
|
||||
**Q: 支持 macOS / Linux 吗?**
|
||||
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `msi`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
|
||||
A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `nsis`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。
|
||||
|
||||
**Q: 怎么改主题 / 语言?**
|
||||
A: 顶部齿轮 → 设置弹窗 → 主题 / 语言。保存到前端 `localStorage`(WebView2),重启后保持。
|
||||
|
||||
+4
-4
@@ -54,7 +54,7 @@
|
||||
|
||||
## Downloads
|
||||
|
||||
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
|
||||
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.exe` (NSIS installer; **since v0.8.3 Windows ships NSIS as the primary format and MSI is no longer actively published** — `.msi` files of past releases remain downloadable)
|
||||
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg` (**unsigned** — first launch needs right-click → Open to bypass Gatekeeper)
|
||||
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
@@ -486,10 +486,10 @@ npm run tauri-build
|
||||
Output:
|
||||
|
||||
```
|
||||
src-tauri/target/release/bundle/msi/Kimi Switch_<version>_x64_en-US.msi
|
||||
src-tauri/target/release/bundle/nsis/Kimi.Switch_<version>_x64-setup.exe
|
||||
```
|
||||
|
||||
A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced.
|
||||
A Windows installer (NSIS setup exe) with the WebView2 bootstrapper embedded for auto-download. Since v0.8.3 Windows **publishes only the NSIS installer; MSI is no longer actively released** (build it locally with `npx tauri build --bundles msi` if needed).
|
||||
|
||||
### First build
|
||||
|
||||
@@ -552,7 +552,7 @@ A: For Kimi Code, `config.toml` itself is the authoritative full config — back
|
||||
A: Vertex requires GCP project / location credentials. The current implementation leaves a TODO pending GCP SDK integration.
|
||||
|
||||
**Q: macOS / Linux support?**
|
||||
A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targets `msi` for bundling. In theory, changing `bundle.targets` to `["app", "dmg"]` etc. would enable cross-platform builds, but this is unverified.
|
||||
A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targets `nsis` for bundling. In theory, changing `bundle.targets` to `["app", "dmg"]` etc. would enable cross-platform builds, but this is unverified.
|
||||
|
||||
**Q: How do I change the theme / language?**
|
||||
A: Top-right gear → Settings modal → Theme / Language. Saved to frontend `localStorage` (WebView2), persists across restarts.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# KimiSwitch v0.8.4
|
||||
|
||||
用量查询增强:NewAPI 中转站现在同时展示**订阅额度占比**与**钱包余额**两套数据,并修掉配置面板把钱包金额误算成百分比的问题。
|
||||
|
||||
## NewAPI 订阅额度(新增)
|
||||
|
||||
- **订阅与钱包分开展示**:NewAPI 站点有两套互相独立的账本——**钱包余额**(`/api/user/self`)与**订阅套餐额度**(`/api/subscription/self`)。此前只查询前者,订阅额度完全看不到;现在两者各占一行:
|
||||
|
||||
```
|
||||
⚡ 订阅 · 41% 已使用 16d 后重置 ← 订阅额度占比(本周期)
|
||||
💰 余额 500 ← 钱包余额
|
||||
```
|
||||
|
||||
- **多个活跃订阅全部展示**:站点返回多个订阅时逐行列出,不隐藏
|
||||
- **订阅行参与预警阈值**:订阅是配额占比行,达到配置面板设定的阈值时会高亮提醒
|
||||
- **重置时间**:显示订阅的下一次重置倒计时(如「16d 后重置」)
|
||||
|
||||
## 修复
|
||||
|
||||
- **配置面板的错误百分比**:此前「测试查询」结果对各行**无条件**拼接 `used/total%`,会把钱包余额算成「已用 29%」这类无意义的累计消耗率(`used_quota / (quota + used_quota)`,没有业务含义)。现在只有真正的配额行才显示百分比,与供应商卡片口径完全一致
|
||||
|
||||
## 兼容性
|
||||
|
||||
- **老版本 NewAPI 站点无影响**:站点没有 `/api/subscription/self` 接口(404)或账号无订阅时静默降级,只显示钱包行,行为与 v0.8.3 完全一致
|
||||
- **不改配置格式**:仅调整用量查询实现与界面渲染,`config.toml`、IPC 契约、`UsageData` 结构均无变化
|
||||
|
||||
## 其他
|
||||
|
||||
- models.dev 快照同步至 2026-10-15(8,876 模型 / 230 供应商)
|
||||
- **Windows 安装包改为以 NSIS(`.exe`)为主,MSI 不再主动发布**:应用内「检查更新」优先下载 NSIS 安装包(无 exe 的历史版本自动回落 `.msi`);历史版本的 `.msi` 仍可下载
|
||||
|
||||
## 测试
|
||||
|
||||
- 前端 150 项(新增 4 项:行判定 / 百分比计算 / 订阅标签)
|
||||
- Rust 205 项(新增 5 项:订阅解析、多订阅、空订阅、零额度不除零、超额钳制)
|
||||
- 已用真实 NewAPI 站点实测:订阅占比与站点网页控制台、API 原始数据三方一致
|
||||
|
||||
## 下载
|
||||
|
||||
- **Windows**:`.exe`(NSIS 安装包)
|
||||
- **macOS / Linux**:见 [Releases](https://github.com/billowliu2/KimiSwitch/releases)(由 CI 构建)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+6792
-1138
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+47
-14
@@ -1599,7 +1599,8 @@ pub async fn download_update(
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
/// Open the downloaded MSI installer using the system default handler.
|
||||
/// Open the downloaded installer (NSIS setup exe / dmg / AppImage) using the
|
||||
/// system default handler.
|
||||
#[tauri::command]
|
||||
pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
@@ -1839,27 +1840,38 @@ fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
/// Preference for Linux is AppImage (portable, no install). If the preferred
|
||||
/// extension is not present, returns None so the UI can fall back to
|
||||
/// "open release page" instead of guessing a less-preferred format.
|
||||
/// Preference for Linux is AppImage (portable, no install). Windows prefers
|
||||
/// the NSIS `-setup.exe` (MSI is no longer actively published since v0.8.3)
|
||||
/// and falls back to `.msi` for releases that predate the switch. If the
|
||||
/// preferred extension is not present, returns None so the UI can fall back
|
||||
/// to "open release page" instead of guessing a less-preferred format.
|
||||
fn pick_asset_for_os(assets: &[serde_json::Value], os: &str) -> Option<String> {
|
||||
let target_ext = match os {
|
||||
"macos" => "dmg",
|
||||
"linux" => "AppImage",
|
||||
"windows" => "msi",
|
||||
let (preferred, fallback) = match os {
|
||||
"macos" => ("dmg", None),
|
||||
"linux" => ("AppImage", None),
|
||||
"windows" => ("setup.exe", Some("msi")),
|
||||
_ => return None,
|
||||
};
|
||||
asset_url_ending_with(assets, preferred)
|
||||
.or_else(|| fallback.and_then(|ext| asset_url_ending_with(assets, ext)))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Download URL of the first release asset whose name ends with `ext`.
|
||||
fn asset_url_ending_with<'a>(
|
||||
assets: &'a [serde_json::Value],
|
||||
ext: &str,
|
||||
) -> Option<&'a str> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
a.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| n.ends_with(target_ext))
|
||||
.map(|n| n.ends_with(ext))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|a| a.get("browser_download_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Pick the GitHub release asset for the current OS (runtime target).
|
||||
@@ -1872,7 +1884,10 @@ fn update_temp_filename() -> String {
|
||||
let ext = match std::env::consts::OS {
|
||||
"macos" => "dmg",
|
||||
"linux" => "AppImage",
|
||||
"windows" => "msi",
|
||||
// The preferred Windows installer is the NSIS setup exe; the fallback
|
||||
// (.msi, pre-v0.8.3-releases) keeps its own extension via the same
|
||||
// suffix match, so "exe" covers the actively published format.
|
||||
"windows" => "exe",
|
||||
_ => "bin",
|
||||
};
|
||||
format!("KimiSwitch_update.{ext}")
|
||||
@@ -2160,10 +2175,28 @@ mod asset_picker_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_picks_msi() {
|
||||
let assets = assets(&["KimiSwitch_0.7.0_x64_en-US.msi", "KimiSwitch_0.7.0_aarch64.dmg", "KimiSwitch_0.7.0_amd64.AppImage"]);
|
||||
fn windows_prefers_nsis_setup_exe_over_msi() {
|
||||
let assets = assets(&[
|
||||
"Kimi.Switch_0.8.3_x64_en-US.msi",
|
||||
"Kimi.Switch_0.8.3_x64-setup.exe",
|
||||
"Kimi.Switch_0.8.3_aarch64.dmg",
|
||||
]);
|
||||
let url = pick_asset_for_os(&assets, "windows");
|
||||
assert_eq!(url.as_deref(), Some("https://example.com/KimiSwitch_0.7.0_x64_en-US.msi"));
|
||||
assert_eq!(
|
||||
url.as_deref(),
|
||||
Some("https://example.com/Kimi.Switch_0.8.3_x64-setup.exe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_falls_back_to_msi_when_no_setup_exe() {
|
||||
// Pre-v0.8.3 releases (and older Gitea releases) ship only the .msi.
|
||||
let assets = assets(&["Kimi.Switch_0.8.2_x64_en-US.msi"]);
|
||||
let url = pick_asset_for_os(&assets, "windows");
|
||||
assert_eq!(
|
||||
url.as_deref(),
|
||||
Some("https://example.com/Kimi.Switch_0.8.2_x64_en-US.msi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。
|
||||
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use super::coding_plan;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
@@ -467,11 +468,95 @@ pub async fn query_newapi(
|
||||
|
||||
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),
|
||||
Ok(wallet) => {
|
||||
// 订阅额度是决策主信息(钱包余额独立于它),前置到钱包行之前;
|
||||
// compact 卡片只取 data[0],所以顺序决定了卡片主摘要显示什么。
|
||||
let subs = fetch_newapi_subscriptions(&client, base, access_token, user_id, quota_per_unit).await;
|
||||
let mut data = subs;
|
||||
data.extend(wallet);
|
||||
UsageResult::ok(data)
|
||||
}
|
||||
Err(err) => err,
|
||||
})
|
||||
}
|
||||
|
||||
/// 拉取用户的订阅套餐额度(NewAPI `GET /api/subscription/self`)。这是 NewAPI
|
||||
/// 较新的特性:站点无此接口(404)、无订阅、网络失败或解析失败一律返回空
|
||||
/// Vec——订阅是钱包余额之外的附加信息,任何失败都不能拖垮整个用量查询。
|
||||
///
|
||||
/// 与 `/api/user/self` 同源同凭据(Bearer access token + New-Api-User)。
|
||||
async fn fetch_newapi_subscriptions(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
quota_per_unit: f64,
|
||||
) -> Vec<UsageData> {
|
||||
let url = format!("{base}/api/subscription/self");
|
||||
let Ok(resp) = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("New-Api-User", user_id)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(raw) = resp.bytes().await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(body) = serde_json::from_slice::<serde_json::Value>(&raw) else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_newapi_subscriptions(&body, quota_per_unit)
|
||||
}
|
||||
|
||||
/// 解析订阅自查响应。每项形如 `{ subscription: { amount_total, amount_used,
|
||||
/// next_reset_time, ... } }`;金额是 int64 原始配额单位,需除以站点
|
||||
/// `quota_per_unit` 才是钱数。产出百分比行(`unit="%"`,与 plan tier 同形态,
|
||||
/// 因此自动参与预警阈值判定)。
|
||||
///
|
||||
/// `amount_total` 为 0(站点未设额度)时百分比记为 0,不做除法。
|
||||
fn parse_newapi_subscriptions(body: &serde_json::Value, quota_per_unit: f64) -> Vec<UsageData> {
|
||||
let Some(subs) = body
|
||||
.get("data")
|
||||
.and_then(|d| d.get("subscriptions"))
|
||||
.and_then(|s| s.as_array())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in subs {
|
||||
// SubscriptionSummary 把实际字段包在 `subscription` 里;兼容站点直接
|
||||
// 平铺字段的情况(取 entry 本身作回退)。
|
||||
let sub = entry.get("subscription").unwrap_or(entry);
|
||||
let total_raw = parse_f64_field(sub, "amount_total").unwrap_or(0.0);
|
||||
let used_raw = parse_f64_field(sub, "amount_used").unwrap_or(0.0);
|
||||
let total = total_raw / quota_per_unit;
|
||||
let used = used_raw / quota_per_unit;
|
||||
let percent = if total > 0.0 {
|
||||
(used / total * 100.0).clamp(0.0, 100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
out.push(UsageData {
|
||||
plan_name: Some("subscription".to_string()),
|
||||
remaining: Some((100.0 - percent).max(0.0)),
|
||||
total: Some(100.0),
|
||||
used: Some(percent),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(percent < 100.0),
|
||||
resets_at: sub.get("next_reset_time").and_then(coding_plan::extract_reset_time),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_newapi(
|
||||
body: &serde_json::Value,
|
||||
quota_per_unit: f64,
|
||||
@@ -842,6 +927,91 @@ mod tests {
|
||||
assert_eq!(data[0].used, Some(0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_maps_to_percent_row() {
|
||||
// 用户实测站点的数值:总额度 $500、已用 $206.17 → 41.234% 已用
|
||||
// (amount_* 是 int64 原始配额,quota_per_unit=500000 换算成钱)。
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"subscriptions": [{
|
||||
"subscription": {
|
||||
"id": 9,
|
||||
"amount_total": 250_000_000,
|
||||
"amount_used": 103_085_000,
|
||||
"next_reset_time": 1_793_491_200,
|
||||
"end_time": 2_106_105_600,
|
||||
"status": "active"
|
||||
}
|
||||
}],
|
||||
"all_subscriptions": []
|
||||
}
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("subscription"));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("%"));
|
||||
assert_eq!(data[0].total, Some(100.0));
|
||||
// 41.234% 已用 → 剩余 58.766
|
||||
assert!((data[0].used.unwrap() - 41.234).abs() < 0.01);
|
||||
assert!((data[0].remaining.unwrap() - 58.766).abs() < 0.01);
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
assert!(data[0].resets_at.is_some(), "next_reset_time must map to ISO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_multiple_all_returned() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"subscriptions": [
|
||||
{ "subscription": { "amount_total": 100_000_000, "amount_used": 90_000_000 } },
|
||||
{ "subscription": { "amount_total": 200_000_000, "amount_used": 20_000_000 } }
|
||||
]
|
||||
}
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 2);
|
||||
assert!((data[0].used.unwrap() - 90.0).abs() < 0.01);
|
||||
assert!((data[1].used.unwrap() - 10.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_empty_or_missing_yields_nothing() {
|
||||
// 站点无订阅 → 空数组
|
||||
let empty = json!({ "success": true, "data": { "subscriptions": [] } });
|
||||
assert!(parse_newapi_subscriptions(&empty, 500_000.0).is_empty());
|
||||
// 老版本无此接口/字段缺失 → 也必须是空,不 panic
|
||||
let no_field = json!({ "success": true, "data": {} });
|
||||
assert!(parse_newapi_subscriptions(&no_field, 500_000.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_zero_total_does_not_divide_by_zero() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "subscriptions": [{ "subscription": { "amount_total": 0, "amount_used": 0 } }] }
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 500_000.0);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].used, Some(0.0));
|
||||
assert_eq!(data[0].remaining, Some(100.0));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_subscription_used_over_total_is_clamped() {
|
||||
// 防御:站点数据异常(已用 > 总额)时百分比钳到 100,不出现负数剩余。
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "subscriptions": [{ "subscription": { "amount_total": 100, "amount_used": 300 } }] }
|
||||
});
|
||||
let data = parse_newapi_subscriptions(&body, 1.0);
|
||||
assert_eq!(data[0].used, Some(100.0));
|
||||
assert_eq!(data[0].remaining, Some(0.0));
|
||||
assert_eq!(data[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_site_root_strips_v1_and_slash() {
|
||||
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
|
||||
|
||||
@@ -53,7 +53,7 @@ fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式:
|
||||
/// - 字符串:直接返回(视为 ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
pub(crate) fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -27,7 +27,6 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"msi",
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, isPlanRow, localizeUsageError, percentOf, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
@@ -131,13 +131,18 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
});
|
||||
if (result.success) {
|
||||
setTestOk(true);
|
||||
// 与卡片 UsageFooter 同一套行判定:金额行(NewAPI 钱包 / 加力钱包)只显示
|
||||
// 金额——其 used/total 是钱数,算成「百分比」是误导(曾把钱包余额误显示为
|
||||
// 「已用 29%」)。只有真正的配额行才追加百分比。
|
||||
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")} ${formatAmount(d.remaining, d.unit)}`);
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
|
||||
if (isPlanRow(d)) {
|
||||
const pct = percentOf(d);
|
||||
if (pct != null) parts.push(`${Math.round(pct)}%`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import {
|
||||
BOOSTER_WALLET_PLAN,
|
||||
formatAmount,
|
||||
isPlanRow,
|
||||
localizeUsageError,
|
||||
percentOf,
|
||||
planLabel,
|
||||
} from "../lib/usage-display";
|
||||
import { isAlertTier } from "../hooks/useUsageQuery";
|
||||
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||
import type { UsageQueryState } from "../hooks/useUsageQuery";
|
||||
|
||||
interface UsageFooterProps {
|
||||
/** Query state lifted to the provider card via useUsageQuery. Both the
|
||||
@@ -21,18 +22,15 @@ interface UsageFooterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
|
||||
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
|
||||
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
|
||||
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
|
||||
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
|
||||
* 多少」,按 unit 判定会把它误渲染成余额。
|
||||
*
|
||||
* NewAPI 站点可同时返回钱包行(planName "NewAPI",金额)与订阅行
|
||||
* (planName "subscription",`unit="%"` 的配额占比);后者不属于金额行,走
|
||||
* 百分比渲染路径。判定逻辑与 usage-display.ts 共用,避免与配置面板再次分叉。
|
||||
*/
|
||||
function isMoneyRow(d: UsageData): boolean {
|
||||
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
|
||||
}
|
||||
|
||||
function isPlanRow(d: UsageData): boolean {
|
||||
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
|
||||
}
|
||||
|
||||
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -48,14 +46,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
|
||||
if (!supported || status === "idle") return null;
|
||||
|
||||
const percentOf = (d: UsageData): number | null => {
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
return (d.used / d.total) * 100;
|
||||
if (d.remaining != null && d.total != null && d.total > 0)
|
||||
return (1 - d.remaining / d.total) * 100;
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatReset = (resetsAt?: string | null): string | null => {
|
||||
if (!resetsAt) return null;
|
||||
const ms = new Date(resetsAt).getTime() - now;
|
||||
|
||||
@@ -514,6 +514,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
usageTierMonthly: "Monthly",
|
||||
usageTierMonthCode: "Monthly (code)",
|
||||
usageTierBoosterWallet: "Booster",
|
||||
usageTierSubscription: "Subscription",
|
||||
usageThreshold: "Alert threshold",
|
||||
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
|
||||
usageThresholdAlert: "Threshold reached",
|
||||
|
||||
@@ -503,6 +503,7 @@ export const zhTranslations = {
|
||||
usageTierMonthly: "30天",
|
||||
usageTierMonthCode: "30天(代码)",
|
||||
usageTierBoosterWallet: "加力钱包",
|
||||
usageTierSubscription: "订阅",
|
||||
usageThreshold: "预警阈值",
|
||||
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
|
||||
usageThresholdAlert: "已达预警阈值",
|
||||
|
||||
+7385
-1206
File diff suppressed because it is too large.
Load diff
+6792
-1138
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
|
||||
import {
|
||||
formatAmount,
|
||||
isMoneyRow,
|
||||
isPlanRow,
|
||||
localizeUsageError,
|
||||
percentOf,
|
||||
planLabel,
|
||||
} from "./usage-display";
|
||||
|
||||
// Minimal TranslateFn: return the key itself so assertions read the key.
|
||||
const t = (key: string) => key;
|
||||
@@ -60,11 +67,51 @@ describe("planLabel", () => {
|
||||
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
|
||||
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
|
||||
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
|
||||
expect(planLabel("subscription", t)).toBe("usageTierSubscription");
|
||||
expect(planLabel("NewAPI", t)).toBe("NewAPI");
|
||||
expect(planLabel("¥", t)).toBe("¥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMoneyRow / isPlanRow", () => {
|
||||
it("treats the NewAPI wallet and booster wallet as money rows", () => {
|
||||
// NewAPI 钱包行只报金额,其 used/total 是钱数,不能当百分比渲染。
|
||||
expect(isMoneyRow({ planName: "NewAPI" })).toBe(true);
|
||||
expect(isMoneyRow({ planName: "booster_wallet" })).toBe(true);
|
||||
expect(isMoneyRow({ planName: "subscription" })).toBe(false);
|
||||
expect(isMoneyRow({ planName: "weekly_limit" })).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies quota rows by planName, not unit", () => {
|
||||
// NewAPI 订阅行:unit="%" → 配额行
|
||||
expect(
|
||||
isPlanRow({ planName: "subscription", total: 100, used: 41 })
|
||||
).toBe(true);
|
||||
// sub2api 订阅窗口:unit 是 USD,但语义仍是配额占比 → 仍算配额行
|
||||
expect(
|
||||
isPlanRow({ planName: "weekly_limit", total: 50, used: 10 })
|
||||
).toBe(true);
|
||||
// 钱包行永远不是配额行,即使带了 total/used
|
||||
expect(isPlanRow({ planName: "NewAPI", total: 704, used: 204 })).toBe(false);
|
||||
// 无 planName 或无数值 → 非配额行
|
||||
expect(isPlanRow({ planName: null, total: 100, used: 1 })).toBe(false);
|
||||
expect(isPlanRow({ planName: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentOf", () => {
|
||||
it("prefers used/total and falls back to remaining/total", () => {
|
||||
expect(percentOf({ used: 41, total: 100 })).toBe(41);
|
||||
expect(percentOf({ remaining: 59, total: 100 })).toBe(41);
|
||||
});
|
||||
|
||||
it("returns null when total is missing or non-positive", () => {
|
||||
expect(percentOf({ used: 41 })).toBeNull();
|
||||
expect(percentOf({ used: 41, total: 0 })).toBeNull();
|
||||
expect(percentOf({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("maps CNY and USD to ¥/$ with two decimals", () => {
|
||||
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
|
||||
|
||||
@@ -12,6 +12,44 @@ type TranslateFn = (
|
||||
*/
|
||||
export const BOOSTER_WALLET_PLAN = "booster_wallet";
|
||||
|
||||
/**
|
||||
* 金额行 vs 配额行:NewAPI 的钱包行与 Kimi 加力钱包(booster_wallet)只报金额,
|
||||
* 其 used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。
|
||||
*
|
||||
* 注意 NewAPI 站点同时有钱包与订阅两套账:钱包行的 planName 是 "NewAPI"(金额),
|
||||
* 订阅行是 "subscription"(`unit="%"` 的配额占比),后者不在此列表内,正常走
|
||||
* 百分比渲染。判定只能看 planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义
|
||||
* 仍是「额度用了百分之多少」,按 unit 判定会把它误渲染成余额。
|
||||
*/
|
||||
export function isMoneyRow(d: { planName?: string | null }): boolean {
|
||||
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
|
||||
}
|
||||
|
||||
/** 额度/配额行:有 planName、非金额行,且报了 total 或 used。 */
|
||||
export function isPlanRow(d: {
|
||||
planName?: string | null;
|
||||
total?: number | null;
|
||||
used?: number | null;
|
||||
}): boolean {
|
||||
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配额行的「已用百分比」。优先 used/total,其次由 remaining/total 反推;
|
||||
* total 缺失或 ≤0(无法算比例)返回 null。金额行不应调用此函数。
|
||||
*/
|
||||
export function percentOf(d: {
|
||||
used?: number | null;
|
||||
total?: number | null;
|
||||
remaining?: number | null;
|
||||
}): number | null {
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
return (d.used / d.total) * 100;
|
||||
if (d.remaining != null && d.total != null && d.total > 0)
|
||||
return (1 - d.remaining / d.total) * 100;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
|
||||
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
|
||||
@@ -19,9 +57,10 @@ export const BOOSTER_WALLET_PLAN = "booster_wallet";
|
||||
* Coverage note: ALL plan-type (套餐) queries flow through
|
||||
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
|
||||
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
|
||||
* TIER_MONTH_CODE — so this mapping covers every current and future plan
|
||||
* provider automatically. If a new tier id is ever added there, add a case
|
||||
* here too.
|
||||
* TIER_MONTH_CODE — so the fixed tiers are covered here. The other cases come
|
||||
* from src-tauri/src/services/balance.rs: `booster_wallet` (Kimi 加力钱包) and
|
||||
* `subscription` (NewAPI 订阅套餐额度行). If a new tier id is added anywhere,
|
||||
* add a case here too.
|
||||
*/
|
||||
export function planLabel(name: string, t: TranslateFn): string {
|
||||
switch (name) {
|
||||
@@ -37,6 +76,8 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
return t("usageTierMonthCode");
|
||||
case BOOSTER_WALLET_PLAN:
|
||||
return t("usageTierBoosterWallet");
|
||||
case "subscription":
|
||||
return t("usageTierSubscription");
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user