diff --git a/Cargo.lock b/Cargo.lock index e5e28c7..f4f10eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -662,6 +683,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1098,6 +1128,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1217,6 +1253,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1266,6 +1308,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1302,6 +1364,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.8" @@ -1596,6 +1664,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1782,6 +1860,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2144,6 +2233,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.0", + "tiff", ] [[package]] @@ -2618,6 +2708,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "ntapi" version = "0.4.2" @@ -2984,6 +3083,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3073,6 +3182,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -3417,6 +3537,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4889,6 +5015,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.6" @@ -5203,6 +5344,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiktoken-rs" version = "0.9.1" @@ -5301,6 +5456,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", @@ -5638,6 +5794,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5964,6 +6131,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -6073,6 +6310,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -6690,6 +6933,24 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -6762,6 +7023,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6954,6 +7232,21 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/crates/token_proxy_core/src/proxy/dashboard.rs b/crates/token_proxy_core/src/proxy/dashboard.rs index daf53b3..0b293fb 100644 --- a/crates/token_proxy_core/src/proxy/dashboard.rs +++ b/crates/token_proxy_core/src/proxy/dashboard.rs @@ -22,6 +22,7 @@ pub struct DashboardSummary { pub output_tokens: u64, pub cached_tokens: u64, pub avg_latency_ms: u64, + pub median_latency_ms: u64, } #[derive(Debug, Clone, Serialize)] @@ -145,6 +146,9 @@ WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); latency_sum_ms / total_requests }; + // 中位数查询:使用 LIMIT/OFFSET 取中间值 + let median_latency_ms = query_median_latency(pool, from_ts_ms, to_ts_ms).await?; + Ok(DashboardSummary { total_requests, success_requests, @@ -154,9 +158,57 @@ WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2); output_tokens, cached_tokens, avg_latency_ms, + median_latency_ms, }) } +/// 计算中位数延迟(SQLite 无内置 MEDIAN,使用单条子查询避免并发写入时的 count/offset 错位) +async fn query_median_latency( + pool: &sqlx::SqlitePool, + from_ts_ms: Option, + to_ts_ms: Option, +) -> Result { + // 单条 SQL 完成中位数计算: + // - 使用 CTE 保证 count 和数据在同一快照内 + // - 奇数个取中间值,偶数个取中间两个值的整数除法平均 + let row = sqlx::query( + r#" +WITH filtered AS ( + SELECT latency_ms + FROM request_logs + WHERE (?1 IS NULL OR ts_ms >= ?1) AND (?2 IS NULL OR ts_ms <= ?2) +), +cnt AS ( + SELECT COUNT(*) AS n FROM filtered +), +ordered AS ( + SELECT latency_ms, ROW_NUMBER() OVER (ORDER BY latency_ms) AS rn + FROM filtered +) +SELECT COALESCE( + CASE + WHEN (SELECT n FROM cnt) = 0 THEN 0 + WHEN (SELECT n FROM cnt) % 2 = 1 THEN + (SELECT latency_ms FROM ordered WHERE rn = ((SELECT n FROM cnt) + 1) / 2) + ELSE + (SELECT (o1.latency_ms + o2.latency_ms) / 2 + FROM ordered o1, ordered o2 + WHERE o1.rn = (SELECT n FROM cnt) / 2 AND o2.rn = (SELECT n FROM cnt) / 2 + 1) + END, + 0 +) AS median_latency; +"#, + ) + .bind(from_ts_ms) + .bind(to_ts_ms) + .fetch_one(pool) + .await + .map_err(|err| format!("Failed to query median latency: {err}"))?; + + let median: i64 = row.try_get("median_latency").unwrap_or(0); + Ok(i64_to_u64(median)) +} + async fn query_providers( pool: &sqlx::SqlitePool, from_ts_ms: Option, diff --git a/crates/token_proxy_core/src/proxy/dashboard.test.rs b/crates/token_proxy_core/src/proxy/dashboard.test.rs index f0386d0..392b950 100644 --- a/crates/token_proxy_core/src/proxy/dashboard.test.rs +++ b/crates/token_proxy_core/src/proxy/dashboard.test.rs @@ -59,3 +59,153 @@ fn fill_series_buckets_returns_original_when_range_unknown_and_empty() { let filled = fill_series_buckets(Vec::new(), None, None, bucket_ms); assert!(filled.is_empty()); } + +// ============================================================================ +// query_median_latency 测试 +// ============================================================================ + +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; + +/// 创建内存数据库并初始化 schema +async fn setup_test_db() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("Failed to create in-memory database"); + + sqlx::query( + r#" + CREATE TABLE request_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts_ms INTEGER NOT NULL, + path TEXT NOT NULL, + provider TEXT NOT NULL, + upstream_id TEXT NOT NULL, + model TEXT, + mapped_model TEXT, + stream INTEGER NOT NULL, + status INTEGER NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + cached_tokens INTEGER, + usage_json TEXT, + upstream_request_id TEXT, + request_headers TEXT, + request_body TEXT, + response_error TEXT, + latency_ms INTEGER NOT NULL + ); + "#, + ) + .execute(&pool) + .await + .expect("Failed to create table"); + + pool +} + +/// 插入测试数据,只需指定 latency_ms +async fn insert_latency(pool: &SqlitePool, latency_ms: i64) { + sqlx::query( + r#" + INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) + VALUES (0, '/test', 'test', 'test', 0, 200, ?) + "#, + ) + .bind(latency_ms) + .execute(pool) + .await + .expect("Failed to insert test data"); +} + +#[tokio::test] +async fn median_latency_empty_table_returns_zero() { + let pool = setup_test_db().await; + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 0, "Empty table should return 0"); +} + +#[tokio::test] +async fn median_latency_single_value() { + let pool = setup_test_db().await; + insert_latency(&pool, 100).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 100, "Single value should be the median"); +} + +#[tokio::test] +async fn median_latency_odd_count() { + let pool = setup_test_db().await; + // 插入 3 个值: 10, 20, 30 -> 中位数应为 20 + insert_latency(&pool, 10).await; + insert_latency(&pool, 30).await; + insert_latency(&pool, 20).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 20, "Odd count median should be middle value"); +} + +#[tokio::test] +async fn median_latency_even_count() { + let pool = setup_test_db().await; + // 插入 4 个值: 10, 20, 30, 40 -> 中位数应为 (20+30)/2 = 25 + insert_latency(&pool, 10).await; + insert_latency(&pool, 40).await; + insert_latency(&pool, 20).await; + insert_latency(&pool, 30).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!( + result, 25, + "Even count median should be average of two middle values" + ); +} + +#[tokio::test] +async fn median_latency_even_count_rounds_down() { + let pool = setup_test_db().await; + // 插入 2 个值: 10, 21 -> 中位数应为 (10+21)/2 = 15 (整数除法向下取整) + insert_latency(&pool, 10).await; + insert_latency(&pool, 21).await; + + let result = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result, 15, "Median should use integer division"); +} + +#[tokio::test] +async fn median_latency_with_time_range_filter() { + let pool = setup_test_db().await; + + // 插入不同时间戳的数据 + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (100, '/test', 'test', 'test', 0, 200, 50)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (200, '/test', 'test', 'test', 0, 200, 100)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO request_logs (ts_ms, path, provider, upstream_id, stream, status, latency_ms) VALUES (300, '/test', 'test', 'test', 0, 200, 150)", + ) + .execute(&pool) + .await + .unwrap(); + + // 只查询 ts_ms 在 150-250 范围内的数据,应该只有 latency_ms=100 的记录 + let result = query_median_latency(&pool, Some(150), Some(250)).await.unwrap(); + assert_eq!(result, 100, "Should filter by time range"); + + // 查询所有数据,中位数应为 100 + let result_all = query_median_latency(&pool, None, None).await.unwrap(); + assert_eq!(result_all, 100, "All data median should be 100"); +} diff --git a/crates/token_proxy_core/src/proxy/logs.rs b/crates/token_proxy_core/src/proxy/logs.rs index 0e925de..3b20062 100644 --- a/crates/token_proxy_core/src/proxy/logs.rs +++ b/crates/token_proxy_core/src/proxy/logs.rs @@ -1,10 +1,28 @@ use serde::Serialize; use sqlx::Row; +/// 请求日志详情,包含表格展示的基础字段和详情面板的扩展字段 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RequestLogDetail { pub id: u64, + // 基础字段(与表格一致) + pub ts_ms: i64, + pub path: String, + pub provider: String, + pub upstream_id: String, + pub model: Option, + pub mapped_model: Option, + pub stream: bool, + pub status: i32, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cached_tokens: Option, + pub latency_ms: i64, + pub upstream_request_id: Option, + // 详情扩展字段 + pub usage_json: Option, pub request_headers: Option, pub request_body: Option, pub response_error: Option, @@ -18,6 +36,21 @@ pub async fn read_request_log_detail( r#" SELECT id, + ts_ms, + path, + provider, + upstream_id, + model, + mapped_model, + stream, + status, + input_tokens, + output_tokens, + total_tokens, + cached_tokens, + latency_ms, + upstream_request_id, + usage_json, request_headers, request_body, response_error @@ -35,15 +68,25 @@ LIMIT 1; return Err("Request log not found.".to_string()); }; - let id = row.try_get::("id").unwrap_or_default(); - let request_headers = row.try_get::, _>("request_headers").ok().flatten(); - let request_body = row.try_get::, _>("request_body").ok().flatten(); - let response_error = row.try_get::, _>("response_error").ok().flatten(); - Ok(RequestLogDetail { - id: id.max(0) as u64, - request_headers, - request_body, - response_error, + id: row.try_get::("id").unwrap_or_default().max(0) as u64, + ts_ms: row.try_get::("ts_ms").unwrap_or_default(), + path: row.try_get::("path").unwrap_or_default(), + provider: row.try_get::("provider").unwrap_or_default(), + upstream_id: row.try_get::("upstream_id").unwrap_or_default(), + model: row.try_get::, _>("model").ok().flatten(), + mapped_model: row.try_get::, _>("mapped_model").ok().flatten(), + stream: row.try_get::("stream").unwrap_or_default() != 0, + status: row.try_get::("status").unwrap_or_default(), + input_tokens: row.try_get::, _>("input_tokens").ok().flatten(), + output_tokens: row.try_get::, _>("output_tokens").ok().flatten(), + total_tokens: row.try_get::, _>("total_tokens").ok().flatten(), + cached_tokens: row.try_get::, _>("cached_tokens").ok().flatten(), + latency_ms: row.try_get::("latency_ms").unwrap_or_default(), + upstream_request_id: row.try_get::, _>("upstream_request_id").ok().flatten(), + usage_json: row.try_get::, _>("usage_json").ok().flatten(), + request_headers: row.try_get::, _>("request_headers").ok().flatten(), + request_body: row.try_get::, _>("request_body").ok().flatten(), + response_error: row.try_get::, _>("response_error").ok().flatten(), }) } diff --git a/crates/token_proxy_core/src/proxy/sqlite.rs b/crates/token_proxy_core/src/proxy/sqlite.rs index c3e3298..d2b583e 100644 --- a/crates/token_proxy_core/src/proxy/sqlite.rs +++ b/crates/token_proxy_core/src/proxy/sqlite.rs @@ -120,6 +120,14 @@ CREATE TABLE IF NOT EXISTS request_logs ( .await .map_err(|err| format!("Failed to create idx_request_logs_provider_ts_ms: {err}"))?; + // 复合索引:优化中位数延迟查询(按时间范围过滤后按延迟排序) + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_request_logs_ts_latency ON request_logs(ts_ms, latency_ms);", + ) + .execute(pool) + .await + .map_err(|err| format!("Failed to create idx_request_logs_ts_latency: {err}"))?; + Ok(()) } diff --git a/crates/token_proxy_core/src/proxy/upstream.rs b/crates/token_proxy_core/src/proxy/upstream.rs index 0aef95f..bae8b68 100644 --- a/crates/token_proxy_core/src/proxy/upstream.rs +++ b/crates/token_proxy_core/src/proxy/upstream.rs @@ -680,10 +680,11 @@ async fn resolve_antigravity_upstream( } fn build_mapped_meta(meta: &RequestMeta, upstream: &UpstreamRuntime, provider: &str) -> RequestMeta { + // 只有当实际发生映射时才设置 mapped_model,避免与 original_model 重复 let mapped_model = meta .original_model .as_deref() - .map(|original| upstream.map_model(original).unwrap_or_else(|| original.to_string())); + .and_then(|original| upstream.map_model(original)); let (mapped_model, reasoning_effort) = normalize_mapped_model_reasoning_suffix( mapped_model, meta.reasoning_effort.clone(), diff --git a/messages/en.json b/messages/en.json index 1a7b286..19bb323 100644 --- a/messages/en.json +++ b/messages/en.json @@ -312,7 +312,7 @@ "update_release_notes_empty": "No release notes.", "update_last_checked": "Last checked: {time}", "update_check": "Check for updates", - "update_download_install": "Download & install", + "update_download_install": "Download", "update_restart_now": "Restart now", "update_status_idle": "Not checked", "update_status_checking": "Checking", @@ -376,7 +376,7 @@ "dashboard_hint_error_rate": "Error rate {rate}", "dashboard_tokens_hint_no_cache": "Input {input} · Output {output}", "dashboard_tokens_hint_with_cache": "Input {input} · Output {output} · Cached {cached}", - "dashboard_latency_hint": "Time to first byte (avg.)", + "dashboard_latency_hint": "Median {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "Sorted by tokens (Top 10)", "dashboard_no_data": "No data", @@ -402,6 +402,16 @@ "logs_detail_desc": "Headers/body appear only when capture is enabled; error responses are always recorded for failed requests.", "logs_detail_loading": "Loading…", "logs_detail_error": "Load failed", + "logs_detail_copy": "Copy all", + "logs_detail_copied": "Copied", + "logs_detail_copy_failed": "Copy failed", + "logs_detail_basic_info": "Basic info", + "logs_detail_stream": "Stream", + "logs_detail_stream_yes": "Yes", + "logs_detail_stream_no": "No", + "logs_detail_upstream_request_id": "Upstream request ID", + "logs_detail_model_mapped": "Model (mapped)", + "logs_detail_usage_json": "Usage (JSON)", "logs_detail_headers": "Request headers", "logs_detail_body": "Request body", "logs_detail_response": "Error response", diff --git a/messages/zh.json b/messages/zh.json index 6a00370..bade589 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -313,7 +313,7 @@ "update_release_notes_empty": "暂无更新说明。", "update_last_checked": "上次检查时间:{time}", "update_check": "检查更新", - "update_download_install": "下载并安装", + "update_download_install": "下载", "update_restart_now": "立即重启", "update_status_idle": "待检查", "update_status_checking": "检查中", @@ -377,7 +377,7 @@ "dashboard_hint_error_rate": "错误率 {rate}", "dashboard_tokens_hint_no_cache": "输入 {input} · 输出 {output}", "dashboard_tokens_hint_with_cache": "输入 {input} · 输出 {output} · 缓存 {cached}", - "dashboard_latency_hint": "按请求均值", + "dashboard_latency_hint": "中位数 {median}", "dashboard_providers_title": "Providers", "dashboard_providers_desc": "按 Tokens 排序(Top 10)", "dashboard_no_data": "暂无数据", @@ -403,6 +403,16 @@ "logs_detail_desc": "请求头/体仅在开启记录后出现;错误响应会在失败请求中始终记录。", "logs_detail_loading": "加载中…", "logs_detail_error": "加载失败", + "logs_detail_copy": "复制全部", + "logs_detail_copied": "已复制", + "logs_detail_copy_failed": "复制失败", + "logs_detail_basic_info": "基础信息", + "logs_detail_stream": "流式", + "logs_detail_stream_yes": "是", + "logs_detail_stream_no": "否", + "logs_detail_upstream_request_id": "上游请求 ID", + "logs_detail_model_mapped": "模型 (映射)", + "logs_detail_usage_json": "用量详情 (JSON)", "logs_detail_headers": "请求头", "logs_detail_body": "请求体", "logs_detail_response": "错误响应", diff --git a/package.json b/package.json index 22e6111..e8e685d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b3e14..25ee312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@tauri-apps/plugin-autostart': specifier: ^2.5.1 version: 2.5.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-deep-link': specifier: ^2 version: 2.4.6 @@ -1531,6 +1534,9 @@ packages: '@tauri-apps/plugin-autostart@2.5.1': resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-deep-link@2.4.6': resolution: {integrity: sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA==} @@ -3983,6 +3989,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-deep-link@2.4.6': dependencies: '@tauri-apps/api': 2.9.1 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b97c9cf..1e3573e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ tauri = { version = "2.9.5", features = ["image-png", "tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-deep-link = "2" tauri-plugin-opener = "2.5.3" +tauri-plugin-clipboard-manager = "2" tokio = { version = "1.49.0", features = [ "fs", "io-util", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 689bb15..93bd6b1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "autostart:allow-enable", "autostart:allow-disable", "autostart:allow-is-enabled", + "clipboard-manager:allow-write-text", "deep-link:default", "dialog:default", "opener:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4af0dc8..a7d4b9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -534,7 +534,8 @@ pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_deep_link::init()); + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_clipboard_manager::init()); #[cfg(desktop)] { builder = builder.plugin( diff --git a/src-tauri/tauri.conf.dev.json b/src-tauri/tauri.conf.dev.json index aedcad8..9a5e0fc 100644 --- a/src-tauri/tauri.conf.dev.json +++ b/src-tauri/tauri.conf.dev.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Token Proxy (dev)", - "version": "0.1.28", + "version": "0.1.36", "identifier": "com.mxyhi.token-proxy.dev", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index 9b4c221..2781ace 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { formatInteger } from "@/features/dashboard/format"; +import { formatCompact, formatInteger } from "@/features/dashboard/format"; import type { DashboardSummary } from "@/features/dashboard/types"; import { m } from "@/paraglide/messages.js"; @@ -29,20 +29,16 @@ export function SectionCards({ summary }: SectionCardsProps) { const outputTokens = summary?.outputTokens ?? 0; const cachedTokens = summary?.cachedTokens ?? 0; const avgLatencyMs = summary?.avgLatencyMs ?? 0; + const medianLatencyMs = summary?.medianLatencyMs ?? 0; const successRate = totalRequests > 0 ? successRequests / totalRequests : 0; const errorRate = totalRequests > 0 ? errorRequests / totalRequests : 0; - const tokensHint = cachedTokens - ? m.dashboard_tokens_hint_with_cache({ - input: formatInteger(inputTokens), - cached: formatInteger(cachedTokens), - output: formatInteger(outputTokens), - }) - : m.dashboard_tokens_hint_no_cache({ - input: formatInteger(inputTokens), - output: formatInteger(outputTokens), - }); + // 缓存信息已在 Badge 中显示,footer 只展示输入/输出 + const tokensHint = m.dashboard_tokens_hint_no_cache({ + input: formatCompact(inputTokens), + output: formatCompact(outputTokens), + }); return (
@@ -50,7 +46,7 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_requests()} - {formatInteger(totalRequests)} + {formatCompact(totalRequests)} @@ -71,7 +67,7 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_errors()} - {formatInteger(errorRequests)} + {formatCompact(errorRequests)} {PERCENT_FORMAT.format(errorRate)} @@ -90,12 +86,12 @@ export function SectionCards({ summary }: SectionCardsProps) { {m.dashboard_stat_total_tokens()} - {formatInteger(totalTokens)} + {formatCompact(totalTokens)} {cachedTokens ? ( - {m.dashboard_cached({ count: formatInteger(cachedTokens) })} + {m.dashboard_cached({ count: formatCompact(cachedTokens) })} ) : null} @@ -114,7 +110,9 @@ export function SectionCards({ summary }: SectionCardsProps) {
- {m.dashboard_latency_hint()} + {m.dashboard_latency_hint({ + median: formatInteger(medianLatencyMs), + })}
diff --git a/src/features/dashboard/RecentRequestsTable.tsx b/src/features/dashboard/RecentRequestsTable.tsx index 963c35f..0eb6fe1 100644 --- a/src/features/dashboard/RecentRequestsTable.tsx +++ b/src/features/dashboard/RecentRequestsTable.tsx @@ -157,13 +157,12 @@ function tokensColumn(): ColumnDef { const totalText = row.original.totalTokens === null ? CELL_PLACEHOLDER : formatInteger(row.original.totalTokens); const cachedText = row.original.cachedTokens ? formatInteger(row.original.cachedTokens) : null; - const totalLabel = m.dashboard_chart_total_tokens(); - const cachedLabel = m.dashboard_chart_cached_tokens(); + // 过滤占位符,避免 tooltip 出现 "— / 123" 这种不清晰文案 const tooltipParts = [ - totalText === CELL_PLACEHOLDER ? null : `${totalLabel} ${totalText}`, - cachedText ? `${cachedLabel} ${cachedText}` : null, + row.original.totalTokens !== null ? totalText : null, + cachedText, ].filter((part): part is string => Boolean(part)); - const tooltipText = tooltipParts.length > 0 ? tooltipParts.join("\n") : CELL_PLACEHOLDER; + const tooltipText = tooltipParts.length > 0 ? tooltipParts.join(" / ") : CELL_PLACEHOLDER; return ( @@ -171,7 +170,7 @@ function tokensColumn(): ColumnDef { {totalText} {cachedText ? ( - {cachedLabel} {cachedText} + {cachedText} ) : null}
diff --git a/src/features/dashboard/format.test.ts b/src/features/dashboard/format.test.ts index 40b5443..150ceff 100644 --- a/src/features/dashboard/format.test.ts +++ b/src/features/dashboard/format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createDashboardTimeFormatter, + formatCompact, formatDashboardTimestamp, formatInteger, } from "@/features/dashboard/format"; @@ -17,5 +18,24 @@ describe("dashboard/format", () => { const formatter = createDashboardTimeFormatter("en-US"); expect(formatDashboardTimestamp(Number.NaN, formatter)).toBe("—"); }); + + it("formats compact numbers with K suffix for thousands", () => { + expect(formatCompact(0)).toBe("0"); + expect(formatCompact(999)).toBe("999"); + expect(formatCompact(1000)).toBe("1K"); + expect(formatCompact(1500)).toBe("1.5K"); + expect(formatCompact(985856)).toBe("985.9K"); + }); + + it("formats compact numbers with M suffix for millions", () => { + expect(formatCompact(1000000)).toBe("1M"); + expect(formatCompact(1500000)).toBe("1.5M"); + expect(formatCompact(12345678)).toBe("12.3M"); + }); + + it("formats compact numbers with B suffix for billions", () => { + expect(formatCompact(1000000000)).toBe("1B"); + expect(formatCompact(2500000000)).toBe("2.5B"); + }); }); diff --git a/src/features/dashboard/format.ts b/src/features/dashboard/format.ts index d865f39..4f8d151 100644 --- a/src/features/dashboard/format.ts +++ b/src/features/dashboard/format.ts @@ -25,3 +25,13 @@ export function formatDashboardTimestamp(tsMs: number, formatter: Intl.DateTimeF export function formatInteger(value: number) { return Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + +// 紧凑格式,用于空间有限的场景(如 985856 → 986K, 1500000 → 1.5M) +const COMPACT_FORMAT = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatCompact(value: number) { + return COMPACT_FORMAT.format(value); +} diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts index 4018083..696b7b5 100644 --- a/src/features/dashboard/types.ts +++ b/src/features/dashboard/types.ts @@ -12,6 +12,7 @@ export type DashboardSummary = { outputTokens: number; cachedTokens: number; avgLatencyMs: number; + medianLatencyMs: number; }; export type DashboardProviderStat = { diff --git a/src/features/logs/LogsPanel.tsx b/src/features/logs/LogsPanel.tsx index 248d8a3..8a2c625 100644 --- a/src/features/logs/LogsPanel.tsx +++ b/src/features/logs/LogsPanel.tsx @@ -1,9 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { AlertCircle } from "lucide-react"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { AlertCircle, Check, Copy } from "lucide-react"; +import { toast } from "sonner"; import { DataTable } from "@/components/data-table"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, @@ -17,12 +21,18 @@ import { RECENT_PAGE_SIZE, useDashboardSnapshot, } from "@/features/dashboard/snapshot"; +import { + createDashboardTimeFormatter, + formatDashboardTimestamp, + formatInteger, +} from "@/features/dashboard/format"; import { readRequestDetailCapture, readRequestLogDetail, setRequestDetailCapture, } from "@/features/logs/api"; import type { RequestLogDetail } from "@/features/logs/types"; +import { useI18n } from "@/lib/i18n"; import { parseError } from "@/lib/error"; import { m } from "@/paraglide/messages.js"; @@ -35,6 +45,83 @@ type RequestDetailCaptureEvent = { enabled: boolean; }; +type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; + +function statusToVariant(status: number): BadgeVariant { + if (status >= 200 && status < 300) return "default"; + if (status >= 400) return "destructive"; + if (status >= 300) return "secondary"; + return "outline"; +} + +type DetailFieldProps = { + label: string; + value: string | null | undefined; +}; + +function DetailField({ label, value }: DetailFieldProps) { + return ( +
+ {label} + + {value?.trim() || DETAIL_PLACEHOLDER} + +
+ ); +} + +type BasicInfoSectionProps = { + detail: RequestLogDetail; + formatter: Intl.DateTimeFormat; +}; + +// 基础信息区域:展示表格中的字段 +function BasicInfoSection({ detail, formatter }: BasicInfoSectionProps) { + const timestamp = formatDashboardTimestamp(detail.tsMs, formatter); + const streamText = detail.stream ? m.logs_detail_stream_yes() : m.logs_detail_stream_no(); + // 只有当 mappedModel 与 model 不同时才展示(相同说明没有实际映射) + const hasMappedModel = + detail.mappedModel?.trim() && + detail.model?.trim() && + detail.mappedModel.trim() !== detail.model.trim(); + + return ( +
+

