Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ before the client runs them.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/en/overview-dark.png">
<img src="docs/screenshots/en/overview-light.png" alt="The overview for the last seven days: what needs attention (failed requests, a cut tool call, unpriced requests); six cards for tokens, cost, cache hits, requests, first token and traffic, each with the change against the seven days before and a chart; and the statistics by model below">
<img src="docs/screenshots/en/overview-light.png" alt="The overview for the last seven days: tokens, cost, requests, cache hits, first token and traffic, each with the change against the seven days before, over a chart of the selected one; below, where the tokens went by model and the quotas and balances the upstreams report">
</picture>

## Sponsors
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/zh/overview-dark.png">
<img src="docs/screenshots/zh/overview-light.png" alt="最近 7 天的概览:需要处理的事项(失败的请求、被切断的工具调用、无法计价的请求);token、费用、缓存命中、请求、首 token 与流量六张卡片,各带与前 7 天的对比和趋势图;下方是按模型的统计">
<img src="docs/screenshots/zh/overview-light.png" alt="最近 7 天的概览:token、费用、请求、缓存命中、首 token 与流量六个指标,各带与前 7 天的对比,下面是所选指标的曲线;再往下是按模型的去向,以及上游报告的额度与余额">
</picture>

## 赞助商
Expand Down
Binary file modified docs/screenshots/en/menubar-menu-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/en/menubar-menu-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/en/overview-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/en/overview-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menubar-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/menubar-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/web/menubar-menu-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/web/menubar-menu.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/web/menubar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/web/overview-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/web/overview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/zh/menubar-menu-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/zh/menubar-menu-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/zh/overview-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/zh/overview-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "thinkwatch-lite",
"private": true,
"description": "Desktop app for a local AI API gateway on macOS, Windows and Linux",
"version": "2026.10.32",
"version": "2026.10.33",
"type": "module",
"packageManager": "pnpm@11.13.0",
"scripts": {
Expand Down
16 changes: 16 additions & 0 deletions release-notes/2026.10.33.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
**Upgrade notes:**
- Core stays at 0.84.0 and the control-plane protocol at 55. Nothing is cleared.

**Overview:**
- What used to sit at the top of the overview now lives in the notices (the bell). Failed requests and models without a price are new notices there, shown in the app only; a cut-off tool call and an unavailable upstream were notices already. Clicking the failed-requests notice shows only the failed requests in Traffic.
- The usage chart draws lines instead of bars. Tokens, cost, requests and traffic are shaded areas; failures and downloads are a second, thin line. The period still in progress is dashed, and periods from before the first request record are marked "Not recorded" instead of being drawn as zero.
- The chart's scale fits the data more closely: a peak of 3.9M now reads 0-5M instead of 0-8M, and axis labels drop the trailing ".0".
- Clicking a day on the past-year heatmap, or changing the time range further down the page, no longer jumps back to the top. The heatmap stays where it was.
- Hovering the chart no longer lights up the border of the whole card.
- Distribution and Breakdown follow one choice of model, upstream or key. Distribution has its title and a slim bar per row, and Breakdown sits in its own titled card.
- The past-year heatmap uses calmer colors, its totals are a size smaller than the metrics above, and days before recording started are faint cells instead of dashed boxes.
- When there is no previous period to compare with, the metric tiles leave the change line empty instead of showing a dash.
- The overview opens on the last 24 hours until a time range has been chosen.

**Fixes:**
- Token counts of a billion or more read like "1.81B" instead of "1809.8M".
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "thinkwatch-lite"
version = "2026.10.32"
version = "2026.10.33"
edition = "2024"
# **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust
# 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和
Expand Down
37 changes: 34 additions & 3 deletions src-tauri/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,19 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) {
if matches!(ev, tw_api::Event::EventsDropped { .. }) {
reconcile_notices(&a);
}
// **算不出费用的请求落地了**:哪个模型没有价格,事件里没有,按现状对一次账
// (`/pricing` 说得出是哪家上游的哪个模型)。一分钟最多一次:一个没定价的
// 模型每个请求都会来一条
if matches!(
ev,
tw_api::Event::RequestPriced {
cost_micros: None,
..
}
) && unpriced_due()
{
reconcile_notices(&a);
}
// **安全模式下换进了一份能用的配置**(界面里改好保存、一键修复、回滚、在
// 编辑器里改好):按正常模式重启,网关自己恢复,不用再去点「重新启动」
if matches!(ev, tw_api::Event::ConfigReloaded { .. })
Expand Down Expand Up @@ -560,6 +573,20 @@ async fn until_switched<F: std::future::Future>(
}
}

