AI

Logprobs Viewer

Token-level probabilities for a model response — the answer itself heat-tinted by uncertainty on one chart token, dotted-underlined below a live threshold, with top-k alternatives as probability bars on hover, focus or arrow key.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleAlert, RefreshCcw, ScanText } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  LogprobsViewerCandidate,
  LogprobsViewerData,
  LogprobsViewerStatus,
  LogprobsViewerToken,
} from "./logprobs-viewer.contract"

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/logprobs-viewer.json

Prompt

Build a React + TypeScript + Tailwind "LogprobsViewer" component with zod and
lucide-react. It renders a model response as an inspectable heat strip of its own
token probabilities. No charting library: the bars are divs, the tint is one CSS
color-mix.

Contract
- A zod schema in a sibling contract file is the single source of truth.
  Token: { id: string; text: string; logprob: number; topK: { token: string;
  prob: number }[] }.
- `text` is the RAW surface form and is never trimmed: " the" and "the" are
  different tokens, and the leading space is what makes the strip line up with
  the answer the user actually read. Newlines and tabs are legal token text.
- `logprob` is the natural log of the CHOSEN token's probability (<= 0, finite —
  zod rejects Infinity, and providers cap the floor near -9.99). `topK[].prob` is
  LINEAR and in [0,1], because that is the number a bar's width is drawn from; a
  bar whose width is a log value is a bar that lies. Exponentiate exactly once,
  in one helper, and document the asymmetry instead of hiding it.
- `topK` normally CONTAINS the chosen token — that is how the panel can say the
  sampler took rank 3. It may also be empty (logprobs requested without
  top_logprobs): a first-class case, not a bug.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  viewer's own state. "empty" means logprobs were not requested for this
  response, which is an ordinary answer, not a failure; "error" means the run
  record failed to load.
- Props: tokens; status; defaultThreshold? (0.6) / threshold? / onThresholdChange?
  (controlled and uncontrolled, threshold is a LINEAR probability in [0,1]);
  showThreshold? showLegend? showSummary? (all true); tint?: "uncertainty" |
  "probability" ("uncertainty"); heatIntensity? (0.62); topK? (5, clamped to >= 1);
  onTokenSelect?(token, index); formatProbability?; emptyState?; errorMessage?;
  onRetry?; label? ("Token probabilities"); className plus the rest of the
  element props, ref forwarded to the <section>.
- Export the exp() helper too — consumers computing their own summaries must not
  re-derive it differently.

Behavior
- THE TINT IS AN ALPHA RAMP ON ONE COLOUR. weight = 1 - p by default
  ("uncertainty": confident text stays clean, risky tokens burn), or p when
  tint="probability". alpha = intensity * weight ** 0.65, painted as
  `color-mix(in oklab, var(--chart-1) N%, transparent)`. A green-to-red hue ramp
  would carry the whole reading in the one channel ~8% of men cannot separate;
  gamma 0.65 lifts the middle of the range because real probabilities pile up
  against 1. Below 0.5% mix, paint nothing.
- A SECOND, NON-COLOUR CHANNEL: tokens whose probability is under the threshold
  get a dotted underline (decoration-destructive), so the flagged set survives
  greyscale, a monochrome theme and a colour-blind reader. The token's aria-label
  carries the same fact in words.
- The threshold is a QUESTION THE READER ASKS, not a verdict shipped with the
  data: a hand-rolled role="slider" (arrows +-1%, PageUp/PageDown +-10%,
  Home/End, pointer-capture drag) moves it live, and the summary line restates
  "N below 60%" as it moves. Controlled and uncontrolled both work.
- ALTERNATIVES ON HOVER **OR** FOCUS. Keep hoverIndex and focusIndex in refs
  written only inside event handlers; the open token is `hover ?? focus`. So the
  pointer leaving the strip falls back to the focused token instead of blindly
  closing, and blurring falls back to the hovered one. Escape stores the
  dismissed index and hides only that panel; moving to another token, or
  re-entering this one, revives it.
- The panel is POSITION-MEASURED, not CSS-anchored: read the token's FIRST line
  box (getClientRects()[0], never getBoundingClientRect — a token that wraps has
  a bounding box as wide as the paragraph), centre the panel on it, clamp it
  inside the strip, and flip it above the line when the viewport has no room
  below. Measure in a layout effect so the placement lands before paint; render
  the first commit with visibility:hidden so the panel's own height can be read.
  The panel is pointer-events-none — it overlaps the tokens beneath it, and a
  panel that could take the pointer would steal the hover from the token that
  opened it and flicker forever.
- KEYBOARD IS A FIRST CLASS PATH: the strip is role="listbox" with roving
  tabindex (exactly one token is a tab stop). Left/Right walk tokens; Up/Down
  move a VISUAL line — wrapped prose makes "the token below" a geometry
  question, so find the nearest line box with a different top, then the nearest
  horizontal centre on it. Home/End jump to the ends, Enter/Space fire
  onTokenSelect, and a "Jump to least certain" button focuses the weakest token
  outright.
