diff --git a/README.md b/README.md index 8258680d..824f6085 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,10 @@ before the client runs them. The content filter deletes instructions hidden in invisible characters before a request leaves and can refuse prompt injection. Each protection starts in Observe, which only records, and is switched over one at a time. -- **Upstream check-up.** Each upstream is compared with the others serving the - same model: answers naming a different model, reported input well above or - below theirs and low prompt-cache reads are marked, with sample sizes. +- **Upstreams, cross-checked.** Each upstream is compared with the others + serving the same model: answers naming a different model, reported input well + above or below theirs and low prompt-cache reads are marked on its row in the + upstream list, with the evidence and sample sizes on hover. - **MCP servers, skills and hooks, scanned.** The MCP servers of thirteen clients side by side, with third-party servers marked, and a scan of client configuration, skills, hooks and project instructions for hidden characters, diff --git a/README.zh-CN.md b/README.zh-CN.md index c218db7c..41633eb5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,7 +32,7 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 - **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。 - **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT、连接串口令、身份证号与银行卡号换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答。内容过滤在请求发出前删除藏在不可见字符里的指令,也可以直接拒绝提示注入。各项防护出厂只记录,逐项切换后生效。 -- **上游体检。** 每个上游都与服务同一模型的其他上游对照:回答中的模型名与发出的不同、报告的输入明显偏多或偏少、提示缓存读取偏低,都会标出,并附样本数。 +- **上游互相对照。** 每个上游都与服务同一模型的其他上游对照:回答中的模型名与发出的不同、报告的输入明显偏多或偏少、提示缓存读取偏低,都会在上游列表的那一行标出,悬停可见证据与样本数。 - **扫描 MCP、技能与钩子。** 十三款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。 - **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。全部请求记录都可以搜索,包括请求与回答的内容。 - **按规则分流,失败自动换。** 按模型、工具、图片、扩展思考等条件分流。回答开始前上游出错时换用下一个,同一会话固定使用同一上游,提示缓存保持有效。标题生成等辅助请求可在本地应答。 diff --git a/src/upstreams/Checkup.i18n.ts b/src/upstreams/Checkup.i18n.ts deleted file mode 100644 index 6c5bbc4d..00000000 --- a/src/upstreams/Checkup.i18n.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index e1d40c6d..00000000 --- a/src/upstreams/CheckupTab.tsx +++ /dev/null @@ -1,361 +0,0 @@ -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/UpstreamTable.i18n.ts b/src/upstreams/UpstreamTable.i18n.ts index 99b85be6..83566877 100644 --- a/src/upstreams/UpstreamTable.i18n.ts +++ b/src/upstreams/UpstreamTable.i18n.ts @@ -6,6 +6,9 @@ function rate(v: number): string { return Number.isInteger(r) ? String(r) : r.toFixed(1); } +/** 英文的单复数 */ +const count = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`; + export const upstreamTableText = messages( { upstream: "上游", @@ -24,6 +27,23 @@ export const upstreamTableText = messages( `上游返回未授权(${status}),该上游的凭据可能已失效。`, needsLogin: "需要重新登录", needsLoginTip: "账号的登录已失效。重新登录之前,经此上游的请求都会失败。", + // 体检标出的偏差(最近 7 天,规则在 checkup.ts)。**只说事实和参照**:「12 次不同」 + // 「+31%」,不说「虚报」「掺假」 —— 标出来是请人看一眼,不是替人判断 + modelDiffers: "模型名不符", + inputHigh: "输入 token 偏多", + inputLow: "输入 token 偏少", + cacheLow: "缓存读取偏低", + modelDiffersTip: (n: number, of: number) => + `最近 7 天的 ${of.toLocaleString()} 次回答中,${n.toLocaleString()} 次写的模型名与发出的不同:`, + modelPair: (sent: string, answered: string, n: number) => + `发出 ${sent},回答 ${answered} · ${n.toLocaleString()} 次`, + inputTip: "最近 7 天,上游报告的输入 token 为本地估算的倍数,与服务同一模型的其他上游相比:", + inputLine: (model: string, gap: string, here: string, hereN: number, k: number, others: string, othersN: number) => + `${model} ${gap}:本上游 ${here}(${hereN.toLocaleString()} 次)· 其他 ${k} 个上游 ${others}(${othersN.toLocaleString()} 次)`, + ratio: (x: number) => `${x.toFixed(2)} 倍`, + cacheTip: "最近 7 天,可命中缓存的轮次中输入从缓存读取的比例,与服务同一模型的其他上游相比:", + cacheLine: (model: string, here: string, hereN: number, k: number, others: string, othersN: number) => + `${model}:本上游 ${here}(${hereN.toLocaleString()} 轮)· 其他 ${k} 个上游 ${others}(${othersN.toLocaleString()} 轮)`, inFlight: (n: number) => `${n} 个请求进行中`, actions: (name: string) => `${name} 的操作`, check: "检测连接", @@ -73,6 +93,23 @@ export const upstreamTableText = messages( needsLogin: "Sign in again", needsLoginTip: "The account's sign-in has expired. Until the account is signed in again, requests through this upstream will fail.", + modelDiffers: "Model name differs", + inputHigh: "Input reported high", + inputLow: "Input reported low", + cacheLow: "Low cache reads", + modelDiffersTip: (n: number, of: number) => + `Over the last 7 days, in ${n.toLocaleString()} of ${count(of, "answer", "answers")}, the model named differs from the one sent:`, + modelPair: (sent: string, answered: string, n: number) => + `Sent ${sent}, answered ${answered} · ${n.toLocaleString()}×`, + inputTip: + "Over the last 7 days, input tokens reported by the upstream as a multiple of the local estimate, compared with other upstreams serving the same model:", + inputLine: (model: string, gap: string, here: string, hereN: number, k: number, others: string, othersN: number) => + `${model} ${gap}: this upstream ${here} (${count(hereN, "request", "requests")}) · ${count(k, "other upstream", "other upstreams")} ${others} (${count(othersN, "request", "requests")})`, + ratio: (x: number) => `${x.toFixed(2)}×`, + cacheTip: + "Over the last 7 days, the share of input read from the prompt cache over turns that could read it, compared with other upstreams serving the same model:", + cacheLine: (model: string, here: string, hereN: number, k: number, others: string, othersN: number) => + `${model}: this upstream ${here} (${count(hereN, "turn", "turns")}) · ${count(k, "other upstream", "other upstreams")} ${others} (${count(othersN, "turn", "turns")})`, inFlight: (n: number) => (n === 1 ? "1 request in progress" : `${n} requests in progress`), actions: (name: string) => `Actions for ${name}`, check: "Check connection", diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index 66f1f528..41451532 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import type { Resource } from "@/lib/resource"; import { Badge } from "@/ui/badge"; @@ -14,8 +14,9 @@ import { resetAt } from "@/format"; import { useNow } from "@/useNow"; import { textOf, useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; -import { usd, type ProviderView, type QuotaWindow } from "@/types"; +import { usd, type ProviderView, type QuotaWindow, type UpstreamHealth } from "@/types"; import type { UpstreamStats } from "./api"; +import { discrepancies, pct, signedPct, type Discrepancies } from "./checkup"; import { slotsByUpstream, type Slot } from "./data"; import { billingLabel, @@ -60,6 +61,9 @@ export interface UpstreamActions { * * 单击一行(或 Enter)打开它的编辑对话框;行尾按钮和右键打开的是同一份操作。 * + * 名字旁边的状态只在异常时出现:先是转发不了的(停用、凭据、熔断),再是最近 7 天的 + * 体检标出的偏差(模型名、输入 token、缓存读取),证据在悬停里。 + * * 五列是定过的(lite#46):上游、模型、额度 / 计费、24 小时、首 token / 速度。「24 小时」 * 一格里多了一条按小时的走势:请求在一天里怎么分布、失败落在哪几个小时,一眼看得出, * 数字留给右边和悬停。窗口窄到放不下时走势先让位(页面的容器查询),数字照常在。 @@ -67,6 +71,7 @@ export interface UpstreamActions { export function UpstreamTable({ providers, stats, + health, since, inFlight, refreshing, @@ -75,6 +80,8 @@ export function UpstreamTable({ }: { providers: ProviderView[]; stats: Resource; + /** 最近 7 天的体检。还没取到、取不到时不标偏差 */ + health: UpstreamHealth | undefined; /** 走势第一格的起点(对齐到整点) */ since: number; /** 此刻每个上游在途的请求数 */ @@ -90,6 +97,10 @@ export function UpstreamTable({ const now = useNow(); const shown = usePresentList(providers, (p) => p.name); const slots = useMemo(() => slotsByUpstream(stats.data?.buckets ?? undefined, since), [stats.data, since]); + const found = useMemo( + () => new Map(health?.upstreams.map((c) => [c.upstream, discrepancies(c)])), + [health], + ); const [flash, setFlash] = useState(null); useEffect(() => { if (!focus) return; @@ -129,6 +140,7 @@ export function UpstreamTable({ > 0 && !p.disabled} liveCount={inFlight.get(p.name) ?? 0} /> @@ -173,16 +185,23 @@ function menu(p: ProviderView, a: UpstreamActions): MenuItems { ]; } +/** 名字旁边的一条状态:几条同时成立时写第一条,悬停列出全部 */ +interface Note { + tone: StatusTone; + label: string; + tip: ReactNode; +} + /** * 这一行的状态。**只在异常时出现**:每行都写一遍「正常」是噪声。 * * 熔断看不见凭据的问题(4xx 不算失败,凭据坏掉的上游永远不会熔断),所以凭据被拒、 * 登录失效要自己说。几件同时成立时说最要紧的那一件,其余在悬停里。 */ -export function problemsOf(p: ProviderView): { tone: StatusTone; label: string; tip: string }[] { +export function problemsOf(p: ProviderView): Note[] { const t = textOf(upstreamTableText); if (p.disabled) return [{ tone: "idle", label: t.disabled, tip: t.disabledTip }]; - const out: { tone: StatusTone; label: string; tip: string }[] = []; + const out: Note[] = []; if (p.oauth?.needs_login) out.push({ tone: "error", label: t.needsLogin, tip: t.needsLoginTip }); if (p.auth_rejected != null) out.push({ tone: "error", label: t.authRejected, tip: t.authRejectedTip(p.auth_rejected) }); @@ -190,13 +209,94 @@ export function problemsOf(p: ProviderView): { tone: StatusTone; label: string; return out; } +/** + * 体检标出的偏差,一处一条,琥珀色。**不是故障**:转发照常,页头也不算进「要处理」。 + * 模型名不同的证据最直接,排第一;输入和缓存都是和服务同一模型的别家比出来的。 + */ +function discrepancyNotes(d: Discrepancies | undefined): Note[] { + if (!d) return []; + const t = textOf(upstreamTableText); + const out: Note[] = []; + if (d.models) { + const m = d.models; + out.push({ + tone: "warn", + label: t.modelDiffers, + tip: ( + <> +

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

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

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

+ ))} + + ), + }); + } + // 差得最多的那个模型定方向;几个模型多少不一时,悬停里各写各的正负 + const top = d.input[0]; + if (top) { + out.push({ + tone: "warn", + label: top.gap > 0 ? t.inputHigh : t.inputLow, + tip: ( + <> +

{t.inputTip}

+ {d.input.map((g) => ( +

+ {t.inputLine( + g.model, + signedPct(g.gap), + t.ratio(g.here.median), + g.here.samples, + g.otherUpstreams, + t.ratio(g.others.median), + g.others.samples, + )} +

+ ))} + + ), + }); + } + if (d.cache.length > 0) { + out.push({ + tone: "warn", + label: t.cacheLow, + tip: ( + <> +

{t.cacheTip}

+ {d.cache.map((g) => ( +

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

+ ))} + + ), + }); + } + return out; +} + /** * 名称一格:标志、名字、套餐与状态,下面一行是协议、地址、出站方式 —— 这一家 * 在哪、怎么连。地址可能很长(带路径的中转):截断,悬停看全。 */ -function NameCell({ p, live, liveCount }: { p: ProviderView; live: boolean; liveCount: number }) { +function NameCell({ + p, + found, + live, + liveCount, +}: { + p: ProviderView; + found: Discrepancies | undefined; + live: boolean; + liveCount: number; +}) { const t = useText(upstreamTableText); - const problems = problemsOf(p); + // 停用的上游只说「已停用」:它不再转发,过去 7 天的偏差这时不是要看的事 + const problems = p.disabled ? problemsOf(p) : [...problemsOf(p), ...discrepancyNotes(found)]; const problem = problems[0]; const plan = planLabel(p.oauth?.account?.plan); const tile = ; @@ -226,9 +326,13 @@ function NameCell({ p, live, liveCount }: { p: ProviderView; live: boolean; live {problem && ( +
{problems.map((x) => ( -

{x.tip}

+
+ {/* 几条叠在一起时各带标题:行上只写得下第一条,其余的名字只在这里 */} + {problems.length > 1 &&

{x.label}

} + {typeof x.tip === "string" ?

{x.tip}

: x.tip} +
))}
} diff --git a/src/upstreams/UpstreamsPage.i18n.tsx b/src/upstreams/UpstreamsPage.i18n.tsx index 8e7fd690..448093b8 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: "价目表", checkup: "体检" }, + tabs: { upstreams: "上游", proxies: "代理", pricing: "价目表" }, 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", checkup: "Check-up" }, + tabs: { upstreams: "Upstreams", proxies: "Proxies", pricing: "Price sheets" }, linkTest: "Connection test", speedTest: "Inference test", newUpstream: "New upstream", diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index fd9c8fa6..e1055eda 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -14,7 +14,6 @@ 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"; @@ -22,8 +21,15 @@ 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 { + patch, + statsPartial, + useAccountQuotas, + useInFlight, + usePricingStatus, + useUpstreamHealth, + useUpstreamStats, +} from "./data"; import { DeleteDialog, type Referrer } from "./DeleteDialog"; import { coreText, errorText, plain } from "./labels"; import { PriceSheetDialog, type PriceSheetDialogMode } from "./PriceSheetDialog"; @@ -43,7 +49,7 @@ import { AliasDialog } from "@/aliases/AliasDialog"; import { aliasesText } from "@/aliases/aliases.i18n"; import type { AliasInput } from "@/types"; -export type UpstreamTab = "upstreams" | "aliases" | "proxies" | "pricing" | "checkup"; +export type UpstreamTab = "upstreams" | "aliases" | "proxies" | "pricing"; /** * 这次打开应用以来最后看的那个标签。切到别的页再回来,还停在那一个。 @@ -69,13 +75,14 @@ type DialogState = | { kind: "alias"; mode: AliasDialogMode }; /** - * 上游页:上游、别名、代理、价目表、体检五个标签。 + * 上游页:上游、别名、代理、价目表四个标签。 * * 上游、代理、价目表描述出站侧的三个方面 —— 请求发往哪个服务、经过哪条网络路径、 * 按什么价格结算 —— 而代理和价目表只被上游引用,所以放在同一页里,引用关系在 * 页内闭合。别名是同一个模型在各个上游的不同名称,也只和上游的模型清单打交道。 * **列表只读**,新建与编辑都在对话框里完成,一次保存一个版本。 - * 体检回答的是「这些上游实际表现如何」:一段时间里各上游的几项事实和相互参照。 + * 「这些上游实际表现如何」不另起一个标签:最近 7 天体检标出的偏差直接写在上游表那一行, + * 判断一个上游去留的地方就看得到证据。 * * 页头的摘要说上游的整体状况:几个、几个正常、几个要处理,24 小时的请求与费用。 */ @@ -103,9 +110,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 health = useUpstreamHealth(); const pricing = usePricingStatus(configVersion); useAccountQuotas(ov.providers, () => void stats.reload()); const inFlight = useInFlight(); @@ -331,8 +337,6 @@ export default function UpstreamsPage({ {t.newProxy} - ) : tab === "checkup" ? ( - ) : ( <>