/// 无法计价触发的对账,上一次是什么时候(毫秒)。见事件流里 `RequestPriced` 那一处
static UNPRICED_AT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// 离上一次无法计价触发的对账过了一分钟没有;过了就记下这一次
fn unpriced_due() -> bool {
use std::sync::atomic::Ordering;
let now = notices::now_ms();
let last = UNPRICED_AT.load(Ordering::Relaxed);
now.saturating_sub(last) >= 60_000
&& UNPRICED_AT
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
}

/// 提醒按 core 的现状对账(见 [`notices::Notices::reconcile`])。问不到就算了:
/// 下一次接上还会再问,事件流上来的照常处理。
pub(crate) fn reconcile_notices(app: &tauri::AppHandle) {
Expand All @@ -573,19 +600,23 @@ pub(crate) fn reconcile_notices(app: &tauri::AppHandle) {
};
let asked_at = notices::now_ms();
let c = &st.control;
let (status, overview, quotas) = tokio::join!(
let (status, overview, quotas, pricing) = tokio::join!(
c.status(),
c.call::<ep::Overview>(&[], &()),
c.call::<ep::Quota>(&[], &())
c.call::<ep::Quota>(&[], &()),
c.call::<ep::Pricing>(&[], &())
);
let (Ok(status), Ok(overview), Ok(quotas)) = (status, overview, quotas) else {
let (Ok(status), Ok(overview), Ok(quotas), Ok(pricing)) =
(status, overview, quotas, pricing)
else {
tracing::debug!("提醒对账:没问到 core 的现状");
return;
};
let snap = notices::rules::Snapshot {
status: &status,
overview: &overview,
quotas: &quotas,
pricing: &pricing,
};
n.reconcile(notices::rules::from_snapshot(&snap, asked_at), asked_at);
});
Expand Down
55 changes: 55 additions & 0 deletions src-tauri/src/notices/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const SECURITY: &str = "security";
const MCP: &str = "mcp";
const PLUGINS: &str = "plugins";
const KEYS: &str = "keys";
/// 流量页,只看失败的那一批
const FAILED_REQUESTS: &str = "requests:failed";
const SETTINGS: &str = "settings";
/// 设置页的「网关监听」一节(`settings:<节>`,界面滚到那一节)
const LISTEN_SETTINGS: &str = "settings:listen";
Expand All @@ -62,6 +64,9 @@ pub fn default_view(key: &str) -> &'static str {
"scan" => MCP,
"plugin" => PLUGINS,
"keylimit" => KEYS,
"failed" => FAILED_REQUESTS,
// 价格设在上游页(每家上游的价目表)
"unpriced" => UPSTREAMS,
// 同步要处理的事在设置的「同步与备份」;密钥改写不成的在客户端页
"sync" => crate::sync::SYNC_VIEW,
"keysync" => "clients",
Expand Down Expand Up @@ -412,6 +417,27 @@ pub fn from_event(ev: &Event) -> Vec<Signal> {
.filter_map(|i| watched("redact", provider, &i.rule, i.custom, &i.detail))
.collect(),
Event::KeyLimitAlert { .. } => key_limit(ev),
// **请求失败只进应用内**(概览上原来那条「需要处理的事」挪到这里):多数失败是上游
// 一时出错,客户端会重试,为它弹系统通知不值得;一阵子接连失败攒成一条,次数照实记。
// 界面上中止的不算 —— 那是用户自己叫停的
Event::RequestFailed {
model,
source,
message,
..
} if *source != "aborted" => {
let why = core_text::clause(message);
vec![
Signal::raised("failed", Level::Info, tr!("请求失败", "Request Failed"))
.body(tr!(
format!("「{model}」:{why}。"),
format!("“{model}”: {why}.")
))
.view(FAILED_REQUESTS)
.event()
.gathered(),
]
}
Event::PluginFailed {
plugin_id,
plugin_name,
Expand Down Expand Up @@ -673,6 +699,8 @@ pub struct Snapshot<'a> {
pub status: &'a tw_api::Status,
pub overview: &'a tw_api::Overview,
pub quotas: &'a [tw_api::ProviderQuota],
/// 默认价目表的现状:最近 7 天里哪些上游的哪些模型有请求无法计价
pub pricing: &'a tw_api::PricingStatus,
}

/// 对账时认得的那几类键:**现状查得到的**。快照里没有、而列表里还开着的这几类,
Expand All @@ -688,6 +716,7 @@ pub const RECONCILED: &[&str] = &[
"quota:",
"config",
"listen",
"unpriced:",
];

/// 这个键是不是对账管的那几类
Expand Down Expand Up @@ -798,9 +827,35 @@ pub fn from_snapshot(s: &Snapshot<'_>, now_ms: u64) -> Vec<Signal> {
.iter()
.flat_map(from_event)
.filter(|sig| sig.change == Change::Raised)
.chain(s.pricing.unpriced_models.iter().map(unpriced))
.collect()
}

/// 一个模型最近 7 天里有请求无法计价(概览上原来那条「需要处理的事」挪到这里)。
///
/// **按现状说,没有对应的事件**:`request_priced` 不带模型,而无法计价是一个持续的状态 ——
/// 价目表里没有它,就一直算不出来。每家上游的每个模型一条,点开落到上游页去设价格;
/// 7 天里不再有算不出来的请求,对账时自己收起。只进应用内:请求照常转发,只是费用没计入
fn unpriced(m: &tw_api::UnpricedModel) -> Signal {
let (provider, model, n) = (&m.provider, &m.model, m.requests);
Signal::raised(
format!("unpriced:{provider}:{model}"),
Level::Info,
tr!(
format!("模型「{model}」未定价"),
format!("No Price for “{model}”")
),
)
.body(tr!(
format!("最近 7 天经「{provider}」的 {n} 次请求无法计价,费用未计入。在上游页为它设定价格。"),
format!(
"{n} {} through “{provider}” in the last 7 days could not be priced, so their cost is not counted. Set a price for it on the Upstreams page.",
if n == 1 { "request" } else { "requests" }
)
))
.view(UPSTREAMS)
}

/// 「仅记录」的命中:规则的动作就是仅记录,或者防护处于「监控」(那时每条规则都按仅记录
/// 处理)。**只认 core 说的结局**(`outcome_detail` 是 `recorded`),不自己按档位和动作推。
///
Expand Down
74 changes: 66 additions & 8 deletions src-tauri/src/notices/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1707,24 +1707,40 @@ fn chinese_notices_say_core_reasons_in_chinese() {
// ---------------------------------------------------------------- 对账

/// 一份真的现状:隔离实例里凭据在启动时就失效(令牌端点回 invalid_grant)、配置文件
/// 刚被外部改坏;额度那一段是手写的(一个窗口用完、一个没有)
fn snapshot() -> (tw_api::Status, tw_api::Overview, Vec<tw_api::ProviderQuota>) {
/// 刚被外部改坏;额度那一段是手写的(一个窗口用完、一个没有);价目表那一段也是手写的
/// (一个模型最近有 13 次请求无法计价)
fn snapshot() -> (
tw_api::Status,
tw_api::Overview,
Vec<tw_api::ProviderQuota>,
tw_api::PricingStatus,
) {
#[derive(serde::Deserialize)]
struct F {
status: tw_api::Status,
overview: tw_api::Overview,
quotas: Vec<tw_api::ProviderQuota>,
}
let f: F = serde_json::from_str(include_str!("fixtures/snapshot.json")).unwrap();
(f.status, f.overview, f.quotas)
let pricing = serde_json::from_value(serde_json::json!({
"date": "2026-10-28",
"source": "fetched",
"models": 2400,
"auto_update": true,
"unpriced_recent": 13,
"unpriced_models": [{ "provider": "openrouter", "model": "qwen/qwen3-coder-plus", "requests": 13 }],
}))
.unwrap();
(f.status, f.overview, f.quotas, pricing)
}

fn snapshot_keys(now: u64) -> Vec<String> {
let (status, overview, quotas) = snapshot();
let (status, overview, quotas, pricing) = snapshot();
let snap = rules::Snapshot {
status: &status,
overview: &overview,
quotas: &quotas,
pricing: &pricing,
};
let mut keys: Vec<String> = rules::from_snapshot(&snap, now)
.into_iter()
Expand All @@ -1739,19 +1755,29 @@ fn snapshot_keys(now: u64) -> Vec<String> {
fn the_snapshot_says_what_the_events_would_have_said() {
assert_eq!(
snapshot_keys(T0),
["config", "credential:官方", "quota:chatgpt:primary"]
[
"config",
"credential:官方",
"quota:chatgpt:primary",
"unpriced:openrouter:qwen/qwen3-coder-plus"
]
);
// 到了重置的时刻,那个窗口就不算用完了
assert_eq!(
snapshot_keys(4_102_444_800_001),
["config", "credential:官方"]
[
"config",
"credential:官方",
"unpriced:openrouter:qwen/qwen3-coder-plus"
]
);
with_lang(Lang::Zh, || {
let (status, overview, quotas) = snapshot();
let (status, overview, quotas, pricing) = snapshot();
let snap = rules::Snapshot {
status: &status,
overview: &overview,
quotas: &quotas,
pricing: &pricing,
};
let s = rules::from_snapshot(&snap, T0);
let cred = s.iter().find(|s| s.key == "credential:官方").unwrap();
Expand All @@ -1761,9 +1787,40 @@ fn the_snapshot_says_what_the_events_would_have_said() {
config.body,
"网关密钥名称「default」重复。上一版配置仍在服务。"
);
let unpriced = s.iter().find(|s| s.key.starts_with("unpriced:")).unwrap();
assert_eq!(unpriced.title, "模型「qwen/qwen3-coder-plus」未定价");
assert_eq!(
unpriced.body,
"最近 7 天经「openrouter」的 13 次请求无法计价,费用未计入。在上游页为它设定价格。"
);
assert_eq!(unpriced.level, Level::Info);
});
}

/// 请求失败只进应用内、攒成一条(概览上那条「需要处理的事」挪到了提醒里);界面上中止的不算
#[test]
fn a_failed_request_is_an_in_app_notice_and_an_abort_is_not() {
let failed = |source: tw_api::FailureSource| {
rules::from_event(&tw_api::Event::RequestFailed {
id: 9,
model: "claude-sonnet-5".into(),
source,
message: msg("gw.upstream.status", "upstream returned 529"),
sent_bytes: None,
received_bytes: None,
duration_ms: None,
usage: None,
answered_model: None,
})
};
let s = failed(tw_api::FailureSource::Upstream);
assert_eq!(s.len(), 1);
assert_eq!(s[0].key, "failed");
assert_eq!(s[0].level, Level::Info);
assert_eq!(rules::default_view("failed"), "requests:failed");
assert!(failed(tw_api::FailureSource::Aborted).is_empty());
}

/// 对账:**开着的不动**(不涨次数、看过的还是看过的、不再弹),没开的补上,现状里
/// 已经好了的收起来 —— 但只收对账管的那几类、只收问现状之前就开着的
#[tokio::test]
Expand Down Expand Up @@ -1794,11 +1851,12 @@ async fn reconciling_fills_in_what_was_missed_without_saying_anything_twice() {
);
let shown_before = b.titles().len();

let (status, overview, quotas) = snapshot();
let (status, overview, quotas, pricing) = snapshot();
let snap = rules::Snapshot {
status: &status,
overview: &overview,
quotas: &quotas,
pricing: &pricing,
};
let asked_at = T0 + 1_000;
b.bus
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ThinkWatch Lite",
"version": "2026.10.32",
"version": "2026.10.33",
"identifier": "app.thinkwatch.lite",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
9 changes: 9 additions & 0 deletions src/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ describe("token", () => {
expect(compact(200_000)).toBe("200k");
expect(compact(2_698_000)).toBe("2.7M");
});

it("十亿以上写 B,三位有效数字;先取整再定单位", () => {
expect(compact(1_809_800_000)).toBe("1.81B");
expect(compact(18_100_000_000)).toBe("18.1B");
expect(compact(181_400_000_000)).toBe("181B");
expect(compact(999_940_000)).toBe("999.9M");
expect(compact(999_960_000)).toBe("1.00B");
expect(compact(9_996_000_000)).toBe("10.0B");
});
});

describe("一行的金额", () => {
Expand Down
Loading
Loading