This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 4842d0fbcfda8e7f021fc274bcd7ffc7d9660a72
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 22:39:13 2026 +0800

    fix(web): explain what each probe asked and what a grade means
---
 web/src/components/AgentGridCard.tsx        |  1 +
 web/src/components/AuthenticityOverview.tsx | 70 ++++++++++++++++++++++++---
 web/src/components/AuthenticityScore.tsx    | 33 ++++++++++++-
 web/src/components/OnboardingChecklist.tsx  | 75 ++++++++++++++++++++++-------
 web/src/components/ProbeCaseList.tsx        | 44 +++++++++++++++--
 web/src/components/ProviderAuditCard.tsx    | 32 ++++++++++--
 web/src/lib/authenticity.ts                 | 21 ++++++++
 web/src/locales/en/data.json                |  5 ++
 web/src/locales/zh/data.json                |  5 ++
 web/src/pages/AuthenticityPage.tsx          |  8 ++-
 web/src/pages/HomePage.tsx                  |  9 +++-
 11 files changed, 269 insertions(+), 34 deletions(-)

diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index 607415a..eef44d0 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -170,6 +170,7 @@ export function AgentGridCard({
   busy,
   runBusy,
   onEnable,
+  onLocated,
   onToggleRunning,
   onTogglePatch,
 }: {
diff --git a/web/src/components/AuthenticityOverview.tsx 
b/web/src/components/AuthenticityOverview.tsx
index 3580fc9..7098304 100644
--- a/web/src/components/AuthenticityOverview.tsx
+++ b/web/src/components/AuthenticityOverview.tsx
@@ -17,16 +17,17 @@ import {Link} from "react-router-dom";
 import {ShieldCheck} from "lucide-react";
 import i18next from "i18next";
 
+import * as ProbeCaseBackend from "@/backend/ProbeCaseBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
-import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore";
+import {GradeScaleTip, ScoreBadge, ScoreDial} from 
"@/components/AuthenticityScore";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent} from "@/components/ui/card";
 import {cn} from "@/lib/utils";
-import {gradeStyleOf, probeFindings, summarizeAuthenticity} from 
"@/lib/authenticity";
+import {checkTitle, gradeStyleOf, probeFindings, summarizeAuthenticity} from 
"@/lib/authenticity";
 import {providerIdOf} from "@/lib/providers";
-import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types";
+import type {ProbeCase, Provider, ProviderProbe, ProviderProbeMode} from 
"@/types";
 
 /** How often the scores are read again while the page is open. */
 const refreshMs = 20000;
@@ -39,18 +40,62 @@ function fill(key: string, values: Record<string, string | 
number>) {
   );
 }
 
