diff --git a/package.json b/package.json index 9ebb6222..0e58846e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/release-notes/2026.10.24.md b/release-notes/2026.10.24.md new file mode 100644 index 00000000..640346bb --- /dev/null +++ b/release-notes/2026.10.24.md @@ -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". diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 03b5658f..b82be50b 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -21,6 +21,7 @@ import { import { HISTORY, IN_FLIGHT, + liveView, SEC_EVENTS, bodies, historySearch, @@ -77,6 +78,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { .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, diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index 301ecc01..9ba0b582 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -21,6 +21,8 @@ import type { HistorySearchQuery, InFlightRequest, LatencyView, + LiveView, + RunningView, TokenRateView, Msg, PriceFields, @@ -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]; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1760f854..4672ba23 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3626,7 +3626,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3682,7 +3682,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4810,7 +4810,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4833,7 +4833,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.23" +version = "2026.10.24" dependencies = [ "anyhow", "block2", @@ -5337,8 +5337,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "serde", "serde_json", @@ -5349,8 +5349,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "memchr", "serde", @@ -5359,8 +5359,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5378,8 +5378,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "serde", "serde_json", @@ -5405,8 +5405,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "serde", "ts-rs", @@ -5414,8 +5414,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "libc", "notify", @@ -5425,8 +5425,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.75.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.75.0#f040a92a2d509d24c21e8a460e25f6cd3db2cd39" +version = "0.76.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.76.0#7c8cdb47b11178d487580b6e7225b09578fc42ba" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5890,7 +5890,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5c99bd99..71148fec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 和 @@ -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" diff --git a/src-tauri/examples/menubar_preview.rs b/src-tauri/examples/menubar_preview.rs index 75a7cedd..63f525c3 100644 --- a/src-tauri/examples/menubar_preview.rs +++ b/src-tauri/examples/menubar_preview.rs @@ -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| 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 { @@ -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 @@ -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 } @@ -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 } } diff --git a/src-tauri/examples/menubar_shots.rs b/src-tauri/examples/menubar_shots.rs index 00a922cc..fe8775d4 100644 --- a/src-tauri/examples/menubar_shots.rs +++ b/src-tauri/examples/menubar_shots.rs @@ -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, diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 099f3474..b7b43463 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -61,6 +61,8 @@ webview_endpoints![ Overview, L1, InFlight, + // 在跑的请求此刻生成得多快:流量页在跑的那几行每秒问一次 + Live, // 配置原文与历史 GetConfig, PatchConfig, diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index e0269a7e..55f2614c 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -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 { .. }) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3970e262..50fdba91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,9 @@ pub struct AppState { /// **立刻**重收一次,不等攒够三秒:菜单刚打开、换了菜单栏的样式或界面语言、 /// 钟跳了(睡醒、改时钟、换时区,见 `clock`) pub menubar_now: Arc, + /// 有请求开始了:菜单栏马上问一次在跑的和速度(`/live`),不等攒够三秒。菜单栏上的 + /// 速度从这一刻起每秒走,第一秒就是 0 而不是破折号 + pub menubar_started: Arc, } pub fn run() { @@ -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); diff --git a/src-tauri/src/menubar/macos/info.rs b/src-tauri/src/menubar/macos/info.rs index e356db9d..a7b8fb1c 100644 --- a/src-tauri/src/menubar/macos/info.rs +++ b/src-tauri/src/menubar/macos/info.rs @@ -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; @@ -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) => { diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 34c1230f..90d72e6e 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -29,7 +29,8 @@ use crate::{AppState, error::CmdError, notices, supervisor::CoreState}; /// 把这一条落库、算出费用的时间 const SETTLE: std::time::Duration = std::time::Duration::from_secs(3); -/// 菜单开着时隔多久走一次秒数和倒计时。**只在开着时走**,关上就停 +/// 菜单开着时隔多久走一次秒数和倒计时;菜单栏上挂着数字、有请求在跑时,也按它刷新此刻的 +/// 速度。**两样都不是时就停** const TICK: std::time::Duration = std::time::Duration::from_secs(1); /// 问 core 的一句最多等多久。**core 接了连接却不回话**(卡在存储层上、远程那台半死不活) @@ -138,7 +139,11 @@ async fn run(app: tauri::AppHandle) { let Some(st) = app.try_state::() else { return; }; - let (wake, now) = (st.menubar.clone(), st.menubar_now.clone()); + let (wake, now, started) = ( + st.menubar.clone(), + st.menubar_now.clone(), + st.menubar_started.clone(), + ); let mut core_rx = st.supervisor.watch(); let mut credits = Credits::default(); let mut tick = tokio::time::interval(TICK); @@ -165,21 +170,17 @@ async fn run(app: tauri::AppHandle) { let reset = next_reset_in(&snap, day_end).map(|d| tokio::time::Instant::now() + d); let mut due = None; // 菜单开着时每秒走一次:秒数、倒计时现算,在跑的和速率问 core 一次(很轻), - // 汇总和额度不问。**菜单栏上挂着速度时菜单关着也走**:速度只算最近一分钟跑完的 - // 请求,不再问的话,最后一个请求落地之后菜单栏上的速度就一直挂着 + // 汇总和额度不问。**菜单栏上挂着数字、有请求在跑时菜单关着也走**:速度是此刻的, + // 请求跑着的每一秒都在变。都跑完了就停(最后那一问拿到的是破折号);下一个请求开始 + // 时 `started` 叫醒这里,马上问一次就又走起来 loop { - let rate_shown = snap.rate.is_some() && drawn_style() != Style::Icon; - let ticking = || open() || rate_shown; - if wait( - &wake, - &now, - &mut core_rx, - reset, - &mut tick, - ticking, - &mut due, - ) - .await + let ticking = || ticks(open(), &snap, drawn_style()); + let wakes = Wakes { + wake: &wake, + now: &now, + started: &started, + }; + if wait(&wakes, &mut core_rx, reset, &mut tick, ticking, &mut due).await == Woke::Collect { break; @@ -190,22 +191,36 @@ async fn run(app: tauri::AppHandle) { } } +/// 该不该每秒走一次:菜单开着;或者有请求在跑、菜单栏上挂着速度那一行(只有标识的、 +/// 不显示的没有)。**什么都没在跑、菜单关着时不走**:菜单栏上的破折号不会自己变 +fn ticks(open: bool, snap: &Snapshot, style: Style) -> bool { + open || (!snap.live.is_empty() && matches!(style, Style::Full | Style::Numbers)) +} + +/// 叫醒菜单栏的几种理由(见 [`wait`]):有事件(攒一攒再收)、要立刻收、有请求开始了 +struct Wakes<'a> { + wake: &'a tokio::sync::Notify, + now: &'a tokio::sync::Notify, + started: &'a tokio::sync::Notify, +} + /// [`wait`] 为什么回来了 #[derive(Debug, PartialEq, Eq)] enum Woke { /// 整个重收 Collect, - /// 菜单开着或者菜单栏上挂着速度,走一秒 + /// 菜单开着,或者有请求在跑、菜单栏上挂着速度:走一秒 Tick, } /// 等到该重收的时候。有事件就攒 [`SETTLE`] 再收;`now`(菜单打开、换了样式或语言、钟跳了)、 /// core 换了状态、到了 `reset`(额度重置、本地零点,见 [`next_reset_in`]),立刻收。 -/// `ticking`(菜单开着,或者菜单栏上挂着速度)时每秒回来一次 [`Woke::Tick`],**攒着的 -/// 那几秒也走** —— 不然请求一个接一个落地时,开着的菜单里秒数会一卡几秒。`due` 是攒到什么时候,由调用方带着跨过这几次 tick +/// `ticking`(菜单开着,或者有请求在跑、菜单栏上挂着速度)时每秒回来一次 [`Woke::Tick`], +/// **攒着的那几秒也走** —— 不然请求一个接一个落地时,开着的菜单里秒数会一卡几秒。`due` 是 +/// 攒到什么时候,由调用方带着跨过这几次 tick。`started`(有请求开始了)马上回来一次 +/// [`Woke::Tick`]:问到在跑的,`ticking` 就成立了,速度从第一秒起每秒走 async fn wait( - wake: &tokio::sync::Notify, - now: &tokio::sync::Notify, + w: &Wakes<'_>, core_rx: &mut tokio::sync::watch::Receiver, reset: Option, tick: &mut tokio::time::Interval, @@ -215,11 +230,12 @@ async fn wait( loop { let settle = *due; tokio::select! { - _ = wake.notified(), if settle.is_none() => { + _ = w.wake.notified(), if settle.is_none() => { *due = Some(tokio::time::Instant::now() + SETTLE); } _ = sleep_until(settle), if settle.is_some() => return Woke::Collect, - _ = now.notified() => return Woke::Collect, + _ = w.now.notified() => return Woke::Collect, + _ = w.started.notified() => return Woke::Tick, _ = core_rx.changed() => return Woke::Collect, _ = sleep_until(reset), if reset.is_some() => return Woke::Collect, _ = tick.tick(), if ticking() => return Woke::Tick, @@ -242,7 +258,7 @@ fn present(snap: &Snapshot) { } /// 只更新随时间走的那几样:现在几点、谁在跑、速率。**问 core 要**(`/live`): -/// 在跑的和最近的生成速率是 core 的事件总线数的,这边不再自己听事件去数。 +/// 在跑的和它们此刻的生成速度是 core 数的,这边不再自己听事件去数。 /// 问不到(或者 [`ASK`] 之内没回话)时留着上一次的 async fn refresh_live(state: &AppState, snap: &mut Snapshot) { snap.now_ms = notices::now_ms(); @@ -278,6 +294,7 @@ fn apply_live(snap: &mut Snapshot, live: tw_api::LiveView) { key: r.client, model: r.model, started_ms: now.saturating_sub(r.elapsed_ms), + rate: r.tokens_per_sec, }) .collect(); snap.rate = live.tokens_per_sec; @@ -834,9 +851,15 @@ mod tests { tick: &mut tokio::time::Interval, open: bool, ) -> usize { + let started = tokio::sync::Notify::new(); let mut due = None; let mut ticks = 0; - while wait(wake, now, rx, reset, tick, || open, &mut due).await == Woke::Tick { + let w = Wakes { + wake, + now, + started: &started, + }; + while wait(&w, rx, reset, tick, || open, &mut due).await == Woke::Tick { ticks += 1; } ticks @@ -866,6 +889,30 @@ mod tests { assert_eq!(ticks, 0); } + /// **请求一开始就问一次在跑的**,菜单关着、什么都没在走也是:不等攒够三秒 + #[tokio::test(start_paused = true)] + async fn a_started_request_asks_for_the_live_numbers_at_once() { + let (wake, now, tx, mut tick) = setup(); + let mut rx = tx.subscribe(); + let started = tokio::sync::Notify::new(); + let start = tokio::time::Instant::now(); + wake.notify_one(); + started.notify_one(); + let w = Wakes { + wake: &wake, + now: &now, + started: &started, + }; + let mut due = None; + let woke = wait(&w, &mut rx, None, &mut tick, || false, &mut due).await; + assert_eq!(woke, Woke::Tick); + assert_eq!(start.elapsed(), std::time::Duration::ZERO); + // 攒着的那次重收照旧在三秒后 + let woke = wait(&w, &mut rx, None, &mut tick, || false, &mut due).await; + assert_eq!(woke, Woke::Collect); + assert_eq!(start.elapsed(), SETTLE); + } + #[tokio::test(start_paused = true)] async fn opening_the_menu_or_a_core_change_collects_at_once() { let (wake, now, tx, mut tick) = setup(); @@ -881,9 +928,29 @@ mod tests { assert_eq!(start.elapsed(), std::time::Duration::ZERO); } - /// core 给的在跑的请求照原样进菜单:谁、哪个模型、跑了多久,还有速率。**跑了多久 - /// 以 core 算的为准**:它的时钟比这台机器快了一分钟(`at_ms` 在「将来」),菜单上 - /// 照样是 12 秒 + /// core 报的一个在跑的请求 + fn running(id: u64, elapsed_ms: u64, tokens_per_sec: Option) -> tw_api::RunningView { + tw_api::RunningView { + id, + client: "default".into(), + client_hint: Some("codex".into()), + model: "gpt-5".into(), + provider: "openai".into(), + at_ms: 148_000, + elapsed_ms, + session: None, + route: "default".into(), + rule: "catch-all".into(), + group: Some("__all__".into()), + upstream: None, + tokens_per_sec, + output_tokens: tokens_per_sec.map(|_| 300), + } + } + + /// core 给的在跑的请求照原样进菜单:谁、哪个模型、跑了多久、此刻多快,还有合计的速度。 + /// **跑了多久以 core 算的为准**:它的时钟比这台机器快了一分钟(`at_ms` 在「将来」), + /// 菜单上照样是 12 秒 #[test] fn the_live_numbers_come_from_core_as_they_are() { let mut snap = Snapshot { @@ -893,40 +960,66 @@ mod tests { apply_live( &mut snap, tw_api::LiveView { - running: vec![tw_api::RunningView { - id: 7, - client: "default".into(), - client_hint: Some("codex".into()), - model: "gpt-5".into(), - provider: "openai".into(), - at_ms: 148_000, - elapsed_ms: 12_000, - session: None, - route: "default".into(), - rule: "catch-all".into(), - group: Some("__all__".into()), - upstream: None, - }], + running: vec![running(7, 12_000, Some(52)), running(8, 1_000, None)], tokens_per_sec: Some(52), }, ); assert_eq!( snap.live, - vec![model::Live { - id: 7, - app: Some("codex".into()), - key: "default".into(), - model: "gpt-5".into(), - started_ms: 88_000, - }] + vec![ + model::Live { + id: 7, + app: Some("codex".into()), + key: "default".into(), + model: "gpt-5".into(), + started_ms: 88_000, + rate: Some(52), + }, + // 还在等第一个 token:没有速度 + model::Live { + id: 8, + app: Some("codex".into()), + key: "default".into(), + model: "gpt-5".into(), + started_ms: 99_000, + rate: None, + }, + ] ); assert_eq!(snap.rate, Some(52)); - // 下一次一个都不在跑、这一分钟也没有跑完的:都清掉,不留上一次的 + // 在跑、却都还没开始生成:0,不是没有 + apply_live( + &mut snap, + tw_api::LiveView { + running: vec![running(8, 2_000, None)], + tokens_per_sec: Some(0), + }, + ); + assert_eq!(snap.rate, Some(0)); + // 下一次一个都不在跑:都清掉,不留上一次的 apply_live(&mut snap, tw_api::LiveView::default()); assert!(snap.live.is_empty()); assert_eq!(snap.rate, None); } + /// **有请求在跑、菜单栏上挂着速度时,菜单关着也每秒走**:速度是此刻的,每一秒都在变。 + /// 都跑完了就停;只有标识、不显示的那两档菜单栏上没有速度,菜单关着不走 + #[test] + fn the_bar_ticks_while_a_request_runs_and_stops_when_none_does() { + let mut snap = Snapshot::default(); + for style in [Style::Full, Style::Numbers, Style::Icon, Style::Hidden] { + assert!(!ticks(false, &snap, style), "{style:?}"); + assert!(ticks(true, &snap, style), "菜单开着总要走:{style:?}"); + } + snap.live = vec![model::Live::default()]; + // 还没开始生成(速度是 0、单个请求没有速度)也走:第一个 token 一到就要跟上 + snap.rate = Some(0); + assert!(ticks(false, &snap, Style::Full)); + assert!(ticks(false, &snap, Style::Numbers)); + assert!(!ticks(false, &snap, Style::Icon)); + assert!(!ticks(false, &snap, Style::Hidden)); + } + /// core 接了连接却一直不回话:问一句最多等 [`ASK`],等不到就当没问到。收数和每秒的 /// `/live` 都是一问接一问的,一问挂住,菜单栏就再也不刷新 #[tokio::test(start_paused = true)] diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 227b3b63..b4bf81ed 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -58,7 +58,8 @@ pub struct Snapshot { pub quotas: Vec, /// 进行中的请求,开始得早的在前 pub live: Vec, - /// 最近一分钟跑完的请求平均每秒生成多少 token + /// 此刻每秒生成多少 token:在跑的请求各自的速度加起来(core 的 `/live`)。**没有在跑的 + /// 请求是 None**;有请求在跑、却都还没开始生成(在等第一个 token,或者是非流式的)是 0 pub rate: Option, /// 未读的提醒,要紧的在前 pub notices: Vec, @@ -229,6 +230,9 @@ pub struct Live { /// 开始的时刻,**这台机器的时钟**:由 core 报的「已经跑了多久」推回来的, /// 和 `Snapshot::now_ms` 相减就是已跑时长 pub started_ms: u64, + /// 这个请求此刻每秒生成多少 token。**第一个 token 之前、非流式的没有**;开了口又停下来 + /// 不吐字的是 0 + pub rate: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -300,7 +304,7 @@ pub enum Tone { #[derive(Debug, Clone, PartialEq)] pub struct Bar { pub style: Style, - /// 上面一行今日 token,下面一行最近一分钟的生成速度。**None 画成两道破折号**:还不知道 + /// 上面一行今日 token,下面一行此刻的生成速度。**None 画成两道破折号**:还不知道 pub numbers: Option<(String, String)>, /// 正在启动,或者不在运行 pub dim: bool, @@ -373,7 +377,7 @@ pub struct Dash { pub tone: StateTone, /// 连着远程时那台服务器的名字,跟在状态后面。原来写在状态头上,状态头没了 pub server: Option, - /// 最近一分钟的生成速度「42 token/秒」,知道时才有 + /// 此刻的生成速度「42 token/秒」,有请求在跑时才有 pub rate: Option, /// 今天的 token,大字。**没问到是破折号**,不是 0 pub value: String, @@ -510,9 +514,12 @@ pub enum Row { window: WindowRow, action: Action, }, + /// 一个在跑的请求:谁、哪个模型,右边是此刻的速度(开口之前没有)和已跑时长 Live { app: String, model: String, + /// 「42 t/s」。第一个 token 之前、非流式的没有 + rate: Option, elapsed: String, action: Action, }, @@ -563,8 +570,8 @@ fn bar(s: &Snapshot, style: Style) -> Bar { Bar { style, numbers: if running { - // 费用不上菜单栏(用户说金额没什么用),在点开的菜单和悬停提示里。最近一分钟没有 - // 跑完的请求就没有速度,写破折号:不是 0 token/秒 + // 费用不上菜单栏(用户说金额没什么用),在点开的菜单和悬停提示里。速度是此刻的: + // 没有在跑的请求写破折号,不是 0 t/s;在跑却都还没开始生成的是 0 t/s s.today .as_ref() .map(|t| (tokens_short(t.tokens), rate_short(s.rate))) @@ -805,6 +812,7 @@ fn rows(s: &Snapshot) -> Vec { .unwrap_or(&l.key) .to_string(), model: l.model.clone(), + rate: l.rate.map(|r| rate_short(Some(r))), elapsed: elapsed(s.now_ms.saturating_sub(l.started_ms)), action: Action::OpenRequest(l.id), }); @@ -1204,7 +1212,8 @@ pub fn tokens_short(n: i64) -> String { } } -/// 菜单栏上的生成速度:`42 t/s`、`2.2k t/s`,数字和 token 数同一套写法。没有速度是 `— t/s` +/// 菜单栏上的生成速度:`42 t/s`、`2.2k t/s`,数字和 token 数同一套写法。没有速度是 `— t/s`。 +/// 菜单里「进行中」每一行右边的速度也是这个写法:那一行要放下应用、模型和时长 pub fn rate_short(rate: Option) -> String { let n = rate.map_or_else(|| "—".to_string(), |r| tokens_short(r.into())); format!("{n} t/s") diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs index 9d8fb18d..a9d1926d 100644 --- a/src-tauri/src/menubar/model/tests.rs +++ b/src-tauri/src/menubar/model/tests.rs @@ -346,7 +346,7 @@ fn the_status_block_says_why_the_gateway_is_not_running() { }); } -/// 菜单栏上两行:今日 token 在上,最近一分钟的生成速度在下。**费用不上菜单栏**(用户说 +/// 菜单栏上两行:今日 token 在上,此刻的生成速度在下。**费用不上菜单栏**(用户说 /// 金额没什么用),菜单里和悬停提示里照旧有 #[test] fn the_bar_shows_todays_tokens_over_the_generation_speed() { @@ -361,7 +361,10 @@ fn the_bar_shows_todays_tokens_over_the_generation_speed() { }); s.rate = Some(2_215); assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "2.2k t/s"); - // 最近一分钟没有跑完的请求:没有速度,不是 0 + // 有请求在跑、却都还没开始生成:0,不是破折号 + s.rate = Some(0); + assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "0 t/s"); + // 没有在跑的请求:没有速度,不是 0 s.rate = None; assert_eq!(build(&s, Style::Full).0.numbers.unwrap().1, "— t/s"); } @@ -732,6 +735,12 @@ fn requests_in_progress_put_a_dot_on_the_mark_and_list_at_most_five() { key: "default".into(), model: "gpt-5-codex".into(), started_ms: NOW - 102_000, + // 第一个开口了,第二个开了口又停下来,其余的还在等第一个 token + rate: match i { + 1 => Some(1_234), + 2 => Some(0), + _ => None, + }, }) .collect(); let (bar, rows) = build(&s, Style::Full); @@ -739,14 +748,19 @@ fn requests_in_progress_put_a_dot_on_the_mark_and_list_at_most_five() { let live: Vec<_> = rows .iter() .filter_map(|r| match r { - Row::Live { app, elapsed, .. } => Some((app.as_str(), elapsed.as_str())), + Row::Live { + app, rate, elapsed, .. + } => Some((app.as_str(), rate.as_deref(), elapsed.as_str())), _ => None, }) .collect(); assert_eq!(live.len(), MAX_LIVE); - assert_eq!(live[0], ("Codex", "1:42")); + // 每一行右边是此刻的速度和已跑时长,速度和菜单栏上同一个写法 + assert_eq!(live[0], ("Codex", Some("1.2k t/s"), "1:42")); // 认不出应用的用密钥名 - assert_eq!(live[1].0, "default"); + assert_eq!(live[1], ("default", Some("0 t/s"), "1:42")); + // 还没开始生成的不写速度 + assert_eq!(live[2].1, None); assert_eq!(item(&rows, "live-more").unwrap().title, "另有 2 个"); } @@ -943,7 +957,7 @@ fn the_tooltip_says_the_whole_state_in_one_sentence() { #[test] fn a_row_keeps_its_shape_while_only_its_text_changes() { - // 菜单开着时,秒数、倒计时每秒在变 —— 那不该让菜单整个重建 + // 菜单开着时,秒数、倒计时、请求此刻的速度每秒在变 —— 那不该让菜单整个重建 let mut s = running(); s.live = vec![Live { id: 7, @@ -965,6 +979,9 @@ fn a_row_keeps_its_shape_while_only_its_text_changes() { s.today.as_mut().unwrap().tokens += 50_000; s.hourly.as_mut().unwrap().tokens[10] += 50_000; s.quotas[0].windows[0].used_percent = 32.0; + // 第一个 token 到了:那一行多出速度,行还是那一行 + s.live[0].rate = Some(42); + s.rate = Some(42); let b: Vec = rows(&s).iter().map(Row::shape).collect(); assert_eq!(a, b); } diff --git a/src-tauri/src/menubar/tray.rs b/src-tauri/src/menubar/tray.rs index b003c4aa..a779e3c2 100644 --- a/src-tauri/src/menubar/tray.rs +++ b/src-tauri/src/menubar/tray.rs @@ -182,13 +182,19 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result Row::Live { app: who, model, + rate, elapsed, action, } => { + // 一行字:和 macOS 那一行同样的顺序,速度在时长前面,开口之前没有 + let right = match rate { + Some(r) => format!("{r} {elapsed}"), + None => elapsed.clone(), + }; items.push(Box::new(MenuItem::with_id( app, id(action), - format!("{who} {model} {elapsed}"), + format!("{who} {model} {right}"), true, None::<&str>, )?)); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index be9368bf..9dc57b28 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.23", + "version": "2026.10.24", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/control.ts b/src/control.ts index 01b20dec..152b6588 100644 --- a/src/control.ts +++ b/src/control.ts @@ -29,6 +29,7 @@ export const WEBVIEW_ENDPOINTS = [ "Overview", "L1", "InFlight", + "Live", "GetConfig", "PatchConfig", "PutConfig", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 8cfac321..f74ea03c 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 48; +export const CONTROL_API_VERSION = 49; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -2545,7 +2545,7 @@ status: number | null, outcome: LiveOutcome, }; export type LiveOutcome = "finished" | "failed" | "cancelled" | "aborted"; /** - * 此刻的实时读数(`GET /live`):在跑的请求,和最近跑完的请求生成得多快。 + * 此刻的实时读数(`GET /live`):在跑的请求,和它们此刻生成得多快。 * * **按事件数出来的,core 里只数一份。**以前菜单栏自己听事件流、自己记在跑的和 * 跑完的:半路才开始听的那一段要拿 `/in-flight` 对账,丢过事件要重对,而这些 @@ -2557,10 +2557,13 @@ export type LiveView = { */ running: Array, /** - * 最近一分钟跑完的请求平均每秒生成多少 token:每个请求的速度(见 - * `RequestFinished::tokens_per_sec`)按它生成用的时间加权,也就是这些请求 - * 吐出的 token 合计除以生成用的时间合计。**只数有速度的**:非流式的不算。这一分钟里 - * 没有的是空,不是 0 + * 此刻每秒生成多少 token:在跑的请求各自的速度([`RunningView::tokens_per_sec`])加起来。 + * **没有在跑的请求是空**;有请求在跑、却都还在等第一个 token(或者是非流式的)是 0。 + * + * 是估的:流里每到一段正文、推理文字、工具参数就按字数估 token(同输入的估算),再乘上 + * 这家上游这个模型此前实测的比例 —— 每个流式请求结束时,拿上游报的输出 token 数校准 + * 一次。上游在流里报累计数的(Gemini)用它报的。**请求结束后记下的速度 + * (`RequestFinished::tokens_per_sec`)照旧按上游报的实数算**,和这里不是一个数 */ tokens_per_sec: number | null, }; @@ -4471,7 +4474,17 @@ route: string, rule: string, group: string | null, * 实际接下它的上游:尝试链里接下了的那一跳。**路由报出结论之前没有** * (还在等上游的响应头,或者正在故障转移) */ -upstream: string | null, }; +upstream: string | null, +/** + * 这个请求此刻每秒生成多少 token:最近 [`LIVE_RATE_WINDOW_MS`] 毫秒(刚开口不到这么久 + * 的,从第一个 token 算起)里估出的输出除以这段时间,估法见 [`LiveView::tokens_per_sec`]。 + * 停下来不吐字的时候往下掉到 0。**第一个 token 之前、非流式的没有** + */ +tokens_per_sec: number | null, +/** + * 到目前为止估的输出 token(同上的估法)。**第一个 token 之前、非流式的没有** + */ +output_tokens: number | null, }; /** * 一页搜索停在哪儿的原因。 diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index e97cd479..33d2feca 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -473,6 +473,7 @@ "gw.upstream.timeout": "上游响应超时。", "gw.upstream.unreachable": "无法连接上游 {url},请检查接口地址、网络和代理设置。", "gw.upstream.forward_failed": "转发失败:{detail}", + "gw.upstream.body_broken": "回答接收途中与上游的连接断开:{detail}", "gw.upstream.rate_limited": "上游「{upstream}」触发限流。", "gw.upstream.status": "上游「{upstream}」返回 {status}。", "gw.upstream.status_message": "上游「{upstream}」返回 {status}:{message}", diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index ca588c2a..039d95ac 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -15,7 +15,7 @@ import { flushSync } from "react-dom"; import { textOf, useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; import { cn } from "@/lib/utils"; -import { latency, money, ms, statusTone, tokens, traffic, when } from "@/format"; +import { compact, latency, money, ms, statusTone, tokens, traffic, when } from "@/format"; import { failureKind, notSentText, translatedText } from "@/labels"; import { ruleName } from "@/security/labels"; import { notSent } from "@/requestRouting"; @@ -39,6 +39,7 @@ import { sessionsText } from "./Sessions.i18n"; import { RowActions, TableMenus, type MenuTarget } from "./TableMenus"; import { abortRequest, abortable } from "./abort"; import { abortText } from "./abort.i18n"; +import { useLiveSpeed } from "./liveSpeed"; import { trafficText } from "./Traffic.i18n"; import { useViaConfig } from "./useModelVia"; import { @@ -446,7 +447,8 @@ function requestWidths(r: RequestRow, t: Text, today: number, via: ViaConfig | n upstreamMin: Math.max(nameMin, ...badges), // 在跑的:首 token 加上一个时钟(「0:42」) latency: r.state === "in_flight" ? (r.ttftMs != null ? String(r.ttftMs).length + 1 : 0) + 4 : textWidth(latency(r.ttftMs, r.durationMs)), - tokens: textWidth(tokens(promptTokens(r), r.outputTokens)), + // 在跑的:此刻的速度,至少七个字宽(见 `LiveSpeed`) + tokens: r.state === "in_flight" ? LIVE_SPEED_CH : textWidth(tokens(promptTokens(r), r.outputTokens)), cost: textWidth(money(r.costMicros, r.costEstimated)), }; } @@ -1343,6 +1345,7 @@ function TokensCell({ r }: { r: RequestRow }) { const t = useText(trafficText); const prompt = promptTokens(r); const text = tokens(prompt, r.outputTokens); + if (r.state === "in_flight") return ; if (prompt === undefined || r.outputTokens === undefined) return <>{text}; const n = (v: number | undefined) => (v ?? 0).toLocaleString(); return ( @@ -1367,6 +1370,40 @@ function TokensCell({ r }: { r: RequestRow }) { ); } +/** 在跑的那一格至少多宽(字数):「999 t/s」 */ +const LIVE_SPEED_CH = 7; + +/** + * 在跑的那一行 token 那一格:此刻的生成速度(`42 t/s`),每秒一换。 + * + * **放在 token 这一格,不另占一列、不进悬停。**在跑的请求还没有用量,这一格本来是一道 + * 破折号;速度说的正是输出此刻涨得多快,结局一到换成「输入→输出」。跑完的请求,速度 + * 在延迟那一格的悬停里(见 `LatencyCell`):表已经放满了,多一列,费用就出了视野。 + * + * 灰的,和在跑的延迟一样:是走着的数,不是结果。**至少占七个字宽**,表头里垫的那一格 + * (`Sizer`)也是:数字变长变短、第一个 token 到了,这一列都不跳。第一个 token 之前没有 + * 速度,照旧是破折号。数每秒从 `liveSpeed` 来,只重画这一小块,行不重画。 + */ +function LiveSpeed({ id, fallback }: { id: number; fallback: string }) { + const t = useText(trafficText); + const n = useLiveSpeed(id); + const box = "inline-block tw-num"; + const style = { minWidth: `${LIVE_SPEED_CH}ch` }; + if (n == null) + return ( + + {fallback} + + ); + return ( + + + {t.liveSpeed(compact(n))} + + + ); +} + /** * 费用。**估算值必须带记号**:猜出来的金额和账单上的数字在列表里长得一模一样, * 而它们不是一回事。没有金额写「—」,不写 $0。 diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 65bfa71a..5b73d47b 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -110,6 +110,9 @@ export const trafficText = messages( ...(speed != null ? [`生成速度 ${speed} token/秒`] : []), ], tokens: "token", + /** 在跑的那一行 token 那一格:此刻的生成速度,和菜单栏同一个写法 */ + liveSpeed: (n: string) => `${n} t/s`, + liveSpeedTip: (n: string) => `当前生成速度 ${n} token/秒`, cost: "费用", // 密钥那一格的悬停说明:密钥、推测的应用、来源,一项一行 @@ -263,6 +266,8 @@ export const trafficText = messages( ...(speed != null ? [`Speed ${speed} tokens/s`] : []), ], tokens: "Tokens", + liveSpeed: (n: string) => `${n} t/s`, + liveSpeedTip: (n: string) => `Current speed ${n} tokens/s`, cost: "Cost", keyTip: (key: string) => `Key ${key}`, diff --git a/src/traffic/TrafficPage.tsx b/src/traffic/TrafficPage.tsx index 9325affb..6abd6747 100644 --- a/src/traffic/TrafficPage.tsx +++ b/src/traffic/TrafficPage.tsx @@ -32,6 +32,7 @@ import { RequestTable, type RequestTableHandle } from "./RequestTable"; import { SessionSheet } from "./SessionPanel"; import { AbortSessionDialog, abortable } from "./abort"; import { TrafficSummary } from "./TrafficSummary"; +import { useLiveSpeeds } from "./liveSpeed"; import { trafficText } from "./Traffic.i18n"; import type { TrafficView } from "./view"; import { OpenRowHint } from "@/guide/PageHints"; @@ -110,6 +111,12 @@ export default function TrafficPage({ /** 库里那一页还没回来,表里一条都还没有:画骨架,不说「没有符合条件的」 */ const searching = search.mode !== "off" && (found === null || (found.busy && found.stopped === null)); const facet = useMemo(() => facets(allRows), [allRows]); + /* + **在跑的那几行此刻多快,问 core**(事件流不带这个数):这一页挂着、窗口没藏起来、 + 列表里有在跑的请求时每秒问一次,都跑完了就停(见 `liveSpeed.ts`) + */ + const anyRunning = useMemo(() => allRows.some((r) => r.state === "in_flight"), [allRows]); + useLiveSpeeds(anyRunning); /** 上一次归出来的组:没变的组沿用原来的对象,组头就不重画(见 `groupBySession`) */ const lastGroups = useRef([]); /* diff --git a/src/traffic/liveSpeed.test.ts b/src/traffic/liveSpeed.test.ts new file mode 100644 index 00000000..9ec783e0 --- /dev/null +++ b/src/traffic/liveSpeed.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LiveView, RunningView } from "@/types"; +import { POLL_MS, pollLive, speedsOf, type Speeds } from "./liveSpeed"; + +function running(id: number, tokens_per_sec: number | null): RunningView { + return { + id, + client: "default", + client_hint: null, + model: "gpt-5", + provider: "openai", + at_ms: 0, + elapsed_ms: 1_000, + session: null, + route: "default", + rule: "catch-all", + group: null, + upstream: null, + tokens_per_sec, + output_tokens: tokens_per_sec == null ? null : 300, + }; +} + +const view = (...running: RunningView[]): LiveView => ({ + running, + tokens_per_sec: running.length === 0 ? null : running.reduce((a, r) => a + (r.tokens_per_sec ?? 0), 0), +}); + +/** 在跑的请求此刻多快:core 给什么就是什么,还没开口的不写 */ +describe("在跑的请求此刻的速度", () => { + it("只收有速度的那几条:第一个 token 之前没有,开了口又停下的是 0", () => { + const s = speedsOf(view(running(1, 42), running(2, null), running(3, 0))); + expect([...s]).toEqual([ + [1, 42], + [3, 0], + ]); + }); +}); + +/** 一个假的页面:问 core 的那一问、窗口藏没藏,都由测试摆布 */ +function harness() { + let hidden = false; + let onVis: (() => void) | null = null; + const seen: Speeds[] = []; + let answer: () => Promise = async () => view(running(1, 42)); + const ask = vi.fn(() => answer()); + const stop = pollLive({ + ask, + hidden: () => hidden, + onVisibility: (f) => { + onVis = f; + return () => { + onVis = null; + }; + }, + emit: (s) => seen.push(s), + }); + return { + ask, + seen, + stop, + hide: (h: boolean) => { + hidden = h; + onVis?.(); + }, + answer: (f: () => Promise) => { + answer = f; + }, + }; +} + +describe("每秒问一次 core", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("挂上就问,之后每秒一次;停下就不再问,并清空", async () => { + const h = harness(); + await vi.advanceTimersByTimeAsync(0); + expect(h.ask).toHaveBeenCalledTimes(1); + expect(h.seen.at(-1)?.get(1)).toBe(42); + await vi.advanceTimersByTimeAsync(POLL_MS * 3); + expect(h.ask).toHaveBeenCalledTimes(4); + h.stop(); + expect(h.seen.at(-1)?.size).toBe(0); + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(h.ask).toHaveBeenCalledTimes(4); + }); + + it("窗口藏着时不问,露面时马上问一次", async () => { + const h = harness(); + await vi.advanceTimersByTimeAsync(0); + h.hide(true); + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(h.ask).toHaveBeenCalledTimes(1); + h.hide(false); + await vi.advanceTimersByTimeAsync(0); + expect(h.ask).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(h.ask).toHaveBeenCalledTimes(3); + h.stop(); + }); + + it("上一问没回来不发下一问;问不到就不写速度", async () => { + const h = harness(); + await vi.advanceTimersByTimeAsync(0); + let release: (v: LiveView) => void = () => {}; + h.answer(() => new Promise((r) => (release = r))); + await vi.advanceTimersByTimeAsync(POLL_MS * 10); + expect(h.ask).toHaveBeenCalledTimes(2); + // 卡着的时候窗口藏起又露面:也不另起一问 + h.hide(true); + h.hide(false); + expect(h.ask).toHaveBeenCalledTimes(2); + release(view(running(1, 7))); + await vi.advanceTimersByTimeAsync(0); + expect(h.seen.at(-1)?.get(1)).toBe(7); + h.answer(() => Promise.reject(new Error("core 不在"))); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(h.seen.at(-1)?.size).toBe(0); + h.stop(); + }); +}); diff --git a/src/traffic/liveSpeed.ts b/src/traffic/liveSpeed.ts new file mode 100644 index 00000000..4ea9c368 --- /dev/null +++ b/src/traffic/liveSpeed.ts @@ -0,0 +1,112 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { call } from "@/control"; +import type { LiveView } from "@/types"; + +/** + * 流量页里在跑的那几行此刻生成得多快。 + * + * **事件流不带这个数**:请求跑着的时候,此刻多快只有 core 知道(`/live`)。所以流量页在 + * 眼前、列表里有在跑的请求时,每秒问 core 一次;没有在跑的、换了页、窗口藏着时不问。 + * + * **数不经过行的渲染。**和已跑时长一样(`Elapsed`):每一秒每一条在跑的请求都换一个数, + * 放进行对象里的话,整行、整张表跟着重画。这里是一份小仓库,那一格里的小组件只订阅自己 + * 那一条。 + */ + +/** 隔多久问一次。和已跑时长的秒针同一个节拍 */ +export const POLL_MS = 1_000; + +/** 请求 id → 此刻每秒多少 token。**第一个 token 之前、非流式的不在里面** */ +export type Speeds = ReadonlyMap; + +const EMPTY: Speeds = new Map(); +let speeds: Speeds = EMPTY; +const listeners = new Set<() => void>(); + +function publish(next: Speeds): void { + if (next === speeds || (next.size === 0 && speeds.size === 0)) return; + speeds = next; + for (const f of listeners) f(); +} + +function subscribe(f: () => void): () => void { + listeners.add(f); + return () => listeners.delete(f); +} + +/** core 那一份里有速度的那几条 */ +export function speedsOf(v: LiveView): Map { + const out = new Map(); + for (const r of v.running) if (r.tokens_per_sec != null) out.set(r.id, r.tokens_per_sec); + return out; +} + +/** 这一条此刻的速度。还没开口(或者没在问)是 `undefined` */ +export function useLiveSpeed(id: number): number | undefined { + return useSyncExternalStore(subscribe, () => speeds.get(id)); +} + +/** + * 一圈一圈地问:问完一次隔 `POLL_MS` 再问,**上一问没回来不发下一问**(core 卡住时不越 + * 积越多)。藏着时停下,露面时马上问一次。问不到就清空:一个旧的数挂在那儿,比不写更 + * 误导。返回停下的函数,停下时也清空。 + */ +export function pollLive({ + ask, + hidden, + onVisibility, + emit, +}: { + ask: () => Promise; + hidden: () => boolean; + /** 挂上「露面 / 藏起」的回调,返回摘掉它的函数 */ + onVisibility: (f: () => void) => () => void; + emit: (s: Speeds) => void; +}): () => void { + let alive = true; + let asking = false; + let timer: ReturnType | null = null; + const next = async () => { + timer = null; + if (!alive || asking || hidden()) return; + asking = true; + try { + const v = await ask(); + if (alive) emit(speedsOf(v)); + } catch { + if (alive) emit(EMPTY); + } finally { + asking = false; + } + if (alive && !hidden()) timer = setTimeout(() => void next(), POLL_MS); + }; + const off = onVisibility(() => { + if (!hidden() && timer === null) void next(); + }); + void next(); + return () => { + alive = false; + off(); + if (timer !== null) clearTimeout(timer); + emit(EMPTY); + }; +} + +/** + * 流量页挂着、`running`(列表里有在跑的请求)时每秒问一次 core。页面卸下(换了页)、 + * 没有在跑的了,就停下并清空。 + */ +export function useLiveSpeeds(running: boolean): void { + useEffect(() => { + if (!running) return; + return pollLive({ + ask: () => call("Live", null), + hidden: () => document.visibilityState === "hidden", + onVisibility: (f) => { + document.addEventListener("visibilitychange", f); + return () => document.removeEventListener("visibilitychange", f); + }, + emit: publish, + }); + }, [running]); +}