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 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.23",
"version": "2026.10.24",
"type": "module",
"packageManager": "pnpm@11.13.0",
"scripts": {
Expand Down
11 changes: 11 additions & 0 deletions release-notes/2026.10.24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
**Upgrade notes:**
- The bundled core is now 0.76.0 and the control-plane protocol is 49. A remote server running an older core must be upgraded to 0.76.0 to stay connected.
- Nothing is cleared; the request history, stored payloads and settings carry over.

**Live generation speed:**
- The menu bar's second line shows how fast the running requests are generating right now, added together, and updates every second while requests run. It shows 0 t/s while requests wait for their first token and a dash when nothing runs. Before, it showed the speed of requests that had finished in the last minute, so a long request showed a dash until it ended.
- Each row under In Progress in the menu shows that request's current speed next to its running time.
- On the Traffic page, a running request shows its current speed in the token column until it finishes.

**Other:**
- An upstream that hangs up in the middle of an answer is reported as a broken connection with its cause, instead of "error decoding response body".
2 changes: 2 additions & 0 deletions scripts/shots/mock/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
HISTORY,
IN_FLIGHT,
liveView,
SEC_EVENTS,
bodies,
historySearch,
Expand Down Expand Up @@ -77,6 +78,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler<N> } = {
.providers.filter((p) => !req.provider || p.name === req.provider)
.map((p) => l1(p.name, p.proxy === "direct" || p.proxy === "system" ? null : p.proxy)),
InFlight: () => ({ now_ms: Date.now(), requests: clone(IN_FLIGHT) }),
Live: () => liveView(),

GetConfig: () => ({ path: "~/.thinkwatch/config.yaml", text: CONFIG_TEXT, version: configVersion() }),
PatchConfig: refuse,
Expand Down
40 changes: 40 additions & 0 deletions scripts/shots/mock/traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type {
HistorySearchQuery,
InFlightRequest,
LatencyView,
LiveView,
RunningView,
TokenRateView,
Msg,
PriceFields,
Expand Down Expand Up @@ -195,6 +197,44 @@ export const SEC_EVENTS: SecurityEventView[] = [];
/** 打开页面时还在跑的请求(`InFlight` 快照):每个请求到目前为止的事件 */
export const IN_FLIGHT: InFlightRequest[] = [];

/** 截图里在跑的那一条此刻每秒生成多少 token:已经开口了 */
const LIVE_SPEED = 64;

/**
* `/live`:在跑的那几条和它们此刻的速度,照 core 的口径 —— 开口之前的没有速度,合计是
* 各条加起来,一条都不在跑时是空
*/
export function liveView(): LiveView {
const running: RunningView[] = IN_FLIGHT.flatMap((f) => {
const s = f.events[0];
if (s?.kind !== "request_started") return [];
const talking = f.events.some((e) => e.kind === "request_first_token");
const elapsed = Math.max(0, Date.now() - s.at_ms);
return [
{
id: f.id,
client: s.client,
client_hint: s.client_hint ?? null,
model: s.model,
provider: s.provider,
at_ms: s.at_ms,
elapsed_ms: elapsed,
session: s.session ?? null,
route: s.route,
rule: s.rule,
group: s.group ?? null,
upstream: s.provider,
tokens_per_sec: talking ? LIVE_SPEED : null,
output_tokens: talking ? Math.round((elapsed / 1000) * LIVE_SPEED) : null,
},
];
});
return {
running,
tokens_per_sec: running.length === 0 ? null : running.reduce((a, r) => a + (r.tokens_per_sec ?? 0), 0),
};
}

function makeRow(s: Spec, r: () => number): HistoryRow {
const outcome = s.outcome ?? { kind: "ok" };
const { dialect, path } = SPEAKS[s.who];
Expand Down
42 changes: 21 additions & 21 deletions src-tauri/Cargo.lock

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

14 changes: 7 additions & 7 deletions 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.23"
version = "2026.10.24"
edition = "2024"
# **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust
# 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和
Expand All @@ -26,12 +26,12 @@ license = "MIT"
# twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是
# 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。
[workspace.dependencies]
tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.75.0" }
tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }
tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }
tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }
tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }
tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.76.0" }