- Whitespace IS the layout: the strip is whitespace-pre-wrap and each token
  renders its raw text, so the model's own spaces and newlines lay the paragraph
  out. A newline token additionally draws a visible glyph, and a zero-length
  token draws a placeholder mark — a token you cannot see is a token you cannot
  inspect.
- Percentages TRUNCATE, never round up: 99.97% must not print as "100%", and a
  4e-7 probability prints as "at most 0.01%" rather than a flat "0%". The only
  100% is a real 1. The summary line adds mean logprob and perplexity
  (exp(-mean)) — one number for how surprised the model was by its own answer.
- Identity resets: the selection, the panel and the roving anchor reset when the
  FIRST token's id changes (a new response), not when the array identity changes
  — a streaming answer hands over a new array on every appended token, and
  resetting on that would both wipe the reader's selection mid-stream and spin
  forever on a parent that builds its tokens inline.
- Defensive maths everywhere: a non-finite logprob reads as 0, probabilities are
  clamped into [0,1], topK is sliced to a clamped limit, and `status="ready"`
  with zero tokens renders the empty branch instead of an empty box.
- Cleanup: the only subscription is one ResizeObserver on the strip (the panel's
  coordinates depend on the wrap), disconnected on unmount. The slider uses
  pointer capture instead of window listeners, so there is nothing else to
  unbind.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  var(--chart-1) for the tint ramp and the chosen candidate's bar, bg-muted plus
  bg-muted-foreground/40 for the other bars, text-destructive and
  decoration-destructive for the below-threshold channel, ring-ring for focus and
  for the open token. No hex, rgb() or oklch() literals anywhere.
- The strip is font-mono with generous leading so the tint blocks do not collide
  between lines; tokens are inline (never inline-block), so the highlight follows
  the text's own line boxes.
- Every transition is motion-reduce guarded and the skeleton's pulse stops under
  prefers-reduced-motion; nothing about the component needs animation to work.
- a11y: role="listbox" + role="option" + aria-selected + roving tabindex; each
  token's aria-label is the quoted token text, its percentage and, when flagged,
  "low confidence" — a screen reader gets the data the tint carries. The open
  token points at the panel with aria-describedby, and the panel opens with an
  sr-only sentence that spells out the whole distribution while the bars
  themselves are aria-hidden. The loading branch is aria-busy with an sr-only
  status; the error branch is role="alert".
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Reading direction: `tint` flips what the ink means (uncertainty vs
  probability) without touching the data; `heatIntensity` sets how loud the strip
  gets (0.3 for a transcript you mostly read, 0.9 for triage).
- Density: `showSummary`, `showThreshold` and `showLegend` each drop a block —
  turn all three off for a compact inline strip inside a chat message, keep them
  for a debugging surface.
- Policy: `defaultThreshold` (or the controlled `threshold`) decides what counts
  as low confidence; wire `onThresholdChange` to persist it per user. `topK`
  decides how many alternatives the panel draws.
- Wording: `formatProbability` swaps percentages for logprobs, bits, or a
  localised format — it drives the bars, the legend, the summary and the
  slider's aria-valuetext at once, so there is exactly one place to change it.
- Chrome: `label` names the region, `emptyState` replaces the "not requested"
  body, `errorMessage` + `onRetry` own the envelope failure, and `onTokenSelect`
  is where you hang your own action (open the eval row, copy the position, seed
  a re-run from here).
- Structure to keep: the exp()-once helper, the truncating formatter, the
  hover-or-focus rule and the first-line-box measurement. Those four are what
  make the numbers honest and the panel land where the token is.

Concepts

  • Uncertainty is the ink — the strip tints what the model was least sure of, so the eye lands on the risky span instead of on the 200 confident tokens around it. Flip tint and the same data answers the opposite question ("what was it sure about?") without a second component.
  • One hue, two channels — the ramp is alpha on a single chart token, never green-to-red, because a hue ramp puts the entire reading in the channel colour-blind readers lose. The dotted underline below the threshold is the second, non-colour channel, and the token's aria-label is the third.
  • The threshold is a question, not a verdict — "low confidence" is not a property the provider ships; it is where the reader decides to draw the line. Dragging the slider re-flags the strip live and the summary restates the count, so the reader can find the cut point that separates real doubt from ordinary decoding noise.
  • Hover or focus, never both fighting — the open panel is hover ?? focus: the pointer leaving the strip falls back to the focused token rather than closing, and Escape dismisses only the index it was on. The panel itself takes no pointer events, which is what stops the classic open-close flicker when a popover covers the thing that opened it.
  • Arrow keys read wrapped prose as geometry — a heat strip is a paragraph, so "the token below this one" cannot be an array index. Up/Down find the nearest different line box and then the nearest horizontal centre on it, which is what makes a 400-token answer walkable without a mouse.
  • Sampled rank is the real finding — a token at 17% that was rank 3 in its own distribution says something a percentage alone does not: temperature, not the model, chose this word. The panel puts the chosen token's rank next to the bars, and says so plainly when the sampler landed outside the returned top-k.

On This Page