From cd7599f0c7c738eae17c6fec9f84d59c1869f8ba Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:47:35 +0800 Subject: [PATCH 1/2] Use core v0.57.0: search the whole history, upstream check-up, ID and card redaction Traffic search reaches the whole request history. The page loads the latest 2,000 requests and filters them on the spot as before; when the list is full, the same filter goes to `POST /history/search` for the older records, and the results join the table. A text-search toggle inside the search box also searches the content of requests and answers (core reads the payloads in budgeted pages): each match shows its excerpt under the row, and the footer says how far back the search got, offers to search further, and from which day payloads are kept. Error messages are matched as displayed: in the Chinese UI the codes whose translation may contain the query go along, and rows matched only by record are filtered again against the text on screen. Upstreams get a Check-up tab (`GET /upstreams/health`) over 24 hours, 7 days, 30 days or a custom range: requests and failure rate, whether the model named in the answers matches the one sent, reported input against other upstreams serving the same model, the share of input read from the prompt cache, and median time to first token and speed. Figures come with sample sizes; a deviation is shown in amber only with enough samples on both sides, and for input only on the upstream further from the local estimate, so the honest side of a two-upstream comparison is not flagged. The two new redaction rules (Chinese resident ID number, bank card number) get their names, matcher descriptions and the "Personal information" group on the Security page. Both endpoints are added to the webview whitelist, and the screenshot pipeline's mock core answers them. Upgrading to core 0.57.0 clears the request history (store schema 23) and needs protocol 31 on a remote core. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/mock/core.ts | 18 +- scripts/shots/mock/traffic.ts | 139 +++++++++- src-tauri/Cargo.lock | 44 +-- src-tauri/Cargo.toml | 12 +- src-tauri/src/call.rs | 5 +- src/control.ts | 2 + src/generated/tw-api.ts | 399 ++++++++++++++++++++++++++- src/i18n/core.i18n.ts | 23 +- src/i18n/template.ts | 25 ++ src/labels.i18n.ts | 4 + src/requestTable.ts | 76 ++--- src/security/labels.i18n.tsx | 18 ++ src/security/labels.tsx | 4 + src/traffic/RequestTable.tsx | 67 ++++- src/traffic/Traffic.i18n.tsx | 43 +++ src/traffic/TrafficPage.tsx | 169 ++++++++++-- src/traffic/historySearch.test.ts | 197 +++++++++++++ src/traffic/historySearch.ts | 254 +++++++++++++++++ src/upstreams/Checkup.i18n.ts | 123 +++++++++ src/upstreams/CheckupTab.tsx | 361 ++++++++++++++++++++++++ src/upstreams/UpstreamsPage.i18n.tsx | 4 +- src/upstreams/UpstreamsPage.tsx | 18 +- src/upstreams/checkup.test.ts | 167 +++++++++++ src/upstreams/checkup.ts | 108 ++++++++ src/useRequests.ts | 67 +++-- 25 files changed, 2213 insertions(+), 134 deletions(-) create mode 100644 src/traffic/historySearch.test.ts create mode 100644 src/traffic/historySearch.ts create mode 100644 src/upstreams/Checkup.i18n.ts create mode 100644 src/upstreams/CheckupTab.tsx create mode 100644 src/upstreams/checkup.test.ts create mode 100644 src/upstreams/checkup.ts diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index c27b348e..5152d483 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -18,7 +18,20 @@ import { security, keys, } from "./config"; -import { HISTORY, IN_FLIGHT, SEC_EVENTS, bodies, lastSeen, routeStats, sessionView, sessions, turns, unpricedModels } from "./traffic"; +import { + HISTORY, + IN_FLIGHT, + SEC_EVENTS, + bodies, + historySearch, + lastSeen, + routeStats, + sessionView, + sessions, + turns, + unpricedModels, + upstreamHealth, +} from "./traffic"; import { DAY, HOUR, NOW, clone, msg } from "./util"; type Handler = (req: Endpoints[N]["req"], params: string[]) => Endpoints[N]["res"]; @@ -80,6 +93,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { .reverse(), ); }, + HistorySearch: (req) => historySearch(req), RequestDetail: (_req, [id]) => { const h = HISTORY.find((x) => x.id === Number(id)) ?? notFound(`Request #${id}`); return { row: clone(h), ...bodies(h), in_flight: false }; @@ -120,6 +134,8 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { DeleteGroup: refuse, KnownModels: () => knownModels(), // 没给时间窗就是今天:本地零点到现在(`Window` 的默认) + // 没给时间窗就是最近 7 天(`UpstreamHealth` 的默认) + UpstreamHealth: (req) => upstreamHealth(req.from_ms ?? NOW - 7 * DAY, req.to_ms ?? Date.now()), RouteStats: (req) => routeStats(req.from_ms ?? new Date(NOW).setHours(0, 0, 0, 0), req.to_ms ?? Date.now()), Pricing: () => pricing(unpricedModels()), diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index fdf93a32..0f81f7f5 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -10,11 +10,15 @@ // 更早的由带种子的随机数铺开,每次拍出来都一样。 import type { AttemptView, + CacheTally, + ContentHit, CostBucket, CostBucketGroup, CostGroup, Dialect, HistoryRow, + HistorySearchPage, + HistorySearchQuery, InFlightRequest, LatencyView, TokenRateView, @@ -27,10 +31,12 @@ import type { SessionView, Summary, TurnView, + UpstreamCheckup, + UpstreamHealth, } from "@/types"; import type { Dashboard } from "@/types"; import { AS_OF, N, priceFor, priceSource } from "./config"; -import { DAY, HOUR, MIN, NOW, SEC, L, msg, rng } from "./util"; +import { DAY, HOUR, MIN, NOW, SEC, L, clone, msg, rng } from "./util"; import OV_EN from "../core/en/overview.json"; // ───────────────────────────────────────── 谁在发请求 @@ -885,3 +891,134 @@ export function bodies(h: HistoryRow) { const text = JSON.stringify({ model: h.model, stream: true, messages: [{ role: "user", content: L("修复登录页的表单校验", "Fix the form validation on the sign-in page") }] }, null, 2); return { request_body: { text, original_len: text.length, truncated: false }, response_body: null }; } + +// ───────────────────────────────────────── 在整份记录里搜索、上游体检(core 0.57) + +/** + * `POST /history/search`,照 tw-store 的 `search::run`:新的在前;按记录对(路径、密钥、应用、来源、 + * 上游、模型、失败原因和它的码、本地应答那句话),按内容找时再看请求和回答的正文。截图的记录 + * 不多,不设读正文的量,一页凑满或者找完就停 + */ +export function historySearch(req: HistorySearchQuery): HistorySearchPage { + const q = (req.q ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(req.limit ?? 100, 1), 500); + const b = req.before ?? null; + const codes = req.error_codes ?? []; + const reads = req.content === true && q !== ""; + const byRecord = (h: HistoryRow) => + q === "" || + (h.local && req.local_matches === true) || + (h.error != null && codes.includes(h.error.code)) || + [h.path, h.client, h.client_hint ?? "", h.peer ?? "", h.local ? "" : h.provider, h.model, h.error?.text ?? ""].some((s) => + s.toLowerCase().includes(q), + ); + const rows = HISTORY.filter( + (h) => + (req.from_ms == null || h.at_ms >= req.from_ms) && + (req.to_ms == null || h.at_ms <= req.to_ms) && + (!b || h.at_ms < b.at_ms || (h.at_ms === b.at_ms && h.id < b.id)) && + (!req.failed || h.error != null) && + (!req.unpriced || (unpriced(h) && !h.cancelled && h.input_tokens != null)) && + (!req.client || h.client === req.client) && + (!req.provider || (!h.local && h.provider === req.provider)) && + (!req.model || h.model === req.model), + ).sort((x, y) => y.at_ms - x.at_ms || y.id - x.id); + const out: HistoryRow[] = []; + const hits: ContentHit[] = []; + for (const h of rows) { + if (byRecord(h)) out.push(h); + else if (reads && !h.local) { + // 截图的记录只带请求的正文(`bodies`),回答那一边没有 + const text = bodies(h).request_body.text; + const at = text.toLowerCase().indexOf(q); + if (at >= 0) { + const from = Math.max(0, at - 40); + const to = Math.min(text.length, at + q.length + 40); + const flat = (s: string) => s.replace(/\s+/g, " "); + hits.push({ + id: h.id, + side: "request", + before: (from > 0 ? "…" : "") + flat(text.slice(from, at)), + matched: text.slice(at, at + q.length), + after: flat(text.slice(at + q.length, to)) + (to < text.length ? "…" : ""), + }); + out.push(h); + } + } + if (out.length >= limit) { + const last = out[out.length - 1]!; + return { rows: clone(out), hits, next: { at_ms: last.at_ms, id: last.id }, bodies_since_ms: reads ? oldestDay() : null, stopped: "full" }; + } + } + return { rows: clone(out), hits, next: null, bodies_since_ms: reads ? oldestDay() : null, stopped: "end" }; +} + +/** 正文最早留到哪一天的零点(UTC):截图的记录都带着正文,就是最老那条请求的那一天 */ +function oldestDay(): number | null { + if (HISTORY.length === 0) return null; + const d = new Date(Math.min(...HISTORY.map((h) => h.at_ms))); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** + * `GET /upstreams/health`,照 tw-store 的 `upstream_health`:本地应答的、一家都没去的不算;请求数 + * 不含取消的。截图的记录里回答写的模型名都和发出去的一样,报的输入就按本地估算记(比值 1), + * 缓存按同一会话里的轮次数 + */ +export function upstreamHealth(from: number, to: number): UpstreamHealth { + const rows = rowsBetween(from, to).filter((h) => !h.local && h.provider); + const by = new Map(); + for (const h of rows) by.set(h.provider, [...(by.get(h.provider) ?? []), h]); + const ok = (h: HistoryRow) => !h.error && !h.cancelled && h.input_tokens != null; + const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)]!; + const tally = (xs: HistoryRow[]): CacheTally => { + const t: CacheTally = { turns: 0, zero_read_turns: 0, input_tokens: 0, cache_read_tokens: 0 }; + for (const h of xs.filter((x) => ok(x) && x.session)) { + t.turns += 1; + t.input_tokens += (h.input_tokens ?? 0) + (h.cache_read_tokens ?? 0) + (h.cache_write_tokens ?? 0); + t.cache_read_tokens += h.cache_read_tokens ?? 0; + if (!h.cache_read_tokens) t.zero_read_turns += 1; + } + return t; + }; + const upstreams: UpstreamCheckup[] = [...by.entries()].map(([p, xs]) => { + const done = xs.filter((h) => !h.cancelled); + const models = [...new Set(xs.filter(ok).map((h) => h.model))]; + const servedBy = (m: string, who: (o: string) => boolean) => + [...by.entries()].filter(([o]) => who(o)).flatMap(([, ys]) => ys.filter((h) => ok(h) && h.model === m)); + const ttft = xs.flatMap((h) => (h.ttft_ms != null ? [h.ttft_ms] : [])); + const tps = xs.flatMap((h) => (h.tokens_per_sec != null ? [h.tokens_per_sec] : [])); + const samples = xs.filter(ok).length; + return { + upstream: p, + requests: done.length, + failed: done.filter((h) => h.error).length, + cancelled: xs.length - done.length, + models: { named: done.filter((h) => h.model).length, differed: 0, examples: [] }, + input: { + all: samples ? { median: 1, samples } : null, + by_model: models.map((m) => { + const others = servedBy(m, (o) => o !== p); + const k = new Set(others.map((h) => h.provider)).size; + return { model: m, here: { median: 1, samples: servedBy(m, (o) => o === p).length }, others: k ? { median: 1, samples: others.length } : null, other_upstreams: k }; + }), + }, + cache: { + all: tally(xs), + by_model: models.flatMap((m) => { + const here = tally(servedBy(m, (o) => o === p)); + if (here.turns === 0) return []; + const others = servedBy(m, (o) => o !== p); + const k = new Set(others.map((h) => h.provider)).size; + return [{ model: m, here, others: k ? tally(others) : null, other_upstreams: k }]; + }), + }, + ttft_ms: ttft.length ? { p50: median(ttft), samples: ttft.length } : null, + tokens_per_sec: tps.length ? { p50: median(tps), samples: tps.length } : null, + }; + }); + upstreams.sort((a, b) => b.requests - a.requests); + const oldest = HISTORY.length ? Math.min(...HISTORY.map((h) => h.at_ms)) : null; + const covered = oldest == null ? null : Math.max(oldest, from); + return { from_ms: from, to_ms: to, covered_since_ms: covered != null && covered < to ? covered : null, upstreams }; +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0a400f6c..8a148116 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.59.0", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -974,7 +974,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1175,7 +1175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2651,7 +2651,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3591,7 +3591,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3647,7 +3647,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4733,7 +4733,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5279,8 +5279,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "serde", "serde_json", @@ -5290,8 +5290,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "serde", "serde_json", @@ -5299,8 +5299,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "base64 0.22.1", "regex", @@ -5313,8 +5313,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "serde", "serde_json", @@ -5340,8 +5340,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "serde", "ts-rs", @@ -5349,8 +5349,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "notify", "thiserror 2.0.21", @@ -5359,8 +5359,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.56.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.56.0#f09d0026740a7698241ab4d1537694cda8a3dee6" +version = "0.57.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.57.0#6dbc5bb1be9f24cc60ce2736781deb76f7b86529" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5857,7 +5857,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8d35a815..8f5a7c40 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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.56.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.56.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.56.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.56.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.56.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.56.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.57.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 6548da1c..2de91495 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -63,8 +63,9 @@ webview_endpoints![ ConfigAt, ConfigRollback, SaveListen, - // 记录 + // 记录。搜索翻的是整个库,界面自己发起、自己翻页 History, + HistorySearch, RequestDetail, Sessions, SessionDetail, @@ -103,6 +104,8 @@ webview_endpoints![ KnownModels, // 各条路由、各条规则命中了多少:路由图按它给线加权、标出从没命中过的规则 RouteStats, + // 上游体检:各家上游的事实和样本数,界面按它写成自己的话 + UpstreamHealth, // 价目表 Pricing, RefreshPricing, diff --git a/src/control.ts b/src/control.ts index 3f5ab86d..32df9451 100644 --- a/src/control.ts +++ b/src/control.ts @@ -32,6 +32,7 @@ export const WEBVIEW_ENDPOINTS = [ "ConfigRollback", "SaveListen", "History", + "HistorySearch", "RequestDetail", "Sessions", "SessionDetail", @@ -65,6 +66,7 @@ export const WEBVIEW_ENDPOINTS = [ "DeleteGroup", "KnownModels", "RouteStats", + "UpstreamHealth", "Pricing", "RefreshPricing", "SetPricingAutoUpdate", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 5667defb..b5e63162 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 = 30; +export const CONTROL_API_VERSION = 31; /** * 一个账号上游登的是哪个账号。 @@ -166,6 +166,79 @@ dim: CostDim, }; */ export type BucketQuery = { from_ms?: number | null, to_ms?: number | null, bucket_ms?: number | null, }; +/** + * 一个模型本该读得到缓存的轮次:这一家的,和别家的参照。 + */ +export type CacheForModel = { +/** + * 归一之后的模型名 + */ +model: string, here: CacheTally, +/** + * 别的上游服务这个模型时,几家合在一起。只有这一家服务它时是空 + */ +others: CacheTally | null, +/** + * `others` 是几家的 + */ +other_upstreams: number, }; + +/** + * 本该读得到缓存的轮次里,读到了多少。 + * + * 「本该读得到」:同一次会话里的后一轮,和它的前一轮发给同一家上游、同一个模型,离 + * 前一轮开始不到 5 分钟(几家缓存最短的存活时间);两轮的输入都至少 4096 token(几家 + * 最小可缓存长度里最大的那个,再短上游本来就不缓存)。只数成功跑完、报了用量的轮次。 + * + * **读不读得到也看客户端**:Anthropic 和 Bedrock 上的模型要客户端标出缓存断点,不标 + * 就一直是 0。所以 `by_model` 里带着别的上游的参照 —— 同样的客户端发给别家读得到, + * 才是一条线索。 + */ +export type CacheReads = { +/** + * 所有模型合在一起 + */ +all: CacheTally, +/** + * 按模型(发出去的那个,归一之后)分,轮次多的在前 + */ +by_model: Array, }; + +/** + * 一组本该读得到缓存的轮次。读到的比例是 `cache_read_tokens / input_tokens`。 + */ +export type CacheTally = { turns: number, +/** + * 其中一个缓存 token 都没读到的 + */ +zero_read_turns: number, +/** + * 这些轮次的输入一共多少:没命中缓存的输入 + 缓存读 + 缓存写 + */ +input_tokens: number, +/** + * 其中缓存读的 + */ +cache_read_tokens: number, }; + +/** + * 一家卡组织认哪些卡号:以哪几段开头、一共几位。 + */ +export type CardNetwork = { +/** + * 英文名(`UnionPay`、`Visa` …)。界面按它查自己的名称表 + */ +name: string, prefixes: Array, +/** + * 一共几位 + */ +lengths: Array, }; + +/** + * 卡号开头的一段,含两头、两头位数相同:`51`–`55`。只有一个数时两头相同。 + */ +export type CardPrefix = { from: number, to: number, }; + /** * 一次进行中的登录。 */ @@ -429,11 +502,38 @@ export type ConfigWrite = { base_version: string, text: string, }; export type ConfigWritten = { version: string, }; +/** + * 一条请求的正文里对上的那一段:命中的前后各四十来个字,空白并成一个空格, + * **已脱敏**(和详情抽屉里的正文同一套打码)。 + */ +export type ContentHit = { +/** + * 哪条请求 + */ +id: number, side: ContentSide, +/** + * 命中之前的那一段。前面还有字时以 `…` 开头 + */ +before: string, +/** + * 命中的那一段,原文的大小写 + */ +matched: string, +/** + * 命中之后的那一段。后面还有字时以 `…` 结尾 + */ +after: string, }; + /** * 内容规则怎么认。 */ export type ContentMatch = "contains" | "regex"; +/** + * 正文里的哪一边。 + */ +export type ContentSide = "request" | "answer"; + /** * 一个要转换格式的候选上游。 */ @@ -719,6 +819,16 @@ billing: Billing, * 少了这个字段,落库那一步就只能记一笔没有模型的账 */ model: string, method: string, path: string, +/** + * 本地估的输入 token 数,和数 token 的本地估算、路由条件里的 `input_tokens` + * 是同一个数。上游体检拿它和上游报的输入比(`GET /upstreams/health`)。 + * + * **说的是发给上游的那一份**:规则只改模型名、输出上限和推理开关,格式转换 + * 换的是同一段内容的写法,都不动它;脱敏换掉的几个值差出的几个 token,在这个 + * 估算本身的误差之内。解不开的请求没有:不是生成回答的(数 token、嵌入), + * 和对话存在上游服务端的(`previous_response_id` 这些) + */ +input_estimate?: number | null, /** * 请求带着 DeepSeek Harness 的会话日志(`dsh_session_log`):这是它序列化之后 * 的字节数。没带是 None。 @@ -753,7 +863,15 @@ usage?: UsageView | null, * 是推理就扣掉,没报就没有这个数 —— 那些推理 token 是在分母之外生成的,算进 * 来会把速度顶高好几倍。没有第一个 token 的(非流式)、没有输出的,也没有 */ -tokens_per_sec?: number | null, } | { "kind": "request_failed", id: number, +tokens_per_sec?: number | null, +/** + * 上游在回答里写的模型名:Anthropic 和 Chat 的 `model`、Responses 的 + * `response.model`、Gemini 的 `modelVersion`。**原样,不归一。** + * + * 回答里没写的没有:Bedrock 的 Converse 不写,WebSocket 那条路不看。和 + * `model` 不是一回事 —— 那是客户端要的,这是上游说它用的 + */ +answered_model?: string | null, } | { "kind": "request_failed", id: number, /** * 模型名。理由见 `RequestFinished::model` */ @@ -774,7 +892,11 @@ duration_ms?: number | null, * 全额,输出算到断开为止。不带上它,那笔钱就不在账上。和取消一样, * 按它算出来的钱只能是估算。响应头之前就失败的没有用量。 */ -usage?: UsageView | null, } | { "kind": "request_cancelled", id: number, +usage?: UsageView | null, +/** + * 失败之前上游在回答里写的模型名(见 `RequestFinished::answered_model`) + */ +answered_model?: string | null, } | { "kind": "request_cancelled", id: number, /** * 模型名。理由见 `RequestFinished::model` */ @@ -790,7 +912,11 @@ bytes: number, duration_ms: number, /** * **没嗅到就是 None,不是零** —— 客户端可能在第一帧之前就走了 */ -usage?: UsageView | null, } | { "kind": "request_routed", id: number, +usage?: UsageView | null, +/** + * 断开之前上游在回答里写的模型名(见 `RequestFinished::answered_model`) + */ +answered_model?: string | null, } | { "kind": "request_routed", id: number, /** * 走的哪条路由,同 `RequestStarted::route` */ @@ -1173,6 +1299,14 @@ revealed: string, }; */ export type HiddenKind = "zero_width" | "tag" | "bidi" | "homoglyph" | "private_use"; +/** + * 记录里的一个位置:一条请求开始的时刻和它的请求号。 + * + * **两样一起才定得住。**同一毫秒里开始的请求不止一条(并发的子代理、同时发出的 + * 几个工具调用),只按时刻翻页,落在页缝上的那几条不是漏掉就是重复。 + */ +export type HistoryCursor = { at_ms: number, id: number, }; + /** * 一条历史请求。 */ @@ -1267,6 +1401,101 @@ session_log_bytes?: number | null, */ security?: Array, }; +/** + * 搜索的一页(`POST /history/search`),新的在前。 + * + * **按正文找的那一页不一定满。**正文要一份一份从盘上读,一次只读一定的量(见 + * `stopped`),读完就把找到的先交回来;`next` 说从哪儿接着找,`next.at_ms` 就是 + * 这一次往回找到了哪一刻。 + */ +export type HistorySearchPage = { +/** + * 对上的请求,和 `GET /history` 同一个样子 + */ +rows: Array, +/** + * 按正文对上的那些各自对上了哪一段,一条请求一项。按记录就对上的不在这里 + * (没有去读它的正文) + */ +hits: Array, +/** + * 下一页从哪儿接着找:交回去当 [`HistorySearchQuery::before`]。`None` 是整段都 + * 找完了 + */ +next: HistoryCursor | null, +/** + * 按正文找时:正文最早留到哪一刻(盘上最老的那一天的零点,UTC)。比它早的 + * 正文已经清掉了,那些请求只按记录对。没按正文找、或者盘上一份正文都没有时 + * 是 `None` + */ +bodies_since_ms: number | null, +/** + * 这一页为什么停在这里 + */ +stopped: SearchStop, }; + +/** + * 在整份请求记录里找(`POST /history/search`)。 + * + * **筛法和流量页的筛选框一模一样**(桌面端 `requestTable.ts` 的 `filterRows`),只是 + * 搬到了库里:那边只筛得到读进来的最近两千条,记录却留着三个月。几个条件之间是「且」。 + * + * 走请求体而不是查询串:错误码是一张清单,查询串放不下清单(core 读查询串用的 + * `serde_urlencoded` 不认序列)。 + */ +export type HistorySearchQuery = { +/** + * 自由文本。**去掉首尾空白、不分大小写**,按子串对:路径、密钥名、按请求头 + * 认出的应用、来源地址、上游、模型、失败原因,任何一样里有就算。空的是不按 + * 文本筛 + */ +q?: string, +/** + * 只要失败的 + */ +failed?: boolean, +/** + * 只要无法计价的:跑完了、报了用量,却没算出金额。还在跑的、失败的、取消的、 + * 没报用量的不算 —— 它们没有金额是另一回事,和概览上「无法计价」那个数是同一批 + */ +unpriced?: boolean, +/** + * 只要这把密钥的,整个名字相等。空串和不给一样 + */ +client?: string | null, +/** + * 只要这个上游服务的,整个名字相等。本地应答的没有上游,按上游筛时一条都不留 + */ +provider?: string | null, +/** + * 只要这个模型的,**整个名字相等** —— 点 gpt-5.5 不该带出 gpt-5.5-codex + */ +model?: string | null, +/** + * 失败原因按码也算对上:界面上那句话是按码翻的,库里只有英文原句。界面把 + * **译文里含着 `q` 的那些码**交过来;英文原句照样按 `q` 对 + */ +error_codes?: Array, +/** + * 本地应答的那几行「上游」一格写的是界面自己的一句说明(按当时的语言)。那句话 + * 里含着 `q` 时界面交 `true`,本地应答的就都算对上 + */ +local_matches?: boolean, +/** + * 也按正文找:请求里新的那一轮(最后一条用户消息,含工具结果)和回答(文字,和工具 + * 调用的名字与参数:模型跑的命令、写的代码都在这里;推理不算)。 + * 只有正文还在盘上的那些能这样找,更早的照样按上面那几样对。没有 `q` 时不起作用 + */ +content?: boolean, from_ms?: number | null, to_ms?: number | null, +/** + * 从这一条往前接着找:上一页的 [`HistorySearchPage::next`] + */ +before?: HistoryCursor | null, +/** + * 一页最多几条。缺省 100、最多 500 + */ +limit?: number | null, }; + /** * 此刻还在跑的请求(`GET /in-flight`):每一个到目前为止的事件,和 core 此刻的 * 时钟。 @@ -1303,6 +1532,45 @@ export type InFlightRequest = { id: number, */ events: Array, }; +/** + * 一个模型的输入之比:这一家的,和别家的参照。 + */ +export type InputForModel = { +/** + * 归一之后的模型名 + */ +model: string, here: RatioView, +/** + * 同一段时间里别的上游服务这个模型时的比值:几家的样本合在一起取中位数。 + * 只有这一家服务它时是空 + */ +others: RatioView | null, +/** + * `others` 是几家的 + */ +other_upstreams: number, }; + +/** + * 上游报的输入 token 和本地估算之比。 + * + * 输入按上游计费的口径算:没命中缓存的输入 + 缓存读 + 缓存写(几种格式落库时已经换算 + * 成这三项互不重叠)。估算见 `RequestStarted::input_estimate`。样本是成功跑完、报了 + * 用量、估算至少 1000 token 的请求:太短的请求里,消息格式自己的那几个 token 就能让 + * 比值差出一截。 + * + * **估算本身只准到两三成**,单看一家的比值说明不了什么;有意义的是同一个模型在不同 + * 上游之间比,所以 `by_model` 里每个模型都带着别的上游服务它时的比值。 + */ +export type InputVsEstimate = { +/** + * 所有模型合在一起。没有样本时是空 + */ +all: RatioView | null, +/** + * 按模型(发出去的那个,归一之后)分,样本多的在前 + */ +by_model: Array, }; + /** * 一把密钥上用户能改的东西。**密钥的值不在里面** —— 它由 core 生成, * 要换就走更换(新值写进已接管的客户端,由桌面端做)。 @@ -1507,7 +1775,12 @@ export type LoginStatus = "pending" | "done" | "failed" | "expired" | "cancelled /** * 一条内置规则按什么认。**给界面说明用**,界面按类型写成自己的话。 */ -export type Matcher = { "kind": "prefix", prefix: string, min_tail: number, } | { "kind": "openai-legacy", min_len: number, } | { "kind": "pem" } | { "kind": "jwt" } | { "kind": "conn-string" } | { "kind": "private-ip" } | { "kind": "domain-suffix", suffixes: Array, } | { "kind": "regex", pattern: string, } | { "kind": "contains", text: string, } | { "kind": "codepoints", ranges: Array, }; +export type Matcher = { "kind": "prefix", prefix: string, min_tail: number, } | { "kind": "openai-legacy", min_len: number, } | { "kind": "pem" } | { "kind": "jwt" } | { "kind": "conn-string" } | { "kind": "private-ip" } | { "kind": "domain-suffix", suffixes: Array, } | { "kind": "cn-resident-id", born_since: number, } | { "kind": "bank-card", networks: Array, } | { "kind": "regex", pattern: string, } | { "kind": "contains", text: string, } | { "kind": "codepoints", ranges: Array, }; + +/** + * 中位数和样本数。 + */ +export type MedianView = { p50: number, samples: number, }; /** * 一个没对上的条件。 @@ -1535,6 +1808,32 @@ export type ModeSave = { */ mode: GuardMode, base_version?: string | null, }; +/** + * 回答里写的模型名,和发出去的对不对得上。 + * + * 发出去的是规则改写之后的那个(尝试链最后一跳的 `model`,没改写的就是客户端要的)。 + * 比之前两边都**归一**:大小写、日期和快照后缀(`-20250929`、`@20250929`、`-latest`)、 + * `models/` 和厂商前缀(`anthropic/`)、Bedrock 的写法(`us.anthropic.…-v1:0`)、 + * `4.5` 和 `4-5` 这类不是换了模型的差别都抹掉。归一宁可宽:错判一次「对不上」就是 + * 冤枉一家诚实的上游。 + * + * **模型名是上游自己写的**:写得一致不能证明真是那个模型,写得不一致才是一条线索。 + */ +export type ModelConsistency = { +/** + * 回答里写了模型名的请求数(取消的不算) + */ +named: number, +/** + * 其中和发出去的对不上的 + */ +differed: number, +/** + * 对不上的里最常见的几对,最多三对,多的在前。归一之后是同一对的合在一起算, + * 名字取其中最常见的那种写法 + */ +examples: Array, }; + /** * 模型清单的结果。**空列表不足以表达**:「上游没这个接口」「上游给了 * 但我们没认出格式」「真的一个都没有」是三件事,塌成空列表之后 UI 只能 @@ -1548,6 +1847,19 @@ export type ModelList = { "kind": "listed", models: Array, } | { "kind": */ export type ModelListStatus = "pending" | "listed" | "no_list" | "failed"; +/** + * 发出去的和回答里写的一对模型名。 + */ +export type ModelPair = { +/** + * 发给上游的,原样 + */ +sent: string, +/** + * 上游在回答里写的,原样 + */ +answered: string, count: number, }; + /** * 清单里的一个模型。 */ @@ -2301,6 +2613,15 @@ resets_at_ms?: number | null, status?: string | null, */ credits?: QuotaCredits | null, }; +/** + * 比值的中位数和样本数。 + */ +export type RatioView = { +/** + * 中位数,精确到千分之一。1.0 是一样多 + */ +median: number, samples: number, }; + /** * 配置里引用了某个上游的一处。 */ @@ -2778,6 +3099,11 @@ route: string, rule: string, group: string | null, */ upstream: string | null, }; +/** + * 一页搜索停在哪儿的原因。 + */ +export type SearchStop = "full" | "budget" | "end"; + /** * 出站脱敏找到的一项:哪条规则、哪个值(已打码)、在这个请求里出现了几次。 */ @@ -2787,19 +3113,21 @@ export type SecretItem = { */ rule: string, custom: boolean, /** - * 类别:`api-keys` / `private-keys` / `jwt` / `conn-strings` / `internal` / `custom` + * 类别:`api-keys` / `private-keys` / `jwt` / `conn-strings` / `personal` / + * `internal` / `custom` */ kind: SecretKind, /** * **已打码。**报出来的东西一律打码 —— 「发现了 sk-ant-xxx」这句话本身 - * 就是一次泄漏。内网地址和内部域名例外,它们不是凭据 + * 就是一次泄漏。内网地址和内部域名例外,它们不是凭据;身份证号和卡号只留 + * 最后四位(`…1234`) */ masked: string, count: number, }; /** * 出站脱敏找到的东西属于哪一类。 */ -export type SecretKind = "api-keys" | "private-keys" | "jwt" | "conn-strings" | "internal" | "custom"; +export type SecretKind = "api-keys" | "private-keys" | "jwt" | "conn-strings" | "personal" | "internal" | "custom"; /** * 各项防护在一段时间里各留下了几条记录。 @@ -3358,6 +3686,57 @@ billing: Billing, }; */ export type UnpricedModel = { provider: string, model: string, requests: number, }; +/** + * 一家上游的体检结果。 + */ +export type UpstreamCheckup = { upstream: string, +/** + * 交给它的请求数。**客户端取消的不算**(见 `cancelled`) + */ +requests: number, +/** + * 其中失败的,口径同 [`Summary::failed`] + */ +failed: number, +/** + * 客户端没等到结束就走了的。**不是失败,也不在 `requests` 里** + */ +cancelled: number, models: ModelConsistency, input: InputVsEstimate, cache: CacheReads, +/** + * 第一个 token 的中位数,毫秒。样本和 `/latency/provider` 的一样:流式的才有 + */ +ttft_ms: MedianView | null, +/** + * 生成速度的中位数,token/秒。样本和 `/token-rate/provider` 的一样 + */ +tokens_per_sec: MedianView | null, }; + +/** + * 上游体检(`GET /upstreams/health`):一段时间里每家上游的几项事实,各带样本数。 + * + * **只摆事实和参照,不下结论。**中转站服务的是不是它说的那个模型、报的用量有没有 + * 虚高、缓存是不是真的在起作用 —— core 给出偏差、样本数和别家的参照,怎么措辞是 + * 界面的事。样本少的时候数字说明不了什么,所以每一项都带着样本数。 + * + * 只数这段时间里落了库的请求。**本地应答不算**(没经过上游),规则拒绝了、一家上游 + * 都没去的也不算(不归哪一家)。请求归给最终服务它的那一家:故障转移之前失败的那 + * 一跳不在这里。 + */ +export type UpstreamHealth = { +/** + * 实际数的那段时间,Unix 毫秒,含起点不含终点。不给参数时是最近 7 天 + */ +from_ms: number, to_ms: number, +/** + * 这段时间里记录从哪一刻起是全的,同 [`RouteStats::covered_since_ms`]:库刚重建、 + * 记录留的天数比窗口短时比 `from_ms` 晚,这段时间里没有一刻有记录时是空 + */ +covered_since_ms: number | null, +/** + * 每家上游一条,请求多的在前。这段时间里一条请求都没有的上游不在这里 + */ +upstreams: Array, }; + /** * 一次调用的用量。 */ @@ -3466,10 +3845,12 @@ export const ENDPOINTS = { CostBy: { method: "GET", path: "/summary/by", params: [], format: "json" }, RouteStats: { method: "GET", path: "/summary/routes", params: [], format: "json" }, History: { method: "GET", path: "/history", params: [], format: "json" }, + HistorySearch: { method: "POST", path: "/history/search", params: [], format: "json" }, Latency: { method: "GET", path: "/latency", params: [], format: "json" }, LatencyByProvider: { method: "GET", path: "/latency/provider", params: [], format: "json" }, TokenRate: { method: "GET", path: "/token-rate", params: [], format: "json" }, TokenRateByProvider: { method: "GET", path: "/token-rate/provider", params: [], format: "json" }, + UpstreamHealth: { method: "GET", path: "/upstreams/health", params: [], format: "json" }, RequestDetail: { method: "GET", path: "/request/{id}", params: ["id"], format: "json" }, Fixture: { method: "GET", path: "/request/{id}/fixture", params: ["id"], format: "text" }, Sessions: { method: "GET", path: "/sessions", params: [], format: "json" }, @@ -3563,10 +3944,12 @@ export type Endpoints = { CostBy: { req: GroupQuery; res: Array }; RouteStats: { req: Window; res: RouteStats }; History: { req: ListQuery; res: Array }; + HistorySearch: { req: HistorySearchQuery; res: HistorySearchPage }; Latency: { req: Window; res: Array }; LatencyByProvider: { req: Window; res: Array }; TokenRate: { req: Window; res: Array }; TokenRateByProvider: { req: Window; res: Array }; + UpstreamHealth: { req: Window; res: UpstreamHealth }; RequestDetail: { req: null; res: RequestDetail }; Fixture: { req: null; res: string }; Sessions: { req: ListQuery; res: Array }; diff --git a/src/i18n/core.i18n.ts b/src/i18n/core.i18n.ts index e3db79fe..259bab81 100644 --- a/src/i18n/core.i18n.ts +++ b/src/i18n/core.i18n.ts @@ -1,7 +1,7 @@ import type { Msg } from "@/types"; import { getLang } from "./index"; import CORE_ZH from "./core.zh.json"; -import { render, type Args, type Tables } from "./template"; +import { render, wordsOf, type Args, type Tables } from "./template"; /** * core 发来的那些码,中文怎么说。 @@ -108,6 +108,27 @@ export function zhOf(m: Msg): string | undefined { return zh === undefined ? undefined : leads.join("") + zh; } +/** + * 中文界面上,**译文里可能含着 `q` 的那些码**。在整份记录里搜索时交给 core + * (`HistorySearchQuery::error_codes`):库里存的是英文原句,界面上看到的是按码翻出来 + * 的中文 —— 照着看到的那半句话搜,英文原句里没有它。 + * + * 只看句子里写死的字和查表的词(`wordsOf`)。原样填进去的参数是上游名、模型名这类, + * 英文原句里本来就有,core 按 `q` 对原句就对上了。**宁可多给**:多给的那几条,界面拿到 + * 之后照着屏幕上的那句话再筛一遍(见 `traffic/historySearch.ts`)。 + * + * 英文界面上看到的就是原句,交空的。`q` 已经去掉首尾空白、转了小写。 + */ +export function codesMatching(q: string): string[] { + if (!q || getLang() === "en") return []; + const out: string[] = []; + for (const [code, say] of Object.entries(MESSAGES)) { + if (code.startsWith("//")) continue; + if (wordsOf(say, CORE_TABLES).some((w) => w.toLowerCase().includes(q))) out.push(code); + } + return out; +} + /** * invoke 抛出来的东西变成一句话。 * diff --git a/src/i18n/template.ts b/src/i18n/template.ts index 3c906352..c9d7f6f9 100644 --- a/src/i18n/template.ts +++ b/src/i18n/template.ts @@ -180,3 +180,28 @@ function run(nodes: Node[], args: Args, tables: Tables): string | undefined { export function render(src: string, args: Args, tables: Tables): string | undefined { return run(compile(src), args, tables); } + +/** + * 一段写法填出来的句子里,**可能出现的那些字**:写死的字,和查表可能查到的每一个词 + * (两个分支、退路都算)。原样填进去的参数不在里面。 + * + * 搜索用(见 `core.i18n.ts` 的 `codesMatching`)。一段一项,搜索词不会跨段对上 —— + * 跨过一个参数对上的,那个参数填进去之后多半就对不上了。 + */ +export function wordsOf(src: string, tables: Tables): string[] { + const out: string[] = []; + const walk = (nodes: Node[]) => { + for (const n of nodes) { + if (n.t === "text") out.push(n.s); + else if (n.t === "if") { + walk(n.then); + walk(n.else); + } else { + if (n.table !== undefined) out.push(...Object.values(tables[n.table] ?? {})); + if (n.fallback) walk(n.fallback); + } + } + }; + walk(compile(src)); + return out; +} diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index a2d3fe27..f74517bc 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -121,6 +121,8 @@ export const labelsText = messages( "private-key": "私钥", jwt: "JWT", "conn-string-password": "连接串口令", + "cn-resident-id": "居民身份证号", + "bank-card": "银行卡号", "internal-ip": "内网地址", "internal-domain": "内部域名", }, @@ -247,6 +249,8 @@ export const labelsText = messages( "private-key": "Private key", jwt: "JWT", "conn-string-password": "Connection string password", + "cn-resident-id": "Chinese resident ID number", + "bank-card": "Bank card number", "internal-ip": "Internal IP address", "internal-domain": "Internal domain", }, diff --git a/src/requestTable.ts b/src/requestTable.ts index 89ce13c2..94aec379 100644 --- a/src/requestTable.ts +++ b/src/requestTable.ts @@ -37,6 +37,12 @@ export interface Filter { * 「533 条无法计价」告诉你有一批请求没进账,却不告诉你是哪些模型。 */ unpricedOnly: boolean; + /** + * 搜索词也在请求和回答的内容里找(只有 core 做得到:内容不在读进来的行上,见 + * `traffic/historySearch.ts`)。**它不是一个筛子**:没有搜索词时什么也不做, + * `hasAnyFilter` 不数它。 + */ + content: boolean; } export const EMPTY_FILTER: Filter = { @@ -46,6 +52,7 @@ export const EMPTY_FILTER: Filter = { provider: "", model: "", unpricedOnly: false, + content: false, }; export function hasAnyFilter(f: Filter): boolean { @@ -129,37 +136,44 @@ export function sortRows( export function filterRows(rows: RequestRow[], f: Filter): RequestRow[] { const q = f.q.trim().toLowerCase(); - return rows.filter((r) => { - if (f.failedOnly && r.state !== "failed") return false; - /* - **没算出金额,不等于金额是零。**跑完了、也报了用量,却没有单价 - 的那些才是「无法计价」;还在跑的、失败的、上游回了 4xx 或者压根 - 没报用量的,没有金额是另一回事(core 的 `unpriced_requests` 也不 - 数它们)。混进来会让「哪些模型该补价」这个问题答不出来,从概览 - 点进来的条数也和那个数对不上。 - */ - if ( - f.unpricedOnly && - (r.costMicros != null || r.state !== "done" || (r.inputTokens == null && r.outputTokens == null)) - ) - return false; - if (f.client && r.client !== f.client) return false; - if (f.provider && r.provider !== f.provider) return false; - if (f.model && r.model !== f.model) return false; - if (!q) return true; - // 路径、密钥、应用、来源、上游、模型、错误信息都算 —— 排查时记得住的 - // 往往是错误里的那半句话,而不是哪个字段装着它。上游那一格写的是什么就按什么搜 - //(本地应答的那句说明,按此刻的语言) - return ( - r.path.toLowerCase().includes(q) || - r.client.toLowerCase().includes(q) || - (r.hint ?? "").toLowerCase().includes(q) || - (r.peer ?? "").includes(q) || - upstreamText(r).toLowerCase().includes(q) || - (r.model ?? "").toLowerCase().includes(q) || - coreText(r.error).toLowerCase().includes(q) - ); - }); + return rows.filter((r) => matches(r, f, q)); +} + +/** + * 一行对不对得上筛选条件。`q` 是整理过的搜索词(去掉首尾空白、转了小写),一批行 + * 一起筛时只整理一次。**core 在库里照抄的是这一段**(`tw-store` 的 `search`),改了 + * 这里那边也要跟着改。 + */ +export function matches(r: RequestRow, f: Filter, q = f.q.trim().toLowerCase()): boolean { + if (f.failedOnly && r.state !== "failed") return false; + /* + **没算出金额,不等于金额是零。**跑完了、也报了用量,却没有单价 + 的那些才是「无法计价」;还在跑的、失败的、上游回了 4xx 或者压根 + 没报用量的,没有金额是另一回事(core 的 `unpriced_requests` 也不 + 数它们)。混进来会让「哪些模型该补价」这个问题答不出来,从概览 + 点进来的条数也和那个数对不上。 + */ + if ( + f.unpricedOnly && + (r.costMicros != null || r.state !== "done" || (r.inputTokens == null && r.outputTokens == null)) + ) + return false; + if (f.client && r.client !== f.client) return false; + if (f.provider && r.provider !== f.provider) return false; + if (f.model && r.model !== f.model) return false; + if (!q) return true; + // 路径、密钥、应用、来源、上游、模型、错误信息都算 —— 排查时记得住的 + // 往往是错误里的那半句话,而不是哪个字段装着它。上游那一格写的是什么就按什么搜 + //(本地应答的那句说明,按此刻的语言) + return ( + r.path.toLowerCase().includes(q) || + r.client.toLowerCase().includes(q) || + (r.hint ?? "").toLowerCase().includes(q) || + (r.peer ?? "").includes(q) || + upstreamText(r).toLowerCase().includes(q) || + (r.model ?? "").toLowerCase().includes(q) || + coreText(r.error).toLowerCase().includes(q) + ); } /** 出现过的客户端/上游,用来填过滤下拉。**按出现过的,不是按配置里的** */ diff --git a/src/security/labels.i18n.tsx b/src/security/labels.i18n.tsx index f509a3c6..1ec17210 100644 --- a/src/security/labels.i18n.tsx +++ b/src/security/labels.i18n.tsx @@ -6,6 +6,9 @@ import CORE_ZH from "@/i18n/core.zh.json"; /** 句子里要按代码样式画的那一段。怎么画由组件决定,这里只管是哪几个字 */ type Code = (text: string) => ReactNode; +/** 卡组织在中文里的名字。只有银联有通行的中文名,别家照写英文 */ +const CARD_NETWORK_ZH: Record = { UnionPay: "银联" }; + const or = (xs: ReactNode[], sep: ReactNode, last: ReactNode) => xs.flatMap((x, i) => (i === 0 ? [x] : [i === xs.length - 1 ? last : sep, x])); @@ -49,6 +52,7 @@ export const securityLabelsText = messages( "private-keys": "私钥", jwt: "JWT", "conn-strings": "连接串", + personal: "个人信息", internal: "内网地址", command: "内置", injection: "指令覆盖", @@ -109,6 +113,13 @@ export const securityLabelsText = messages( domainSuffix: (code: Code, suffixes: string[]) => ( <>以 {or(suffixes.map(code), "、", "、")} 结尾的域名 ), + cnResidentId: (bornSince: number) => ( + <>18 位居民身份证号:地区码、{bornSince} 年以来的出生日期和校验码都对得上 + ), + /** 卡组织的名字按英文名查,查不到的照写 */ + bankCard: (networks: string[]) => ( + <>{or(networks.map((n) => CARD_NETWORK_ZH[n] ?? n), "、", "、")} 的卡号:号段、位数对得上并通过 Luhn 校验;公开的测试卡号除外 + ), regex: (code: Code, pattern: string) => <>正则 {code(pattern)}, contains: (code: Code, text: string) => <>包含 {code(text)},不区分大小写, codepoints: (code: Code, ranges: string[]) => <>码位 {or(ranges.map(code), "、", "、")}, @@ -150,6 +161,7 @@ export const securityLabelsText = messages( "private-keys": "Private keys", jwt: "JWTs", "conn-strings": "Connection strings", + personal: "Personal information", internal: "Internal addresses", command: "Built-in", injection: "Instruction override", @@ -244,6 +256,12 @@ export const securityLabelsText = messages( domainSuffix: (code: Code, suffixes: string[]) => ( <>Domains ending in {or(suffixes.map(code), ", ", " or ")} ), + cnResidentId: (bornSince: number) => ( + <>An 18-character resident ID number whose region code, birth date since {bornSince} and check character all check out + ), + bankCard: (networks: string[]) => ( + <>A {or(networks, ", ", " or ")} card number whose prefix and length match and that passes the Luhn check; public test card numbers excepted + ), regex: (code: Code, pattern: string) => <>Regex {code(pattern)}, contains: (code: Code, text: string) => <>Contains {code(text)}, ignoring case, codepoints: (code: Code, ranges: string[]) => <>Code points {or(ranges.map(code), ", ", " and ")}, diff --git a/src/security/labels.tsx b/src/security/labels.tsx index 6e9d86ce..affc96ce 100644 --- a/src/security/labels.tsx +++ b/src/security/labels.tsx @@ -145,6 +145,10 @@ export function MatcherText({ m }: { m: Matcher }) { return t.privateIp(code); case "domain-suffix": return t.domainSuffix(code, m.suffixes); + case "cn-resident-id": + return t.cnResidentId(m.born_since); + case "bank-card": + return t.bankCard(m.networks.map((n) => n.name)); case "regex": return t.regex(code, m.pattern); case "contains": diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index a1d02d2a..ecd759fd 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -7,7 +7,7 @@ import { notSentText, translatedText } from "@/labels"; import { ruleName } from "@/security/labels"; import { notSent } from "@/requestRouting"; import { promptTokens, upstreamText, type Filter, type SortDir, type SortKey } from "@/requestTable"; -import type { RequestRow } from "@/types"; +import type { ContentHit, RequestRow } from "@/types"; import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { UpstreamLogo } from "@/ui/logos"; @@ -38,6 +38,7 @@ const FIRST_PAINT = 60; */ export function RequestTable({ rows, + hits, groups, openGroups, selectedSession, @@ -57,6 +58,8 @@ export function RequestTable({ onFilter, }: { rows: RequestRow[]; + /** 按内容搜到的那些各自对上了哪一段:那一行下面跟一行片段 */ + hits?: ReadonlyMap; /** 给了就是归组形态。不给就是平表 */ groups?: Group[]; /** 展开着的那几个会话 */ @@ -162,6 +165,7 @@ export function RequestTable({ */ ; groups?: Group[]; openGroups: ReadonlySet; showClient: boolean; @@ -303,6 +309,7 @@ function RequestRows({ , StatusTone> = { */ const Row = memo(function Row({ r, + hit, sameClient, sameModel, sameProvider, @@ -386,6 +394,8 @@ const Row = memo(function Row({ onFilter, }: { r: RequestRow; + /** 按内容搜到的:对上的那一段,在这一行下面另起一行 */ + hit?: ContentHit; sameClient: boolean; sameModel: boolean; sameProvider: boolean; @@ -443,21 +453,24 @@ const Row = memo(function Row({ ), }, ]; - return ( + // 点一行和键盘选中一行是同一件事:之后的方向键从这一行接着走 + const activate = () => { + onCursor({ kind: "request", id: r.id }); + onOpen(r.id); + }; + const row = ( { - // 点一行和键盘选中一行是同一件事:之后的方向键从这一行接着走 - onCursor({ kind: "request", id: r.id }); - onOpen(r.id); - }} + onClick={activate} className={cn( ROW, fresh ? "motion-row-in" : inGroup === "open" && "motion-fade", inGroup && "[&>td:first-child]:pl-7", + // 下面跟着片段那一行:分隔线画在片段下面,悬停到片段上时这一行也亮 + hit && "border-b-0 [&:has(+tr:hover)]:bg-foreground/[0.035]", )} > {/* @@ -524,8 +537,48 @@ const Row = memo(function Row({ ); + if (!hit) return row; + /* + **片段另起一行,跨过状态以外的各列。**放进哪一格里都太窄(模型一格最宽 13rem), + 而这一段话正是按内容搜索的人要看的。它不是表里的一行请求:没有 `data-row`,键盘 + 不停在它上面;点它和点上面那一行一样。 + */ + return ( + <> + {row} + + + + + + + + ); }); +/** + * 按内容对上的那一段:前面一个词说是请求里的还是回答里的,对上的字标出来。 + * + * **不撑宽表格。**片段有一百来个字,表格按内容定列宽,放开写会把各列都撑开;里面 + * 一层零宽、`min-w-full` 的块,宽度只跟着格子走,放不下的截掉(core 给的片段前后 + * 已经带着「…」)。 + */ +function HitText({ hit }: { hit: ContentHit }) { + const t = useText(trafficText); + return ( +
+ {hit.side === "request" ? t.hitRequest : t.hitAnswer} + {hit.before} + {hit.matched} + {hit.after} +
+ ); +} + /** * 在跑的那一条的延迟:第一个 token 到了就先写它,后面是已经跑了多久,每秒走一格 * (`1180→0:42`)。灰的,而且写成时钟的样子 —— 别让它看起来像已经结束的总耗时; diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index de664694..e79447c4 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -45,6 +45,27 @@ export const trafficText = messages( allModels: "全部模型", shownOf: (shown: number, total: number) => `${shown} / ${total} 条`, clear: "清空", + /** 搜索框里的图标开关:搜索词也在请求和回答的内容里找。读屏念的是前一句,悬停是后一句 */ + searchContent: "搜索内容", + searchContentTip: "同时搜索请求与回答的内容", + + // 在整份记录里搜索(读进来的只有最近两千条) + /** 搜索伸到了库里,表里一共找到几条 */ + found: (n: number) => `找到 ${n} 条`, + searchingOlder: "正在搜索更早的记录…", + searchingContent: "正在搜索内容…", + /** 搜到了哪一刻:今天的给到秒,更早的带日期 */ + searchedTo: (at: string) => `已搜索至 ${at}`, + searchedAll: "已搜索全部记录", + searchMore: "继续搜索", + searchFailed: (why: string) => `搜索失败:${why}`, + /** 按内容找时,报文最早留到哪一天(UTC 的日子:报文按天存放) */ + bodiesSince: (ms: number) => + `报文保留自 ${new Date(ms).toLocaleDateString("zh-CN", { month: "long", day: "numeric", timeZone: "UTC" })},更早的请求不按内容匹配。`, + noBodies: "没有保留的报文,请求不按内容匹配。", + /** 片段前面那个词:对上的是请求里的,还是回答里的 */ + hitRequest: "请求", + hitAnswer: "回答", // 还没有上游 noUpstreams: "尚未配置上游", @@ -54,6 +75,9 @@ export const trafficText = messages( // 空状态 noMatchTitle: "没有符合条件的请求", noMatch: (n: number) => `共 ${n} 条记录,当前筛选条件下没有匹配项。`, + /** 全被筛掉了,库里也找过了。`at`:搜到了哪一刻;`null` 是找完了 */ + noMatchSearched: (at: string | null) => + at === null ? "已搜索全部记录,当前筛选条件下没有匹配项。" : `已搜索至 ${at},当前筛选条件下没有匹配项。`, clearFilters: "清除筛选条件", emptyTitle: "暂无请求记录", pointClients: (endpoint: ReactNode) => <>将客户端的端点设为 {endpoint},并使用以 tw- 开头的客户端密钥。, @@ -156,6 +180,21 @@ export const trafficText = messages( allModels: "All models", shownOf: (shown: number, total: number) => `${shown} / ${count(total, "request", "requests")}`, clear: "Clear", + searchContent: "Search content", + searchContentTip: "Also search the content of requests and answers", + + found: (n: number) => `${n} found`, + searchingOlder: "Searching earlier records…", + searchingContent: "Searching content…", + searchedTo: (at: string) => `Searched back to ${at}`, + searchedAll: "All records searched", + searchMore: "Search further", + searchFailed: (why: string) => `Search failed: ${why}`, + bodiesSince: (ms: number) => + `Payloads are kept from ${new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}; earlier requests are not matched by content.`, + noBodies: "No payloads are kept; requests are not matched by content.", + hitRequest: "Request", + hitAnswer: "Answer", noUpstreams: "No upstreams configured yet", listening: (addr: ReactNode) => ( @@ -168,6 +207,10 @@ export const trafficText = messages( n === 1 ? "1 request recorded; it does not match the current filters." : `${n} requests recorded; none match the current filters.`, + noMatchSearched: (at: string | null) => + at === null + ? "All records searched; none match the current filters." + : `Searched back to ${at}; nothing matches the current filters.`, clearFilters: "Clear filters", emptyTitle: "No requests yet", pointClients: (endpoint: ReactNode) => ( diff --git a/src/traffic/TrafficPage.tsx b/src/traffic/TrafficPage.tsx index 1517f4ed..5384715c 100644 --- a/src/traffic/TrafficPage.tsx +++ b/src/traffic/TrafficPage.tsx @@ -1,27 +1,33 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { FunnelXIcon } from "lucide-react"; +import { FunnelXIcon, TextSearchIcon } from "lucide-react"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; -import { EMPTY_FILTER, facets, filterRows, hasAnyFilter, sortRows } from "@/requestTable"; +import { errorText } from "@/i18n/core.i18n"; +import { when } from "@/format"; +import { EMPTY_FILTER, facets, hasAnyFilter, sortRows } from "@/requestTable"; import type { CoreStatus, RequestRow, SessionView } from "@/types"; import RequestDrawer from "@/RequestDrawer"; import { useNav, useNavParams } from "@/nav"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { IconCopy, IconFlow } from "@/ui/icons"; -import { Input } from "@/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { usePending } from "@/ui/notify"; import { PageHeader } from "@/ui/page"; import { Segmented } from "@/ui/segmented"; import { Skeleton } from "@/ui/skeleton"; +import { Spinner } from "@/ui/spinner"; import { EmptyState, ErrorState } from "@/ui/states"; import { StatusDot } from "@/ui/status-dot"; +import { Tip } from "@/ui/tip"; import { Toggle } from "@/ui/toggle"; import { useStartOf } from "@/useNow"; +import { LIST_LIMIT } from "@/useRequests"; import { useArrivals } from "./arrivals"; import { copyText } from "./cells"; import { groupAt, groupBySession, isAt, lines, step, visible, type Cursor, type Group } from "./grouping"; +import { mergeFound, useHistorySearch, type HistorySearch } from "./historySearch"; import { RequestTable } from "./RequestTable"; import { SessionSheet } from "./SessionPanel"; import { TrafficSummary } from "./TrafficSummary"; @@ -84,10 +90,24 @@ export default function TrafficPage({ const { filter, setFilter, sortKey, sortDir, toggleSort, grouped, setGrouped, openGroups, setOpenGroups } = view; const [retrying, retry] = usePending(); + /* + **筛选伸到整份记录里。**读进来的只有最近两千条:列表装满了、或者要按内容找时, + 同样的条件交给 core 在库里找,找到的并进来(见 `historySearch.ts`)。没有请求时 + 库里也没有,不去问。 + */ + const search = useHistorySearch( + filter, + allRows, + allRows.length >= LIST_LIMIT, + seeded && seedError === undefined && allRows.length > 0, + ); + const found = search.found; const rows = useMemo( - () => sortRows(filterRows(allRows, filter), sortKey, sortDir), - [allRows, filter, sortKey, sortDir], + () => sortRows(mergeFound(allRows, filter, found), sortKey, sortDir), + [allRows, filter, found, sortKey, sortDir], ); + /** 库里那一页还没回来,表里一条都还没有:画骨架,不说「没有符合条件的」 */ + const searching = search.mode !== "off" && (found === null || (found.busy && found.stopped === null)); const facet = useMemo(() => facets(allRows), [allRows]); /** 上一次归出来的组:没变的组沿用原来的对象,组头就不重画(见 `groupBySession`) */ const lastGroups = useRef([]); @@ -103,14 +123,18 @@ export default function TrafficPage({ const dir = sortDir === "asc" ? 1 : -1; return sortKey === "time" ? [...gs].sort((a, b) => (groupAt(a) - groupAt(b)) * dir) : gs; }, [grouped, rows, sessions, sortKey, sortDir]); + /** 全集:读进来的,加上库里找到的(它们可能来自别的密钥、别的应用) */ + const universe = useMemo(() => (found ? [...allRows, ...found.rows, ...found.kept] : allRows), [allRows, found]); /** * 「密钥」这一列只在真的分得开的时候才出现:一把密钥时整列是同一个值。按实际 * 出现过的算;推测出的应用、非本机的来源分得开也算(格子里也写它们)。 */ const showClient = - facet.clients.length > 1 || new Set(allRows.map((r) => r.hint ?? "")).size > 1 || allRows.some((r) => r.peer); + new Set(universe.flatMap((r) => (r.client ? [r.client] : []))).size > 1 || + new Set(universe.map((r) => r.hint ?? "")).size > 1 || + universe.some((r) => r.peer); /** 有哪一行带着推测出的应用。按全集算:筛选一变,密钥那一格的缩进不该跟着跳 */ - const hints = useMemo(() => allRows.some((r) => r.hint), [allRows]); + const hints = useMemo(() => universe.some((r) => r.hint), [universe]); /* 刚到的请求和刚出现的会话,滑进来。**按全集算**,见 `useArrivals`。 @@ -289,15 +313,37 @@ export default function TrafficPage({ 重新找它在哪儿;⌘F 也要一直有地方落。没有请求时除了搜索框都是禁用的。 */}
- setFilter((f) => ({ ...f, q: e.target.value }))} - placeholder={t.search} - spellCheck={false} - /> + {/* + **「搜索内容」是框里的一个图标开关,不和筛选并排**:它改的是怎么搜,不是筛掉 + 哪些(编辑器搜索框里「区分大小写」那一类开关也在框里,也是图标)。写成字的话, + 英文的「Content」在 Windows 上把占位文字挤掉一截,并排放则默认窗口宽度下过滤 + 条右端的条数和「清空」要折到第二行。内容只有 core 读得到,按下之后搜索词交给它 + 在请求和回答里找;没有搜索词时它什么也不做 + */} + + setFilter((f) => ({ ...f, q: e.target.value }))} + placeholder={t.search} + spellCheck={false} + /> + + + setFilter((f) => ({ ...f, content: v }))} + > + + + + + - {t.shownOf(rows.length, allRows.length)} + + {/* 在库里找过就说不出「一共几条」了:只说找到几条,搜到了哪儿写在表下面 */} + {found ? t.found(rows.length) : t.shownOf(rows.length, allRows.length)} + @@ -416,17 +465,40 @@ export default function TrafficPage({
{failedEmpty ? ( void retry(onRetry)} retrying={retrying} /> - ) : seeded && rows.length === 0 ? ( + ) : seeded && rows.length === 0 && !searching ? ( !none ? ( // 有记录,只是全被筛掉了。这时说「暂无请求记录」是错的 } title={t.noMatchTitle} - description={t.noMatch(allRows.length)} + description={ + found ? ( + <> + {found.error !== undefined + ? t.searchFailed(errorText(found.error)) + : t.noMatchSearched(found.next ? when(found.next.at_ms, today) : null)} + {found.mode === "content" && found.stopped !== null && ( + <> +
+ {found.bodiesSinceMs !== null ? t.bodiesSince(found.bodiesSinceMs) : t.noBodies} + + )} + + ) : ( + t.noMatch(allRows.length) + ) + } action={ - + <> + {found && (found.error !== undefined || found.next) && ( + + )} + + } /> ) : ( @@ -466,6 +538,7 @@ export default function TrafficPage({ ) : (
+ {rows.length > 0 && } {locallyAnswered > 0 && rows.length > 0 && (

{t.probesElsewhere(locallyAnswered)}

)} @@ -504,6 +578,57 @@ export default function TrafficPage({ ); } +/** + * 表下面那一行:在整份记录里搜到了哪一刻、还能不能往前接着找;按内容找时再说一句 + * 报文留到哪一天 —— 比那更早的请求只按记录对,搜不到内容不等于内容里没有。 + */ +function SearchStatus({ search, today }: { search: HistorySearch; today: number }) { + const t = useText(trafficText); + const common = useText(commonText); + const f = search.found; + if (search.mode === "off") return null; + return ( +
+ {f === null || f.stopped === null ? ( + f?.error !== undefined ? ( + + ) : ( + + + {search.mode === "content" ? t.searchingContent : t.searchingOlder} + + ) + ) : f.error !== undefined ? ( + + ) : f.next ? ( + + {t.searchedTo(when(f.next.at_ms, today))} + + + ) : ( + {t.searchedAll} + )} + {f && f.mode === "content" && f.stopped !== null && ( + {f.bodiesSinceMs !== null ? t.bodiesSince(f.bodiesSinceMs) : t.noBodies} + )} +
+ ); +} + +function SearchFailed({ error, onRetry, retry }: { error: unknown; onRetry: () => void; retry: string }) { + const t = useText(trafficText); + return ( + + {t.searchFailed(errorText(error))} + + + ); +} + /** * 空状态的图:一列记录,角上一个在跳的点 —— 网关开着、在等第一条请求。 * 图标位是 40px 的方块、图标 18px 居中,点要落在方块的右上角,所以往外挪 11px。 diff --git a/src/traffic/historySearch.test.ts b/src/traffic/historySearch.test.ts new file mode 100644 index 00000000..b818223b --- /dev/null +++ b/src/traffic/historySearch.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import { codesMatching } from "@/i18n/core.i18n"; +import { EMPTY_FILTER, type Filter } from "@/requestTable"; +import type { ContentHit, RequestRow } from "@/types"; +import { mergeFound, oldestCursor, searchMode, searchQuery, type Found } from "./historySearch"; + +function row(p: Partial & { id: number }): RequestRow { + return { + client: "claude-code", + provider: "relay", + path: "/v1/messages", + atMs: 1_000_000 + p.id, + state: "done", + ...p, + }; +} + +const hit = (id: number): ContentHit => ({ id, side: "answer", before: "…", matched: "rm -rf", after: "…" }); + +function found(p: Partial): Found { + return { + key: "k", + mode: "older", + kept: [], + rows: [], + hits: new Map(), + at: null, + next: null, + stopped: "end", + bodiesSinceMs: null, + busy: false, + error: undefined, + ...p, + }; +} + +const q = (s: string): Filter => ({ ...EMPTY_FILTER, q: s }); + +describe("什么时候去库里找", () => { + it("没有筛选条件时不找:表里就是最近的那些", () => { + expect(searchMode(EMPTY_FILTER, true)).toBe("off"); + // 只按下「搜索内容」、没有搜索词,什么也不做 + expect(searchMode({ ...EMPTY_FILTER, content: true }, true)).toBe("off"); + }); + + /** + * 列表没装满,读进来的就是全部记录:当场筛完了,再去库里问只会拿回同样的行。 + */ + it("列表没装满时只当场筛", () => { + expect(searchMode(q("timeout"), false)).toBe("off"); + expect(searchMode({ ...EMPTY_FILTER, failedOnly: true }, false)).toBe("off"); + }); + + it("列表装满了,更早的那些交给 core", () => { + expect(searchMode(q("timeout"), true)).toBe("older"); + expect(searchMode({ ...EMPTY_FILTER, provider: "relay" }, true)).toBe("older"); + }); + + /** 内容不在读进来的行上:装没装满都要 core 去读 */ + it("按内容找总是去库里,装没装满都一样", () => { + expect(searchMode({ ...q("rm -rf"), content: true }, false)).toBe("content"); + expect(searchMode({ ...q("rm -rf"), content: true }, true)).toBe("content"); + // 空白不算搜索词 + expect(searchMode({ ...q(" "), content: true }, false)).toBe("off"); + }); +}); + +describe("从哪一条往前找", () => { + it("读进来的最老那一条;同一毫秒里按请求号", () => { + const rows = [row({ id: 9, atMs: 500 }), row({ id: 4, atMs: 100 }), row({ id: 3, atMs: 100 }), row({ id: 7, atMs: 300 })]; + expect(oldestCursor(rows)).toEqual({ at_ms: 100, id: 3 }); + expect(oldestCursor([])).toBeNull(); + }); +}); + +describe("交给 core 的条件", () => { + it("和界面的筛选一一对应,空串是不限", () => { + const got = searchQuery( + { ...EMPTY_FILTER, q: " Opus ", failedOnly: true, provider: "relay" }, + "older", + { at_ms: 100, id: 3 }, + ); + expect(got).toMatchObject({ + q: "opus", + failed: true, + unpriced: false, + client: null, + provider: "relay", + model: null, + content: false, + before: { at_ms: 100, id: 3 }, + }); + }); + + it("按内容找时才带 content,一页少一些", () => { + const a = searchQuery({ ...q("x"), content: true }, "content", null); + const b = searchQuery(q("x"), "older", null); + expect(a.content).toBe(true); + expect(b.content).toBe(false); + expect(a.limit!).toBeLessThan(b.limit!); + }); + + /** + * 失败原因在中文界面上是按码翻出来的,库里只有英文原句:照着屏幕上的「超时」搜, + * 英文原句里没有这两个字。 + */ + it("中文界面带上译文里含着搜索词的码", () => { + expect(searchQuery(q("超时"), "older", null).error_codes).toContain("gw.upstream.timeout"); + setLang("en"); + expect(searchQuery(q("timeout"), "older", null).error_codes).toEqual([]); + }); + + it("本地应答那句说明里有搜索词时,交 local_matches", () => { + expect(searchQuery(q("本地"), "older", null).local_matches).toBe(true); + expect(searchQuery(q("relay"), "older", null).local_matches).toBe(false); + expect(searchQuery(EMPTY_FILTER, "older", null).local_matches).toBe(false); + setLang("en"); + expect(searchQuery(q("locally"), "older", null).local_matches).toBe(true); + }); +}); + +describe("译文里可能含着搜索词的码", () => { + it("写死的字对得上就算", () => { + expect(codesMatching("上游响应超时")).toContain("gw.upstream.timeout"); + }); + + /** 跨过参数对上的不算:那个参数填进去之后,这几个字多半就不挨着了 */ + it("不跨过参数对", () => { + // 「上游「{upstream}」使用的代理」:「」使用」两边隔着一个参数 + const codes = codesMatching("上游「」使用"); + expect(codes).not.toContain("l1.config.proxy_undefined"); + }); + + it("没有搜索词时一个都不给", () => { + expect(codesMatching("")).toEqual([]); + }); +}); + +describe("表里该有的行", () => { + const f = q("relay"); + + it("没在库里找时,就是读进来的里对上的", () => { + const loaded = [row({ id: 1 }), row({ id: 2, provider: "official" })]; + expect(mergeFound(loaded, f, null).map((r) => r.id)).toEqual([1]); + }); + + it("库里找到的并进来", () => { + const loaded = [row({ id: 10 })]; + const got = mergeFound(loaded, f, found({ rows: [row({ id: 3 }), row({ id: 2 })] })); + expect(got.map((r) => r.id).sort((a, b) => a - b)).toEqual([2, 3, 10]); + }); + + /** 实时的那一行更全(脱敏、可疑调用只在事件里有),而且还在变 */ + it("两边都有的用读进来的那个", () => { + const live = row({ id: 5, durationMs: 900 }); + const fromDb = row({ id: 5, durationMs: 100 }); + const got = mergeFound([live], f, found({ mode: "content", rows: [fromDb] })); + expect(got).toHaveLength(1); + expect(got[0]).toBe(live); + }); + + /** + * 按内容对上的:读进来的那一行按记录对不上(路径、上游里都没有这个词),照样要。 + * 只按记录交回来、这边却对不上的不要 —— 失败原因的码是宽着给的,以屏幕上的为准。 + */ + it("读进来的那一行对不上:按内容对上的才要", () => { + const a = row({ id: 6, provider: "official" }); + const b = row({ id: 7, provider: "official" }); + const got = mergeFound( + [a, b], + q("rm -rf"), + found({ mode: "content", rows: [a, b], hits: new Map([[6, hit(6)]]) }), + ); + expect(got.map((r) => r.id)).toEqual([6]); + expect(got[0]).toBe(a); + }); + + /** + * 新请求一来,最老的被挤出列表;`older` 是从当时最老的那一条往前找的 —— 挤掉的那几条 + * 两边都不再有,靠 `kept` 留在表里。 + */ + it("搜索开始后被挤出列表的,还在表里", () => { + const pushedOut = row({ id: 1 }); + const now = [row({ id: 2 }), row({ id: 3 })]; + const got = mergeFound(now, f, found({ kept: [pushedOut, now[0]!] })); + expect(got.map((r) => r.id).sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); + + it("还在列表里、但已经对不上的,不因为 kept 留下", () => { + // 开始搜索时还没算出金额,之后算出来了:「仅显示无法计价」下它该走 + const before = row({ id: 4, inputTokens: 10, outputTokens: 5 }); + const after = { ...before, costMicros: 120 }; + const got = mergeFound([after], { ...EMPTY_FILTER, unpricedOnly: true }, found({ kept: [before] })); + expect(got).toEqual([]); + }); +}); diff --git a/src/traffic/historySearch.ts b/src/traffic/historySearch.ts new file mode 100644 index 00000000..ab397733 --- /dev/null +++ b/src/traffic/historySearch.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { call } from "@/control"; +import { textOf, useLang } from "@/i18n"; +import { codesMatching } from "@/i18n/core.i18n"; +import { filterRows, hasAnyFilter, matches, type Filter } from "@/requestTable"; +import type { ContentHit, HistoryCursor, HistorySearchQuery, RequestRow, SearchStop } from "@/types"; +import { rowFromHistory } from "@/useRequests"; +import { requestsText } from "@/useRequests.i18n"; + +/** + * 流量页的搜索伸到整份请求记录里(`POST /history/search`)。 + * + * **读进来的只有最近两千条,记录却留着三个月。**筛选框原来只筛读进来的那些,再往前 + * 的永远看不见;请求和回答的内容不在行上,读进来的那些也搜不到。所以分两步:读进来的 + * 当场筛(照旧),库里的交给 core,找到的并进同一张表。 + * + * 两种找法(`SearchMode`): + * + * - `older`:只按记录找,**从读进来的最老那一条往前**。比它新的读进来的都已经筛过了, + * 再问一遍只会拿回一页已经在表里的行,「继续搜索」点几次都不见新东西。列表没装满 + * 时读进来的就是全部,不去问。 + * - `content`:也按内容找,**从最新的往前**:内容只有 core 读得到,读进来的那些也要它 + * 去看。按内容找要读盘,core 每次只读一定的量就交回来(`SearchStop` 的 `budget`), + * 要再往前就再问一次。 + */ +export type SearchMode = "off" | "older" | "content"; + +/** 一页几条。按记录找是一句走索引的 SQL,一页给多一点,少点几次「继续搜索」 */ +const PAGE_RECORDS = 200; +/** 按内容找时一页几条。多半凑够之前,读盘的量(core 的 `Budget`)就先用完了 */ +const PAGE_CONTENT = 100; +/** 停手多久之后才去库里找。读进来的那些当场就筛了,这一步不必跟着每个键走 */ +export const DEBOUNCE_MS = 300; + +/** 这组条件要不要去库里找、怎么找。`capped`:列表装满了,更早的还在库里 */ +export function searchMode(f: Filter, capped: boolean): SearchMode { + if (!hasAnyFilter(f)) return "off"; + if (f.content && f.q.trim() !== "") return "content"; + return capped ? "older" : "off"; +} + +/** 读进来的行里最老的那一条:`older` 从它往前找。没有行时是 `null` */ +export function oldestCursor(rows: readonly RequestRow[]): HistoryCursor | null { + let at: RequestRow | undefined; + for (const r of rows) if (!at || r.atMs < at.atMs || (r.atMs === at.atMs && r.id < at.id)) at = r; + return at ? { at_ms: at.atMs, id: at.id } : null; +} + +/** + * 交给 core 的那一份条件,和界面的筛选一一对应。 + * + * 失败原因在界面上是按码翻出来的(中文界面),库里只有英文原句:译文里可能含着搜索 + * 词的码一起交过去(`codesMatching`)。本地应答的那几行「上游」一格写的是界面自己的 + * 一句说明,那句话里含着搜索词时交 `local_matches`。 + */ +export function searchQuery(f: Filter, mode: "older" | "content", before: HistoryCursor | null): HistorySearchQuery { + const q = f.q.trim().toLowerCase(); + return { + q, + failed: f.failedOnly, + unpriced: f.unpricedOnly, + client: f.client || null, + provider: f.provider || null, + model: f.model || null, + error_codes: codesMatching(q), + local_matches: q !== "" && textOf(requestsText).answeredLocally.toLowerCase().includes(q), + content: mode === "content", + before, + limit: mode === "content" ? PAGE_CONTENT : PAGE_RECORDS, + }; +} + +/** 一次搜索(一组条件)到目前为止的结果 */ +export interface Found { + /** 哪一组条件的结果。和此刻的条件对不上就是作废了的,不画(见 `useHistorySearch`) */ + key: string; + mode: "older" | "content"; + /** + * 搜索开始那一刻,读进来的行里对上的那些。**之后被挤出列表的靠它留在表里**: + * 列表只装两千条,新请求一来,最老的就被挤掉 —— 而 `older` 是从当时最老的那一条 + * 往前找的,挤掉的那几条两边都不再有。 + */ + kept: RequestRow[]; + /** 库里找到的,按 core 给的顺序(新的在前) */ + rows: RequestRow[]; + /** 按内容对上的那些各自对上了哪一段 */ + hits: ReadonlyMap; + /** 正在要、或者最后一次要的那一页从哪儿找起。失败了「重试」从这里再要一次 */ + at: HistoryCursor | null; + /** 下一页从哪儿接着找。`null`:找完了,或者第一页还没回来(看 `stopped`) */ + next: HistoryCursor | null; + /** 最后一页为什么停在那儿。第一页还没回来时是 `null` */ + stopped: SearchStop | null; + /** 按内容找时:报文最早留到哪一刻。盘上一份都没有时是 `null` */ + bodiesSinceMs: number | null; + /** 正在要一页 */ + busy: boolean; + /** 最后一次要页失败的原因。成了就清掉 */ + error: unknown; +} + +/** + * 表里该有的行:读进来的行里对上的,加上库里找到的。 + * + * **同一条请求两边都有时用读进来的那个**:实时的那一行更全(脱敏、可疑的工具调用只在 + * 事件里有),而且它还在变。读进来的那一行按记录对不上、库里却交回来了的,只有按内容 + * 对上的才要 —— 别的是那边宽、这边严(失败原因的码是宽着给的,见 `searchQuery`), + * 以屏幕上看得见的为准。 + */ +export function mergeFound(loaded: readonly RequestRow[], f: Filter, found: Found | null): RequestRow[] { + const matched = filterRows(loaded as RequestRow[], f); + if (!found) return matched; + const live = new Map(loaded.map((r) => [r.id, r])); + const out = new Map(matched.map((r) => [r.id, r])); + for (const r of found.kept) if (!live.has(r.id) && !out.has(r.id)) out.set(r.id, r); + for (const r of found.rows) { + if (out.has(r.id)) continue; + const l = live.get(r.id); + if (!l) out.set(r.id, r); + else if (found.hits.has(r.id)) out.set(r.id, l); + } + return [...out.values()]; +} + +/** 要一页,换成表格的行。按记录交回来的照屏幕上的说法再筛一遍(见 `mergeFound`) */ +async function fetchPage(f: Filter, mode: "older" | "content", before: HistoryCursor | null) { + const page = await call("HistorySearch", searchQuery(f, mode, before)); + const hits = new Map(page.hits.map((h) => [h.id, h])); + const q = f.q.trim().toLowerCase(); + return { + rows: page.rows.map(rowFromHistory).filter((r) => hits.has(r.id) || matches(r, f, q)), + hits, + next: page.next, + stopped: page.stopped, + bodiesSinceMs: page.bodies_since_ms, + }; +} + +export interface HistorySearch { + mode: SearchMode; + /** 这组条件的结果。`mode` 是 `off` 时是 `null` */ + found: Found | null; + /** 往前接着找一页;上一次失败了就是重试那一页 */ + more: () => void; +} + +/** + * 跟着筛选条件在库里找。条件一变就是一次新的搜索(停手 `DEBOUNCE_MS` 之后才问), + * 之前那次还没回来的结果作废。 + * + * `ready`:历史读成了。之前「列表装没装满」「最老的是哪一条」都还不知道;读失败时 + * 读进来的只是之后的事件流,那两样也不成立。 + */ +export function useHistorySearch( + filter: Filter, + loaded: readonly RequestRow[], + capped: boolean, + ready: boolean, +): HistorySearch { + // 换了语言要重找:失败原因按哪种语言对、本地应答那句话怎么写,都跟着语言 + const lang = useLang(); + const mode = ready ? searchMode(filter, capped) : "off"; + const key = + mode === "off" + ? "" + : JSON.stringify([ + mode, + lang, + filter.q.trim().toLowerCase(), + filter.failedOnly, + filter.unpricedOnly, + filter.client, + filter.provider, + filter.model, + ]); + const [found, setFound] = useState(null); + /* + **读进来的行和条件放在 ref 里。**开始一次搜索时要用它们(从哪一条往前、当时对上了 + 哪些),但新请求一到就重找是不对的:读进来的那些在页面上当场就筛了。 + */ + const loadedRef = useRef(loaded); + loadedRef.current = loaded; + const filterRef = useRef(filter); + filterRef.current = filter; + const foundRef = useRef(found); + foundRef.current = found; + /** 第几次搜索。回来的一页对不上这个数就是作废了的那次 */ + const seq = useRef(0); + + const load = useCallback((my: number, m: "older" | "content", before: HistoryCursor | null) => { + setFound((prev) => prev && { ...prev, at: before, busy: true, error: undefined }); + fetchPage(filterRef.current, m, before).then( + (p) => { + if (seq.current !== my) return; + setFound( + (prev) => + prev && { + ...prev, + rows: [...prev.rows, ...p.rows], + hits: p.hits.size === 0 ? prev.hits : new Map([...prev.hits, ...p.hits]), + next: p.next, + stopped: p.stopped, + bodiesSinceMs: p.bodiesSinceMs, + busy: false, + }, + ); + }, + (e: unknown) => { + if (seq.current !== my) return; + setFound((prev) => prev && { ...prev, busy: false, error: e }); + }, + ); + }, []); + + useEffect(() => { + const my = ++seq.current; + if (mode === "off") { + setFound(null); + return; + } + const rows = loadedRef.current; + const from = mode === "older" ? oldestCursor(rows) : null; + setFound({ + key, + mode, + kept: filterRows(rows as RequestRow[], filterRef.current), + rows: [], + hits: new Map(), + at: from, + next: null, + stopped: null, + bodiesSinceMs: null, + busy: true, + error: undefined, + }); + const timer = setTimeout(() => load(my, mode, from), DEBOUNCE_MS); + return () => clearTimeout(timer); + // `key` 已经包含了 `mode` 和条件里要紧的每一样 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, load]); + + const more = useCallback(() => { + const f = foundRef.current; + if (!f || f.busy) return; + if (f.error !== undefined) load(seq.current, f.mode, f.at); + else if (f.next) load(seq.current, f.mode, f.next); + }, [load]); + + /* + 条件变了的那一帧,新的一次搜索还没开始(effect 在画完之后才跑),手上的还是上一组 + 条件的结果 —— 它们不一定对得上新的条件,不画 + */ + return { mode, found: found?.key === key ? found : null, more }; +} diff --git a/src/upstreams/Checkup.i18n.ts b/src/upstreams/Checkup.i18n.ts new file mode 100644 index 00000000..6c5bbc4d --- /dev/null +++ b/src/upstreams/Checkup.i18n.ts @@ -0,0 +1,123 @@ +import { messages } from "@/i18n"; + +/** 英文的单复数 */ +const count = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`; + +/** + * 上游页「体检」标签的文案。 + * + * **只说事实和参照,不下结论**:「12 次不同」「+31%」,不说「虚报」「掺假」。偏差标成 + * 琥珀色,是请人看一眼,不是替人判断(见 `checkup.ts`)。 + */ +export const checkupText = messages( + { + // 表头 + upstream: "上游", + models: "模型名", + modelsTip: "回答中写的模型名与发出的是否相同。", + input: "输入 token", + inputTip: "上游报告的输入 token 与本地估算之比,和服务同一模型的其他上游相比。", + cache: "缓存读取", + cacheTip: "可命中缓存的轮次中,输入从缓存读取的比例。", + timing: "首 token / 速度", + + // 上游一格 + requests: (n: number) => `${n.toLocaleString()} 次请求`, + failedShare: (pct: string) => `失败 ${pct}`, + requestsTip: (n: number) => `请求 ${n.toLocaleString()} 次`, + failedTip: (n: number) => `失败 ${n.toLocaleString()} 次`, + cancelledTip: (n: number) => `另有 ${n.toLocaleString()} 次被客户端取消,不计入请求`, + + // 模型名 + consistent: "一致", + differed: (n: number) => `${n.toLocaleString()} 次不同`, + named: (n: number) => `共 ${n.toLocaleString()} 次`, + noNames: "回答中没有写模型名。", + allSame: (n: number) => `${n.toLocaleString()} 次回答中写的模型名都与发出的相同。`, + pairsTitle: (n: number, of: number) => + `${of.toLocaleString()} 次回答中,${n.toLocaleString()} 次写的模型名与发出的不同:`, + pair: (sent: string, answered: string, n: number) => `发出 ${sent},回答 ${answered} · ${n.toLocaleString()} 次`, + + // 输入 token + ratio: (x: number) => `${x.toFixed(2)} 倍`, + inputUnit: "上游报告的输入 token 为本地估算的倍数:", + inputLine: (here: string, hereN: number, k: number, others: string, othersN: number) => + `本上游 ${here}(${hereN.toLocaleString()} 次)· 其他 ${k} 个上游 ${others}(${othersN.toLocaleString()} 次)`, + inputAll: (x: string, n: number) => `所有模型合计为本地估算的 ${x}(${n.toLocaleString()} 次)。`, + noComparison: "没有服务同一模型且样本足够的其他上游,无从比较。", + + // 缓存读取 + turns: (n: number) => `${n.toLocaleString()} 轮`, + cacheSummary: (turns: number, share: string, zero: number) => + `可命中缓存的 ${turns.toLocaleString()} 轮中,缓存读取占输入的 ${share},${zero.toLocaleString()} 轮未读到缓存。`, + cacheLine: (here: string, hereN: number, k: number, others: string, othersN: number) => + `本上游 ${here}(${hereN.toLocaleString()} 轮)· 其他 ${k} 个上游 ${others}(${othersN.toLocaleString()} 轮)`, + noTurns: "没有可命中缓存的轮次。", + + // 首 token / 速度 + ms: (n: number) => `${n.toLocaleString()} ms`, + speed: (n: number) => `${n.toLocaleString()} token/秒`, + ttftTip: (n: number) => `首 token 中位数 · ${n.toLocaleString()} 个样本`, + speedTip: (n: number) => `生成速度中位数 · ${n.toLocaleString()} 个样本`, + + // 整页 + covered: (at: string) => `请求记录自 ${at} 起,更早的时段不在统计内。`, + empty: "所选时段内没有发往上游的请求", + emptyDesc: "请求经上游转发后,这里按上游列出统计。", + failed: "体检数据读取失败", + }, + { + upstream: "Upstream", + models: "Model name", + modelsTip: "Whether the model named in the answers matches the one sent.", + input: "Input tokens", + inputTip: + "Input tokens reported by the upstream relative to the local estimate, compared with other upstreams serving the same model.", + cache: "Cache reads", + cacheTip: "The share of input read from the prompt cache, over turns that could read it.", + timing: "TTFT / speed", + + requests: (n: number) => count(n, "request", "requests"), + failedShare: (pct: string) => `${pct} failed`, + requestsTip: (n: number) => count(n, "request", "requests"), + failedTip: (n: number) => `${n.toLocaleString()} failed`, + cancelledTip: (n: number) => + n === 1 + ? "1 more was cancelled by the client and is not counted" + : `${n.toLocaleString()} more were cancelled by the client and are not counted`, + + consistent: "Matches", + differed: (n: number) => `${n.toLocaleString()} differ`, + named: (n: number) => count(n, "answer", "answers"), + noNames: "The answers do not name a model.", + allSame: (n: number) => + n === 1 ? "The model named in 1 answer matches the one sent." : `The model named in all ${n.toLocaleString()} answers matches the one sent.`, + pairsTitle: (n: number, of: number) => + `In ${n.toLocaleString()} of ${count(of, "answer", "answers")}, the model named differs from the one sent:`, + pair: (sent: string, answered: string, n: number) => `Sent ${sent}, answered ${answered} · ${n.toLocaleString()}×`, + + ratio: (x: number) => `${x.toFixed(2)}×`, + inputUnit: "Input tokens reported by the upstream, as a multiple of the local estimate:", + inputLine: (here: string, hereN: number, k: number, others: string, othersN: number) => + `This upstream ${here} (${count(hereN, "request", "requests")}) · ${count(k, "other upstream", "other upstreams")} ${others} (${count(othersN, "request", "requests")})`, + inputAll: (x: string, n: number) => `All models together: ${x} the local estimate (${count(n, "request", "requests")}).`, + noComparison: "No other upstream served the same model with enough samples to compare.", + + turns: (n: number) => count(n, "turn", "turns"), + cacheSummary: (turns: number, share: string, zero: number) => + `Over ${count(turns, "turn", "turns")} that could read the cache, ${share} of the input was read from it; ${zero.toLocaleString()} read none.`, + cacheLine: (here: string, hereN: number, k: number, others: string, othersN: number) => + `This upstream ${here} (${count(hereN, "turn", "turns")}) · ${count(k, "other upstream", "other upstreams")} ${others} (${count(othersN, "turn", "turns")})`, + noTurns: "No turns could read the cache.", + + ms: (n: number) => `${n.toLocaleString()} ms`, + speed: (n: number) => `${n.toLocaleString()} tokens/s`, + ttftTip: (n: number) => `Median time to first token · ${count(n, "sample", "samples")}`, + speedTip: (n: number) => `Median generation speed · ${count(n, "sample", "samples")}`, + + covered: (at: string) => `Requests are recorded from ${at}; earlier times are not included.`, + empty: "No requests went to an upstream in this period", + emptyDesc: "Once requests go through upstreams, each upstream's figures are listed here.", + failed: "Could not load the check-up", + }, +); diff --git a/src/upstreams/CheckupTab.tsx b/src/upstreams/CheckupTab.tsx new file mode 100644 index 00000000..e1d40c6d --- /dev/null +++ b/src/upstreams/CheckupTab.tsx @@ -0,0 +1,361 @@ +import type { ReactNode } from "react"; +import { StethoscopeIcon } from "lucide-react"; +import { call } from "@/control"; +import { when } from "@/format"; +import { useText } from "@/i18n"; +import { useResource } from "@/lib/resource"; +import { cn } from "@/lib/utils"; +import type { ProviderView, UpstreamCheckup } from "@/types"; +import type { Range } from "@/ui/range"; +import { Skeleton } from "@/ui/skeleton"; +import { EmptyState, ErrorState } from "@/ui/states"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { Tip } from "@/ui/tip"; +import { useStartOf } from "@/useNow"; +import { checkupText } from "./Checkup.i18n"; +import { + cacheFlagged, + cacheGaps, + cacheShare, + failFlagged, + failRate, + inputFlagged, + inputGaps, + pct, + signedPct, +} from "./checkup"; +import { VendorTile } from "./parts"; + +const HOUR = 3_600_000; + +/** + * 体检的数。窗口的起点对齐到整点:页面开着的时候起点一小时才挪一格,不会每分钟 + * 重问一遍同一个问题。**请求落地之后重读**,但最多半分钟一次 —— 七天、三十天的数, + * 多一条请求几乎不动,而这一问要把整段时间的记录过一遍。 + */ +function useUpstreamHealth(range: Range) { + const hour = useStartOf(HOUR); + const from = range.from ?? hour + HOUR - range.ms; + return useResource("upstream-health", () => call("UpstreamHealth", { from_ms: from }), { + events: ["request_finished", "request_failed", "request_cancelled"], + throttleMs: 30_000, + deps: [from], + }); +} + +/** + * 上游页的「体检」标签:一段时间里每个上游的几项事实,各带样本数和参照。 + * + * 一行一个上游(请求多的在前):请求与失败、回答里的模型名对不对得上、报的输入和 + * 别的上游比、缓存读到了多少、首 token 与速度。数字之外的说明都在悬停里。 + */ +export function CheckupTab({ range, providers }: { range: Range; providers: ProviderView[] }) { + const t = useText(checkupText); + const health = useUpstreamHealth(range); + const today = useStartOf(24 * HOUR); + const d = health.data; + + if (d === undefined) { + if (health.error !== undefined && !health.loading) { + return ( + void health.reload()} + retrying={health.refreshing} + /> + ); + } + return ; + } + if (d.upstreams.length === 0) { + return } title={t.empty} description={t.emptyDesc} />; + } + // 库刚重建、记录留的天数比窗口短:开头那一段没有记录,要说出来 —— 不然「7 天」里其实只有两天 + const late = d.covered_since_ms !== null && d.covered_since_ms > d.from_ms + 60_000; + return ( +
+ {late &&

{t.covered(when(d.covered_since_ms!, today))}

} + + + + {t.upstream} + + {t.models} + + + {t.input} + + + {t.cache} + + {t.timing} + + + + {d.upstreams.map((c) => ( + + p.name === c.upstream)} /> + + + + + + ))} + +
+
+ ); +} + +/** 表头上带说明的那几列:字下面一道虚线,悬停看这一列比的是什么 */ +function HeadTip({ text, children }: { text: string; children: ReactNode }) { + return ( + {text}}> + {children} + + ); +} + +/** 一格里上下两行:上面是数,下面淡一档的是样本或补充。和上游标签那张表同一个写法 */ +function Two({ top, bottom, tone, tip }: { top: ReactNode; bottom?: ReactNode; tone?: "warn" | "muted"; tip?: ReactNode }) { + const body = ( +
+ {top} + {bottom ?? "\u00a0"} +
+ ); + if (!tip) return body; + return ( + {tip}
}> +
{body}
+ + ); +} + +function Dash() { + return —; +} + +/** 上游一格:标志、名字,下面是请求数和失败率。上游已经删了的照样列出(记录里还有它) */ +function NameCell({ c, p }: { c: UpstreamCheckup; p: ProviderView | undefined }) { + const t = useText(checkupText); + const rate = failRate(c); + return ( + +
+ +
+
{c.upstream}
+ +

{t.requestsTip(c.requests)}

+

{t.failedTip(c.failed)}

+ {c.cancelled > 0 &&

{t.cancelledTip(c.cancelled)}

} +
+ } + > +
+ {t.requests(c.requests)} + {rate !== null && c.failed > 0 && ( + · {t.failedShare(pct(rate))} + )} +
+ +
+
+ + ); +} + +/** + * 模型名一格。**写得一致不能证明真是那个模型,写得不一致才是一条线索**:所以一致的 + * 写成淡的「一致」,不一致的写次数、悬停列出是哪几对。 + */ +function ModelsCell({ c }: { c: UpstreamCheckup }) { + const t = useText(checkupText); + const m = c.models; + if (m.named === 0) { + return ( + + + + + + + + ); + } + if (m.differed === 0) { + return ( + + +
+ {t.consistent} + {t.named(m.named)} +
+
+
+ ); + } + return ( + + +

{t.pairsTitle(m.differed, m.named)}

+ {m.examples.map((x) => ( +

+ {t.pair(x.sent, x.answered, x.count)} +

+ ))} +
+ } + > +
+ {t.differed(m.differed)} + {t.named(m.named)} +
+ + + ); +} + +/** + * 输入 token 一格:和别的上游比得上的模型里,差得最多的那一个 —— 上面是差多少,下面 + * 是哪个模型。比不上的(只有它服务这些模型、样本不够)写「—」,悬停说为什么, + * 并给出它自己的比值:单看一家的比值说明不了什么,但用户问起时要有。 + */ +function InputCell({ c }: { c: UpstreamCheckup }) { + const t = useText(checkupText); + const gaps = inputGaps(c); + const all = c.input.all; + const allLine = all ? t.inputAll(t.ratio(all.median), all.samples) : null; + if (gaps.length === 0) { + return ( + + } + tip={ + <> +

{t.noComparison}

+ {allLine &&

{allLine}

} + + } + /> +
+ ); + } + const top = gaps[0]!; + return ( + + {top.model}} + tip={ + <> +

{t.inputUnit}

+ {gaps.map((g) => ( +
+

+ {g.model} {signedPct(g.gap)} +

+

{t.inputLine(t.ratio(g.here.median), g.here.samples, g.otherUpstreams, t.ratio(g.others.median), g.others.samples)}

+
+ ))} + {allLine &&

{allLine}

} + + } + /> +
+ ); +} + +/** + * 缓存读取一格:上面是可命中缓存的轮次里,输入从缓存读的那一份;下面是几轮。和别的 + * 上游比,有哪个模型差得多就标出来 —— 读不读得到也看客户端(有的要客户端标出缓存 + * 断点),同样的客户端发给别的上游读得到,才是一条线索。 + */ +function CacheCell({ c }: { c: UpstreamCheckup }) { + const t = useText(checkupText); + const all = c.cache.all; + const share = cacheShare(all); + if (all.turns === 0 || share === null) { + return ( + + } tip={

{t.noTurns}

} /> +
+ ); + } + const gaps = cacheGaps(c); + const flagged = gaps.some(cacheFlagged); + return ( + + +

{t.cacheSummary(all.turns, pct(share), all.zero_read_turns)}

+ {gaps.map((g) => ( +
+

{g.model}

+

{t.cacheLine(pct(g.hereShare), g.here.turns, g.otherUpstreams, pct(g.othersShare), g.others.turns)}

+
+ ))} + + } + /> +
+ ); +} + +/** 首 token 和生成速度的中位数,上下两行,和上游标签那张表同一个写法 */ +function TimingCell({ c }: { c: UpstreamCheckup }) { + const t = useText(checkupText); + if (!c.ttft_ms && !c.tokens_per_sec) { + return ( + + } /> + + ); + } + return ( + + } + bottom={c.tokens_per_sec ? t.speed(c.tokens_per_sec.p50) : "—"} + tip={ + <> + {c.ttft_ms &&

{t.ttftTip(c.ttft_ms.samples)}

} + {c.tokens_per_sec &&

{t.speedTip(c.tokens_per_sec.samples)}

} + + } + /> +
+ ); +} + +/** 数还没到:几行占位,和真的行一样高 */ +function CheckupSkeleton() { + return ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+ +
+ + +
+ + + + +
+ ))} +
+ ); +} diff --git a/src/upstreams/UpstreamsPage.i18n.tsx b/src/upstreams/UpstreamsPage.i18n.tsx index 448093b8..8e7fd690 100644 --- a/src/upstreams/UpstreamsPage.i18n.tsx +++ b/src/upstreams/UpstreamsPage.i18n.tsx @@ -20,7 +20,7 @@ export const upstreamsPageText = messages( modelsFetched: (name: string, n: number) => `${name}:已获取 ${n} 个模型`, pricesUpdated: (changed: number) => `默认价目表已更新,${changed} 个模型的价格有变化`, pricesCurrent: "默认价目表已是最新", - tabs: { upstreams: "上游", proxies: "代理", pricing: "价目表" }, + tabs: { upstreams: "上游", proxies: "代理", pricing: "价目表", checkup: "体检" }, linkTest: "链路测速", speedTest: "推理测速", newUpstream: "新建上游", @@ -70,7 +70,7 @@ export const upstreamsPageText = messages( ? "Default price sheet updated; 1 model's price changed" : `Default price sheet updated; ${changed} models' prices changed`, pricesCurrent: "Default price sheet is up to date", - tabs: { upstreams: "Upstreams", proxies: "Proxies", pricing: "Price sheets" }, + tabs: { upstreams: "Upstreams", proxies: "Proxies", pricing: "Price sheets", checkup: "Check-up" }, linkTest: "Connection test", speedTest: "Inference test", newUpstream: "New upstream", diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index dfb6bc17..4f849ed3 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -14,6 +14,7 @@ import { Skeleton } from "@/ui/skeleton"; import { EmptyState } from "@/ui/states"; import { StatusDot } from "@/ui/status-dot"; import { Switch } from "@/ui/switch"; +import { RangePicker, useRange } from "@/ui/range"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs"; import { Tip } from "@/ui/tip"; import { useText } from "@/i18n"; @@ -21,6 +22,7 @@ import { commonText } from "@/i18n/common.i18n"; import type { Overview, PricingStatus, ProviderView } from "@/types"; import { api, type UpstreamStats } from "./api"; import { ChatgptLoginDialog } from "./ChatgptLoginDialog"; +import { CheckupTab } from "./CheckupTab"; import { patch, statsPartial, useAccountQuotas, useInFlight, usePricingStatus, useUpstreamStats } from "./data"; import { DeleteDialog, type Referrer } from "./DeleteDialog"; import { coreText, errorText, plain } from "./labels"; @@ -37,7 +39,7 @@ import { UpstreamTable, problemsOf } from "./UpstreamTable"; import { ZaiLoginDialog } from "./ZaiLoginDialog"; import { NextClientsHint } from "@/guide/PageHints"; -export type UpstreamTab = "upstreams" | "proxies" | "pricing"; +export type UpstreamTab = "upstreams" | "proxies" | "pricing" | "checkup"; /** * 这次打开应用以来最后看的那个标签。切到别的页再回来,还停在那一个。 @@ -61,11 +63,12 @@ type DialogState = | { kind: "delete-sheet"; name: string }; /** - * 上游页:上游、代理、价目表三个标签。 + * 上游页:上游、代理、价目表、体检四个标签。 * - * 三者描述出站侧的三个方面 —— 请求发往哪个服务、经过哪条网络路径、按什么 + * 前三者描述出站侧的三个方面 —— 请求发往哪个服务、经过哪条网络路径、按什么 * 价格结算 —— 而代理和价目表只被上游引用,所以放在同一页里,引用关系在 * 页内闭合。**列表只读**,新建与编辑都在对话框里完成,一次保存一个版本。 + * 体检回答的是「这些上游实际表现如何」:一段时间里各上游的几项事实和相互参照。 * * 页头的摘要说上游的整体状况:几个、几个正常、几个要处理,24 小时的请求与费用。 */ @@ -92,6 +95,8 @@ export default function UpstreamsPage({ setTabState(next); }; const [dialog, setDialog] = useState(null); + /** 体检看多长一段。各页各记各的,出厂是 7 天:一天里的样本撑不起比较 */ + const [checkupRange, setCheckupRange] = useRange("tw-checkup-range", "7d"); const { stats, since } = useUpstreamStats(); const pricing = usePricingStatus(configVersion); useAccountQuotas(ov.providers, () => void stats.reload()); @@ -311,6 +316,8 @@ export default function UpstreamsPage({ {t.newProxy} + ) : tab === "checkup" ? ( + ) : ( <>