{m.logs_detail_basic_info()}

+
+ + + + + {/* Model 展示逻辑与表格一致:主模型在上,映射模型在下 */} +
+ {m.dashboard_table_model()} +
+ + {detail.model?.trim() || DETAIL_PLACEHOLDER} + + {hasMappedModel ? ( + + {detail.mappedModel} + + ) : null} +
+
+
+ {m.dashboard_table_status()} + {detail.status} +
+ + + +
+
+ ); +} + type DetailSectionProps = { title: string; value: string | null; @@ -56,12 +143,61 @@ function DetailSection({ title, value }: DetailSectionProps) { ); } +// 将详情格式化为可复制的文本 +function formatDetailAsText(detail: RequestLogDetail, formatter: Intl.DateTimeFormat): string { + const lines: string[] = []; + const hasMappedModel = + detail.mappedModel?.trim() && + detail.model?.trim() && + detail.mappedModel.trim() !== detail.model.trim(); + + lines.push(`ID: ${detail.id}`); + lines.push(`${m.dashboard_table_time()}: ${formatDashboardTimestamp(detail.tsMs, formatter)}`); + lines.push(`${m.dashboard_table_path()}: ${detail.path}`); + lines.push(`${m.dashboard_table_provider()}: ${detail.upstreamId} · ${detail.provider}`); + lines.push(`${m.dashboard_table_model()}: ${detail.model?.trim() || DETAIL_PLACEHOLDER}`); + if (hasMappedModel) { + lines.push(`${m.logs_detail_model_mapped()}: ${detail.mappedModel}`); + } + lines.push(`${m.dashboard_table_status()}: ${detail.status}`); + lines.push(`${m.logs_detail_stream()}: ${detail.stream ? m.logs_detail_stream_yes() : m.logs_detail_stream_no()}`); + lines.push(`${m.dashboard_table_latency_ms()}: ${formatInteger(detail.latencyMs)}`); + lines.push(`${m.logs_detail_upstream_request_id()}: ${detail.upstreamRequestId?.trim() || DETAIL_PLACEHOLDER}`); + + if (detail.usageJson?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_usage_json()} ---`); + lines.push(detail.usageJson); + } + + if (detail.requestHeaders?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_headers()} ---`); + lines.push(detail.requestHeaders); + } + + if (detail.requestBody?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_body()} ---`); + lines.push(detail.requestBody); + } + + if (detail.responseError?.trim()) { + lines.push(""); + lines.push(`--- ${m.logs_detail_response()} ---`); + lines.push(detail.responseError); + } + + return lines.join("\n"); +} + type RequestDetailSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; status: DetailStatus; statusMessage: string; detail: RequestLogDetail | null; + formatter: Intl.DateTimeFormat; }; function RequestDetailSheet({ @@ -70,12 +206,57 @@ function RequestDetailSheet({ status, statusMessage, detail, + formatter, }: RequestDetailSheetProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + if (!detail) return; + const text = formatDetailAsText(detail, formatter); + try { + await writeText(text); + setCopied(true); + toast.success(m.logs_detail_copied()); + } catch { + toast.error(m.logs_detail_copy_failed()); + } + }, [detail, formatter]); + + // 重置复制状态当 sheet 关闭时,并清理 timeout + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + useEffect(() => { + if (!open) setCopied(false); + }, [open]); + return ( - + - {m.logs_detail_title()} +
+ {m.logs_detail_title()} + {status === "idle" && detail ? ( + + ) : null} +
{m.logs_detail_desc()}
@@ -92,19 +273,24 @@ function RequestDetailSheet({ ) : null} - {status === "idle" ? ( + {status === "idle" && detail ? (
+ +
) : null} @@ -128,6 +314,9 @@ export function LogsPanel() { onNextPage, } = useDashboardSnapshot(); + const { locale } = useI18n(); + const formatter = createDashboardTimeFormatter(locale); + const [captureEnabled, setCaptureEnabled] = useState(false); const [captureLoading, setCaptureLoading] = useState(false); const [detailOpen, setDetailOpen] = useState(false); @@ -203,19 +392,7 @@ export function LogsPanel() { setDetailOpen(true); }, []); - const loadDetail = useCallback(async (itemId: number) => { - setDetailStatus("loading"); - setDetailMessage(""); - try { - const data = await readRequestLogDetail(itemId); - setDetail(data); - setDetailStatus("idle"); - } catch (error) { - setDetailMessage(parseError(error)); - setDetailStatus("error"); - } - }, []); - + // 加载详情,使用 active 标志防止过期响应覆盖当前选择 useEffect(() => { if (!detailOpen) { setDetail(null); @@ -223,10 +400,35 @@ export function LogsPanel() { setDetailMessage(""); return; } - if (selectedId !== null) { - void loadDetail(selectedId); + if (selectedId === null) { + return; } - }, [detailOpen, selectedId, loadDetail]); + + let active = true; + + const load = async () => { + setDetailStatus("loading"); + setDetailMessage(""); + try { + const data = await readRequestLogDetail(selectedId); + if (active) { + setDetail(data); + setDetailStatus("idle"); + } + } catch (error) { + if (active) { + setDetailMessage(parseError(error)); + setDetailStatus("error"); + } + } + }; + + void load(); + + return () => { + active = false; + }; + }, [detailOpen, selectedId]); return (
@@ -271,6 +473,7 @@ export function LogsPanel() { status={detailStatus} statusMessage={detailMessage} detail={detail} + formatter={formatter} />
); diff --git a/src/features/logs/types.ts b/src/features/logs/types.ts index 96e02dc..a0fc610 100644 --- a/src/features/logs/types.ts +++ b/src/features/logs/types.ts @@ -1,5 +1,23 @@ +/// 请求日志详情,包含表格展示的基础字段和详情面板的扩展字段 export type RequestLogDetail = { id: number; + // 基础字段(与表格一致) + tsMs: number; + path: string; + provider: string; + upstreamId: string; + model: string | null; + mappedModel: string | null; + stream: boolean; + status: number; + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + cachedTokens: number | null; + latencyMs: number; + upstreamRequestId: string | null; + // 详情扩展字段 + usageJson: string | null; requestHeaders: string | null; requestBody: string | null; responseError: string | null; diff --git a/src/test/setup.ts b/src/test/setup.ts index 50f133f..7e62a8e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -60,6 +60,11 @@ vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn<() => Promise>().mockResolvedValue(null), })); +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText: vi.fn<(text: string) => Promise>().mockResolvedValue(undefined), + readText: vi.fn<() => Promise>().mockResolvedValue(""), +})); + // ------------------------------ // jsdom polyfills // ------------------------------