+/**
+ * What a row says about itself in one plain sentence, so a state nobody has
+ * clicked into still reads as something. A row that failed names the cases it
+ * failed: "F" alone is a letter, not a finding.
+ */
+function stateOf(probe: ProviderProbe | undefined, cases: ProbeCase[]) {
+  if (!probe) {
+    return i18next.t("audit:Not probed yet");
+  }
+  if (!probe.ok) {
+    return i18next.t("audit:The probe could not run");
+  }
+  const {alerts, warnings} = probeFindings(probe);
+  const names = (checks: typeof alerts) => checks.map(check => 
checkTitle(check, cases)).join(", ");
+  if (alerts.length > 0) {
+    return fill("audit:Cases that failed", {names: names(alerts)});
+  }
+  if (warnings.length > 0) {
+    return fill("audit:Cases that were flagged", {names: names(warnings)});
+  }
+  return i18next.t(gradeStyleOf(probe.grade).label);
+}
+
 /** One provider's line: who it is, what it scored, and what failed if 
anything. */
-function ProviderScore({provider, probe}: {provider: Provider; probe?: 
ProviderProbe}) {
+function ProviderScore({
+  provider,
+  probe,
+  cases,
+}: {
+  provider: Provider;
+  probe?: ProviderProbe;
+  /** The suite as it stands now, which is what names a case in the reader's
+   * language rather than by the English it was stored under. */
+  cases: ProbeCase[];
+}) {
   const {alerts, warnings} = probeFindings(probe);
   const name = provider.displayName || provider.name;
 
   return (
     <Link
       
to={`/authenticity?tab=report&provider=${encodeURIComponent(providerIdOf(provider))}`}
-      className="hover:border-foreground/20 hover:bg-accent/40 flex min-w-0 
items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors"
+      className="hover:border-foreground/20 hover:bg-accent/40 flex min-w-0 
items-start gap-2 rounded-lg border px-2.5 py-1.5 transition-colors"
     >
-      <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} 
size={16} />
-      <span className="truncate text-xs">{name}</span>
+      <ProviderIcon
+        icon={provider.icon}
+        baseUrl={provider.baseUrl}
+        alt={name}
+        size={16}
+        className="mt-0.5"
+      />
+      <div className="flex min-w-0 flex-col">
+        <span className="truncate text-xs">{name}</span>
+        <span className="text-muted-foreground line-clamp-2 text-[11px] 
leading-snug">
+          {stateOf(probe, cases)}
+        </span>
+      </div>
       <div className="ml-auto flex shrink-0 items-center gap-1.5">
         {alerts.length > 0 ? (
           <Badge variant="danger">{fill("audit:Failed cases", {count: 
alerts.length})}</Badge>
@@ -80,6 +125,7 @@ export function AuthenticityOverview({
   className?: string;
 }) {
   const [probes, setProbes] = React.useState<ProviderProbe[]>([]);
+  const [cases, setCases] = React.useState<ProbeCase[]>([]);
   const [mode, setMode] = React.useState<ProviderProbeMode>("auto");
   const [loaded, setLoaded] = React.useState(false);
 
@@ -102,6 +148,14 @@ export function AuthenticityOverview({
     return () => clearInterval(interval);
   }, []);
 
+  // Read once: the suite names the cases a row failed, and it does not change
+  // while a page is open.
+  React.useEffect(() => {
+    ProbeCaseBackend.getProbeCases()
+      .then(res => setCases(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
+  }, []);
+
   const configured = providers.filter(provider => provider.status !== 
"disabled");
   const byProvider = new Map(probes.map(probe => [probe.provider, probe]));
   const scored = configured
@@ -134,6 +188,7 @@ export function AuthenticityOverview({
             <div className="flex items-center gap-1.5">
               <ShieldCheck className="text-muted-foreground size-3.5" />
               <span className="text-sm 
font-medium">{i18next.t("audit:Authenticity")}</span>
+              <GradeScaleTip />
             </div>
             <span className="text-xs">
               {summary.graded === 0
@@ -167,6 +222,7 @@ export function AuthenticityOverview({
                 key={providerIdOf(entry.provider)}
                 provider={entry.provider}
                 probe={entry.probe}
+                cases={cases}
               />
             ))}
           </div>
diff --git a/web/src/components/AuthenticityScore.tsx 
b/web/src/components/AuthenticityScore.tsx
index ca46aed..f1b196f 100644
--- a/web/src/components/AuthenticityScore.tsx
+++ b/web/src/components/AuthenticityScore.tsx
@@ -12,14 +12,45 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {HelpCircle} from "lucide-react";
 import i18next from "i18next";
 
 import {cn} from "@/lib/utils";
-import {formatScore, gradeLetter, gradeStyleOf} from "@/lib/authenticity";
+import {formatScore, gradeLetter, gradeRanges, gradeStyleOf, gradeStyles} from 
"@/lib/authenticity";
 import {Badge} from "@/components/ui/badge";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import type {ProbeGrade, ProviderProbe} from "@/types";
 
+/**
+ * Which score buys which letter. A letter on its own is a verdict nobody can
+ * check, so the scale sits behind the question mark next to every grade.
+ */
+export function GradeScaleTip({className}: {className?: string}) {
+  return (
+    <SimpleTooltip
+      className="max-w-xs"
+      title={
+        <div className="flex flex-col gap-1">
+          <span className="font-medium">{i18next.t("audit:How a score becomes 
a letter")}</span>
+          {gradeRanges().map(entry => (
+            <div key={entry.grade} className="flex items-center gap-2">
+              <span className="w-3 shrink-0 font-semibold">{entry.grade}</span>
+              <span className="w-14 shrink-0 tabular-nums 
opacity-80">{`${entry.low}–${entry.high}`}</span>
+              <span>{i18next.t(gradeStyles[entry.grade].label)}</span>
+            </div>
+          ))}
+          <span className="opacity-80">{i18next.t("audit:Score is the weighted 
share")}</span>
+        </div>
+      }
+    >
+      <HelpCircle
+        className={cn("text-muted-foreground size-3.5 shrink-0 cursor-help", 
className)}
+        aria-label={i18next.t("audit:How a score becomes a letter")}
+      />
+    </SimpleTooltip>
+  );
+}
+
 /**
  * The score as a dial. The number is the weighted share of the test cases that
  * could be measured which answered the way the vendor's own API documents, so
diff --git a/web/src/components/OnboardingChecklist.tsx 
b/web/src/components/OnboardingChecklist.tsx
index 859fffb..94b7c09 100644
--- a/web/src/components/OnboardingChecklist.tsx
+++ b/web/src/components/OnboardingChecklist.tsx
@@ -33,9 +33,13 @@ function readDismissed(): boolean {
   }
 }
 
-function persistDismissed() {
+function writeDismissed(dismissed: boolean) {
   try {
-    localStorage.setItem(dismissedStorageKey, "true");
+    if (dismissed) {
+      localStorage.setItem(dismissedStorageKey, "true");
+    } else {
+      localStorage.removeItem(dismissedStorageKey);
+    }
   } catch {
     // Private-mode storage failures must not take the page down.
   }
@@ -48,13 +52,21 @@ interface Step {
   to: string;
 }
 
+export interface Onboarding {
+  steps: Step[];
+  /** Whether the card is on screen, which is what the reopen entry hangs off. 
*/
+  open: boolean;
+  dismiss: () => void;
+  reopen: () => void;
+}
+
 /**
- * The three-step path from an empty machine to seeing traffic flow, taken
- * from the README's "Send an agent's traffic through Gateway" section. It
- * disappears once every step is done, and stays gone across reloads once
- * dismissed by hand.
+ * The three-step path from an empty machine to seeing traffic flow, taken from
+ * the README's "Send an agent's traffic through Gateway" section. It hides
+ * itself once every step is done and stays hidden once dismissed, so the state
+ * is held here rather than inside the card: something has to offer it back.
  */
-export function OnboardingChecklist({
+export function useOnboarding({
   providers,
   agents,
   stats,
@@ -62,8 +74,12 @@ export function OnboardingChecklist({
   providers: Provider[];
   agents: Agent[];
   stats: LlmAgentStat[];
-}) {
+}): Onboarding {
   const [dismissed, setDismissed] = React.useState(readDismissed);
+  // Set by the reopen entry, which shows the card again even where every step
+  // is done: someone asking for the guide is asking to read it, not to be told
+  // there is nothing left to do.
+  const [reopened, setReopened] = React.useState(false);
 
   const steps: Step[] = [
     {
@@ -85,16 +101,36 @@ export function OnboardingChecklist({
       to: "/llm-records",
     },
   ];
-  const doneCount = steps.filter(step => step.done).length;
 
-  if (dismissed || doneCount === steps.length) {
-    return null;
-  }
-
-  const dismiss = () => {
-    persistDismissed();
-    setDismissed(true);
+  return {
+    steps: steps,
+    open: reopened || (!dismissed && !steps.every(step => step.done)),
+    dismiss: () => {
+      writeDismissed(true);
+      setDismissed(true);
+      setReopened(false);
+    },
+    reopen: () => {
+      writeDismissed(false);
+      setDismissed(false);
+      setReopened(true);
+    },
   };
+}
+
+/** The entry that brings the card back, for whoever closed it. */
+export function OnboardingButton({onboarding}: {onboarding: Onboarding}) {
+  return (
+    <Button variant="ghost" onClick={onboarding.reopen}>
+      <ListChecks />
+      {i18next.t("agent:Getting started")}
+    </Button>
+  );
+}
+
+export function OnboardingChecklist({onboarding}: {onboarding: Onboarding}) {
+  const steps = onboarding.steps;
+  const doneCount = steps.filter(step => step.done).length;
 
   return (
     <Card className="gap-0 py-0 shadow-xs">
@@ -110,7 +146,12 @@ export function OnboardingChecklist({
             </div>
             <Progress value={(doneCount / steps.length) * 100} 
className="h-1.5" />
           </div>
-          <Button variant="ghost" size="icon-sm" onClick={dismiss} 
aria-label={i18next.t("general:Dismiss")}>
+          <Button
+            variant="ghost"
+            size="icon-sm"
+            onClick={onboarding.dismiss}
+            aria-label={i18next.t("general:Dismiss")}
+          >
             <X />
           </Button>
         </div>
diff --git a/web/src/components/ProbeCaseList.tsx 
b/web/src/components/ProbeCaseList.tsx
index 36c4b98..cf1b25b 100644
--- a/web/src/components/ProbeCaseList.tsx
+++ b/web/src/components/ProbeCaseList.tsx
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
+import {useLocation} from "react-router-dom";
 import {ChevronDown, FlaskConical, Pencil, Plus, RotateCcw, Trash2} from 
"lucide-react";
 import i18next from "i18next";
 
@@ -301,6 +302,7 @@ function CaseParams({probeCase}: {probeCase: ProbeCase}) {
 function CaseCard({
   probeCase,
   share,
+  lit,
   onEdit,
   onDelete,
   onToggle,
@@ -309,15 +311,30 @@ function CaseCard({
   probeCase: ProbeCase;
   /** What this case is worth of everything weighted, as a percentage. */
   share: number;
+  /** Set when a report tile linked here, which opens the case and lights it. 
*/
+  lit: boolean;
   onEdit: () => void;
   onDelete: () => void;
   onToggle: (enabled: boolean) => void;
   busy: boolean;
 }) {
-  const [open, setOpen] = React.useState(false);
+  const [open, setOpen] = React.useState(lit);
+
+  React.useEffect(() => {
+    if (lit) {
+      setOpen(true);
+    }
+  }, [lit]);
 
   return (
-    <Card className={cn(!probeCase.enabled && "opacity-60")}>
+    <Card
+      data-probe-case={probeCase.name}
+      className={cn(
+        "scroll-mt-20 transition-shadow",
+        !probeCase.enabled && "opacity-60",
+        lit && "ring-primary/60 ring-2",
+      )}
+    >
       <CardContent className="flex flex-col gap-3 p-4">
         <div className="flex flex-wrap items-center gap-2">
           <span className="text-sm font-medium">{caseTitle(probeCase)}</span>
@@ -654,7 +671,7 @@ function CaseDialog({
  * every case can be reweighted, turned off, rewritten or added to, because the
  * questions worth asking of a reseller are not the same everywhere.
  */
-export function ProbeCaseList({onChanged}: {onChanged?: () => void}) {
+export function ProbeCaseList({focus = "", onChanged}: {focus?: string; 
onChanged?: () => void}) {
   const [cases, setCases] = React.useState<ProbeCase[] | null>(null);
   const [draft, setDraft] = React.useState<ProbeCase | null>(null);
   const [creating, setCreating] = React.useState(false);
@@ -677,6 +694,26 @@ export function ProbeCaseList({onChanged}: {onChanged?: () 
=> void}) {
     load();
   }, [load]);
 
+  // A case linked to from a report tile is scrolled to and lit for a moment,
+  // otherwise the click lands on a long list that looks unchanged.
+  const [lit, setLit] = React.useState("");
+  // Keyed on the navigation, so clicking the same tile twice scrolls again.
+  const navigation = useLocation().key;
+
+  const loaded = cases !== null;
+
+  React.useEffect(() => {
+    if (focus === "" || !loaded) {
+      return;
+    }
+    document
+      .querySelector(`[data-probe-case="${CSS.escape(focus)}"]`)
+      ?.scrollIntoView({behavior: "smooth", block: "start"});
+    setLit(focus);
+    const off = setTimeout(() => setLit(""), 2500);
+    return () => clearTimeout(off);
+  }, [focus, navigation, loaded]);
+
   const done = () => {
     load();
     onChanged?.();
@@ -796,6 +833,7 @@ export function ProbeCaseList({onChanged}: {onChanged?: () 
=> void}) {
               key={probeCase.name}
               probeCase={probeCase}
               share={weighted === 0 ? 0 : (probeCase.weight / weighted) * 100}
+              lit={lit === probeCase.name}
               busy={busy === probeCase.name}
               onEdit={() => {
                 setCreating(false);
diff --git a/web/src/components/ProviderAuditCard.tsx 
b/web/src/components/ProviderAuditCard.tsx
index 51acf71..a29e651 100644
--- a/web/src/components/ProviderAuditCard.tsx
+++ b/web/src/components/ProviderAuditCard.tsx
@@ -13,18 +13,19 @@
 // limitations under the License.
 
 import * as React from "react";
+import {Link} from "react-router-dom";
 import {Stethoscope} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
-import {ScoreDial} from "@/components/AuthenticityScore";
+import {GradeScaleTip, ScoreDial} from "@/components/AuthenticityScore";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent} from "@/components/ui/card";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
-import {checkTitle, gradeStyleOf, probeFindings} from "@/lib/authenticity";
+import {checkQuestion, checkTitle, gradeStyleOf, probeFindings} from 
"@/lib/authenticity";
 import {formatCost, formatTokens} from "@/lib/usage";
 import type {
   LlmAuditCheck,
@@ -78,12 +79,18 @@ function fill(key: string, values: Record<string, string | 
number>) {
 /** One measurement, whichever side it was measured from. */
 function CheckTile({
   title,
+  question,
+  caseName,
   value,
   detail,
   level,
   weight,
 }: {
   title: React.ReactNode;
+  /** What was asked. An answer read without its question explains nothing. */
+  question?: string;
+  /** The case behind the question, which the question links to. */
+  caseName?: string;
   value: React.ReactNode;
   detail: React.ReactNode;
   level: LlmAuditLevel;
@@ -103,6 +110,20 @@ function CheckTile({
           </SimpleTooltip>
         ) : null}
       </div>
+      {question ? (
+        caseName ? (
+          <SimpleTooltip title={i18next.t("audit:See how this is judged")}>
+            <Link
+              
to={`/authenticity?tab=cases&case=${encodeURIComponent(caseName)}`}
+              className="hover:text-foreground text-foreground/75 text-xs 
leading-snug underline decoration-dotted underline-offset-2"
+            >
+              {question}
+            </Link>
+          </SimpleTooltip>
+        ) : (
+          <span className="text-foreground/75 text-xs 
leading-snug">{question}</span>
+        )
+      ) : null}
       <span className={cn("text-lg font-semibold tabular-nums", 
style.value)}>{value}</span>
       <span className="text-muted-foreground text-xs 
leading-snug">{detail}</span>
     </div>
@@ -343,7 +364,10 @@ function ScoreHeadline({probe}: {probe: ProviderProbe}) {
     <div className="bg-card flex items-center gap-4 rounded-lg border p-3">
       <ScoreDial probe={probe} size={84} />
       <div className="flex min-w-0 flex-col gap-1">
-        <span className="text-sm font-semibold">{i18next.t(style.label)}</span>
+        <div className="flex items-center gap-1.5">
+          <span className="text-sm 
font-semibold">{i18next.t(style.label)}</span>
+          <GradeScaleTip />
+        </div>
         <span className="text-muted-foreground text-xs 
leading-snug">{i18next.t(style.verdict)}</span>
         <span className="text-muted-foreground text-xs">
           {fill("audit:Score from cases", {
@@ -438,6 +462,8 @@ function ProbeSection({
               <CheckTile
                 key={`${check.case}-${check.key}-${index}`}
                 title={check.case ? checkTitle(check, cases) : 
probeTitle(check.key)}
+                question={checkQuestion(check, cases)}
+                caseName={check.case}
                 value={probeValue(check)}
                 detail={probeDetail(check, probe)}
                 level={check.level}
diff --git a/web/src/lib/authenticity.ts b/web/src/lib/authenticity.ts
index eafbf69..4a38f99 100644
--- a/web/src/lib/authenticity.ts
+++ b/web/src/lib/authenticity.ts
@@ -80,6 +80,18 @@ export const gradeStyles: Record<
   },
 };
 
+/**
+ * Every grade as the range of scores it is cut from, which is the one thing a
+ * letter cannot say about itself.
+ */
+export function gradeRanges() {
+  return gradeFloors.map((entry, index) => ({
+    grade: entry.grade,
+    low: entry.floor,
+    high: index === 0 ? 100 : gradeFloors[index - 1].floor - 1,
+  }));
+}
+
 export function gradeStyleOf(grade: ProbeGrade | undefined) {
   return gradeStyles[grade ?? "unknown"] ?? gradeStyles.unknown;
 }
@@ -177,6 +189,15 @@ export function caseMethod(probeCase: ProbeCase) {
   return localized(probeCase, "method");
 }
 
+/**
+ * The question a check was drawn from, so a tile can show what was asked and
+ * not only what came back. Empty for a report older than the stored suite.
+ */
+export function checkQuestion(check: ProbeCheck, cases: ProbeCase[]) {
+  const known = cases.find(probeCase => probeCase.name === check.case);
+  return known ? caseQuestion(known) : "";
+}
+
 /**
  * What a check was called. The name stored on the check is what the case was
  * called when it ran, which is what an old report has to keep saying; the live
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index b9c168b..d16564b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -612,6 +612,8 @@
     "Case dialog detail": "A case is one question and one way of judging the 
answer. What it is worth in the score is the weight, relative to the other 
cases measured on the same run.",
     "Case name hint": "The id this case is stored and reported under. It 
cannot be changed later.",
     "Cases description": "The suite every probe runs. Each case names the 
question it puts to the upstream and how the answer is judged, because a score 
whose method is not published is not evidence. Reweight one, turn it off, 
rewrite it, or add a question of your own.",
+    "Cases that failed": "Did not pass: {names}",
+    "Cases that were flagged": "Worth a look: {names}",
     "Channel audit": "Channel audit",
     "Check": "Check",
     "Check hint": "Which engine runs the case: what it sends and how it reads 
the answer.",
@@ -663,6 +665,7 @@
     "Hidden instructions": "Hidden instructions",
     "Hidden none": "Nothing stands above this request but what this gateway 
sent.",
     "Honored": "Honored",
+    "How a score becomes a letter": "How a score becomes a letter",
     "Identity alias": "Asked for {asked} and {answered} answered, which is the 
model this vendor documents that name as pointing at.",
     "Identity different": "Asked for {asked} and the answer named {answered}.",
     "Identity has no request": "This case sends nothing of its own: it reads 
the model name off the first answer of the run.",
@@ -747,7 +750,9 @@
     "Right": "Right",
     "Run this case": "Run this case",
     "Score from cases": "{measured} of {total} cases measured",
+    "Score is the weighted share": "The score is the weighted share of the 
cases that could be measured which answered the way the vendor's own API 
documents.",
     "Second request after": "Second request after",
+    "See how this is judged": "See what this case asks and how the answer is 
judged",
     "See the evidence": "See the evidence",
     "Self match": "It says it is {vendor}'s: {answer}",
     "Self other": "Sold as a model of {vendor}, it says it is {other}'s: 
{answer}",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index aa0fef4..e4cd473 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -612,6 +612,8 @@
     "Case dialog detail": "一条用例就是一个问题加一套判定方法。它在评分中占多少由权重决定,与同一次探测中其他被测用例相比。",
     "Case name hint": "用例存储和上报时用的 ID,之后不能修改。",
     "Cases description": 
"每次探测都会跑这套用例。每条用例都写明它向上游提了什么问题、答案怎么判定——评分方法不公开就算不上证据。你可以改权重、停用、重写,也可以自己加一条。",
+    "Cases that failed": "没通过:{names}",
+    "Cases that were flagged": "值得看一眼:{names}",
     "Channel audit": "渠道体检",
     "Check": "检查项",
     "Check hint": "由哪个引擎来跑:决定发什么请求、怎么读答案。",
@@ -663,6 +665,7 @@
     "Hidden instructions": "隐藏指令",
     "Hidden none": "这次请求上面没有本网关之外的任何东西。",
     "Honored": "生效",
+    "How a score becomes a letter": "分数和等级怎么对应",
     "Identity alias": "请求的是 {asked},回答的是 {answered},而这正是该厂商在文档里写明这个名字指向的模型。",
     "Identity different": "请求的是 {asked},返回里写的是 {answered}。",
     "Identity has no request": "这条用例不单独发请求:它读的是本次探测第一个回答里的模型名。",
@@ -747,7 +750,9 @@
     "Right": "答对",
     "Run this case": "运行这条用例",
     "Score from cases": "{total} 条用例中测得 {measured} 条",
+    "Score is the weighted share": "分数是可测到的用例里、按厂商自己 API 文档作答的那部分的加权占比。",
     "Second request after": "第二次请求间隔",
+    "See how this is judged": "看看这道题问的是什么、怎么判对错",
     "See the evidence": "查看证据",
     "Self match": "它说自己出自 {vendor}:{answer}",
     "Self other": "卖的是 {vendor} 的模型,它却说自己出自 {other}:{answer}",
diff --git a/web/src/pages/AuthenticityPage.tsx 
b/web/src/pages/AuthenticityPage.tsx
index 6b6121a..7e00ba1 100644
--- a/web/src/pages/AuthenticityPage.tsx
+++ b/web/src/pages/AuthenticityPage.tsx
@@ -41,6 +41,8 @@ export default function AuthenticityPage({account}: {account: 
Account}) {
   const tab: AuthenticityTab = searchParams.get("tab") === "cases" ? "cases" : 
"report";
   // Set when the overview above was clicked, so the report opens at that card.
   const focus = searchParams.get("provider") ?? "";
+  // Set when a report tile was clicked, so the suite opens at the case it 
asks.
+  const caseFocus = searchParams.get("case") ?? "";
   const setTab = (next: AuthenticityTab) =>
     setSearchParams(
       previous => {
@@ -80,7 +82,11 @@ export default function AuthenticityPage({account}: 
{account: Account}) {
         </TabsList>
       </Tabs>
 
-      {tab === "report" ? <ProviderAuditPanel owner={account.name} 
focus={focus} /> : <ProbeCaseList />}
+      {tab === "report" ? (
+        <ProviderAuditPanel owner={account.name} focus={focus} />
+      ) : (
+        <ProbeCaseList focus={caseFocus} />
+      )}
     </PageContainer>
   );
 }
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index faca348..d52c8b7 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -24,7 +24,7 @@ import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {AuthenticityOverview} from "@/components/AuthenticityOverview";
 import {AgentGridCard} from "@/components/AgentGridCard";
-import {OnboardingChecklist} from "@/components/OnboardingChecklist";
+import {OnboardingButton, OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
 import {EmptyState, ErrorState} from "@/components/shared/empty-state";
 import {Loading} from "@/components/shared/loading";
 import {UnauthorizedResult} from "@/components/shared/misc";
@@ -189,6 +189,10 @@ export default function HomePage({account}: {account: 
Account}) {
     return () => clearInterval(interval);
   }, [isAdmin]);
 
+  // Held here rather than inside the card, so the header can offer the guide
+  // back to whoever closed it.
+  const onboarding = useOnboarding({providers: providers, agents: agents, 
stats: stats});
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -214,6 +218,7 @@ export default function HomePage({account}: {account: 
Account}) {
         description={account.hostname}
         actions={
           <>
+            {loaded && !onboarding.open ? <OnboardingButton 
onboarding={onboarding} /> : null}
             <Button asChild variant="ghost">
               <Link to="/agents">
                 <Table2 />
@@ -225,7 +230,7 @@ export default function HomePage({account}: {account: 
Account}) {
         }
       />
 
-      {loaded ? <OnboardingChecklist providers={providers} agents={agents} 
stats={stats} /> : null}
+      {loaded && onboarding.open ? <OnboardingChecklist 
onboarding={onboarding} /> : null}
 
       {/* With no agents to show, the failure is the whole page and is rendered
           below instead; here it sits above the cards that are still on 
screen. */}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to