[lib]
name = "thinkwatch_lite_lib"
Expand Down
15 changes: 10 additions & 5 deletions src-tauri/examples/menubar_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,14 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot {
let remote_hours = [
0, 0, 0, 0, 0, 0, 0, 0, 2, 5, 6, 4, 3, 5, 7, 6, 4, 3, 2, 1, 3, 2,
];
let live = |id: u64, app: &str, model: &str, secs: u64| Live {
// `rate`:这个请求此刻的速度,还在等第一个 token 的是 None。菜单栏上的速度是它们的合计
let live = |id: u64, app: &str, model: &str, secs: u64, rate: Option<u32>| Live {
id,
app: Some(app.into()),
key: app.into(),
model: model.into(),
started_ms: start - secs * 1000,
rate,
};
let remote = matches!(name, "remote" | "unlinked");
let base = Snapshot {
Expand Down Expand Up @@ -281,8 +283,8 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot {
.into(),
}],
live: vec![
live(41, "claude-code", "claude-opus-4-6", 12),
live(42, "codex", "gpt-5.2-codex", 3),
live(41, "claude-code", "claude-opus-4-6", 12, Some(42)),
live(42, "codex", "gpt-5.2-codex", 3, None),
],
update: Some("2026.10.9".into()),
..base
Expand Down Expand Up @@ -366,7 +368,10 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot {
},
],
live: (0..7)
.map(|i| live(7 + i, "claude-code", "claude-sonnet-4-5", 21 + i * 13))
.map(|i| {
let rate = [Some(18), Some(0), Some(34)].get(i as usize).copied().flatten();
live(7 + i, "claude-code", "claude-sonnet-4-5", 21 + i * 13, rate)
})
.collect(),
..base
}
Expand All @@ -377,7 +382,7 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot {
today,
hourly,
rate: Some(38),
live: vec![live(9, "claude-code", "claude-sonnet-4-5", 5)],
live: vec![live(9, "claude-code", "claude-sonnet-4-5", 5, Some(38))],
..base
}
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/examples/menubar_shots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ fn main() {
key: "claude-code".into(),
model: "claude-sonnet-5".into(),
started_ms: now - 6_000,
rate: Some(64),
}],
rate: Some(64),
notices_on: true,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ webview_endpoints![
Overview,
L1,
InFlight,
// 在跑的请求此刻生成得多快:流量页在跑的那几行每秒问一次
Live,
// 配置原文与历史
GetConfig,
PatchConfig,
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,9 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) {
)
{
st.menubar.notify_one();
if matches!(ev, tw_api::Event::RequestStarted { .. }) {
st.menubar_started.notify_one();
}
}
// core 说这个订阅者掉过队:掉的那几条里可能有该提醒的事
if matches!(ev, tw_api::Event::EventsDropped { .. }) {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ pub struct AppState {
/// **立刻**重收一次,不等攒够三秒:菜单刚打开、换了菜单栏的样式或界面语言、
/// 钟跳了(睡醒、改时钟、换时区,见 `clock`)
pub menubar_now: Arc<tokio::sync::Notify>,
/// 有请求开始了:菜单栏马上问一次在跑的和速度(`/live`),不等攒够三秒。菜单栏上的
/// 速度从这一刻起每秒走,第一秒就是 0 而不是破折号
pub menubar_started: Arc<tokio::sync::Notify>,
}

pub fn run() {
Expand Down Expand Up @@ -413,6 +416,7 @@ pub fn run() {
supervising: supervising.clone(),
menubar: menubar_wake,
menubar_now: Arc::new(tokio::sync::Notify::new()),
menubar_started: Arc::new(tokio::sync::Notify::new()),
});
// 等另一个位置的程序请求切换(见 `single`)
single::listen(&handle);
Expand Down
24 changes: 16 additions & 8 deletions src-tauri/src/menubar/macos/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ impl InfoView {

/// 同一行里两段字之间至少空这么多
const TEXT_GAP: f64 = 10.0;
/// 「进行中」一行:速度和时长之间。比两段字之间窄一点:两样都是灰的等宽数字,挨着读
const LIVE_GAP: f64 = 8.0;
/// 自画的行最多把菜单撑到这么宽。再宽就收窄柱子、截字(见 [`today_fit`])
const MAX_WIDTH: f64 = 380.0;

Expand Down Expand Up @@ -475,22 +477,28 @@ pub(super) fn draw_row(row: &Row, b: NSRect, hl: bool) {
Row::Live {
app,
model,
rate,
elapsed,
..
} => {
if let Some(img) = symbol("circle.dashed", Some(&*ink.secondary)) {
img.drawInRect(rect(PAD, 4.0, 16.0, 16.0));
}
let el = attributed(
elapsed,
&mono(12.0, weight(Weight::Regular)),
&ink.secondary,
);
let font = mono(12.0, weight(Weight::Regular));
let el = attributed(elapsed, &font, &ink.secondary);
let ew = el.size().width;
el.drawAtPoint(NSPoint::new(w - PAD - ew, 4.5));
// 应用名和模型都在时长左边。放不下先截模型;应用名(认不出应用时是密钥名,
// 多长都有)再长也截在时长前面
let right = w - PAD - ew - 10.0;
// 此刻的速度在时长左边,同一种字、同一个颜色:两样都是随时在走的数。等宽字,
// 数字变了位置不晃;开口之前没有速度,那一段空着、模型往右多占一点
let rw = rate.as_deref().map_or(0.0, |r| {
let a = attributed(r, &font, &ink.secondary);
let aw = a.size().width;
a.drawAtPoint(NSPoint::new(w - PAD - ew - LIVE_GAP - aw, 4.5));
aw + LIVE_GAP
});
// 应用名和模型都在速度和时长左边。放不下先截模型;应用名(认不出应用时是密钥名,
// 多长都有)再长也截在它们前面
let right = w - PAD - ew - rw - 10.0;
let a = attributed(app, &NSFont::menuFontOfSize(0.0), &ink.label);
let aw = match fit(&a, right - TEXT_X) {
Some(a) => {
Expand Down
Loading
Loading