Three new primitives — every product chat / agent surface should
adopt these to make the model honest about what it is doing.
──────────────────────────────────────────────────────────────────
<CostMeter> · Wave 13.C.1
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/CostMeter.tsx (new)
- Live token + (optional) USD readout
- 4 tiers: neutral (no budget) · ok (<70 %) · warn (>=70 %) ·
danger (>=95 %) — token-tinted via color-mix() so all four
palettes degrade gracefully on browsers without var() support
- NaN-safe — non-finite / negative inputs floor to 0
- role=status + aria-live=polite + aria-label assembles a
screen-reader-friendly sentence
- Mini-bar visual indicator at the end of the pill when budget
is provided
- Pure passive surface — never warns / prompts / blocks
──────────────────────────────────────────────────────────────────
<ConfidenceTag> · Wave 13.C.2
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/ConfidenceTag.tsx (new)
- Accepts `number | 'high' | 'medium' | 'low' | 'unknown'`
- Default thresholds 0.8 / 0.5 — both overridable
- Out-of-range numerics map to `unknown` (no false confidence)
- Optional `showScore` renders a tabular-nums percent suffix
- 4 token-tinted palettes (success / warning / danger /
neutral) — pair naturally with <CitationChip> for the full
'show your work' story
──────────────────────────────────────────────────────────────────
<RefusalCard> · Wave 13.C.3
──────────────────────────────────────────────────────────────────
packages/ai-ui/src/RefusalCard.tsx (new)
- 6 reason archetypes — safety / policy / capability /
authorization / rate-limit / unknown — each with a typed
heading + glyph
- Calm warning palette (never red) — refusals are not errors
- Up to 3 actionable next steps (further entries silently
clipped) — one is markable `primary` to render as filled CTA
- Optional `footer` slot for policy doc links
- role=note + composite aria-label covering heading +
explanation
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ pnpm -F @bytelyst/ai-ui test → 67/67 passing (was 53/53)
+14 new trust-surface tests in src/__tests__/trust.test.tsx
✓ Exports wired in src/index.ts under '0.5 surfaces' section
✓ package.json 0.4.0 → 0.5.0
──────────────────────────────────────────────────────────────────
Roadmap tracker — 5 boxes flipped (§11)
──────────────────────────────────────────────────────────────────
13.C.1 CostMeter shipped
13.C.2 ConfidenceTag shipped
13.C.3 RefusalCard shipped
13.C.7 trust-surfaces showcase (lands in paired showcase commit)
MAG.3 the trust-surfaces customer-magnet ✨
Wave 13 Futurism: 5/39 → 9/39 (23%)
Magnet demos: 1/8 → 2/8 (25%)
TOTAL: 19/202 → 24/202 (12%)
Vendored snapshot + showcase /ai-ui/* + /futurism/trust-surfaces
routes land in the paired showcase commit.
Pending in 13.C: ProvenanceDrawer (.4) · DebugOverlay (.5) ·
PrivacyBadge (.6).
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import type { CSSProperties } from 'react';
|
|
|
|
export interface CostMeterProps {
|
|
/** Tokens already consumed in this session (or invocation). */
|
|
tokensUsed: number;
|
|
/** Soft budget — UI tints amber at 70 %, red at 95 %. */
|
|
budgetTokens?: number;
|
|
/**
|
|
* Per-1k-token cost in USD. If provided, a dollar estimate renders
|
|
* alongside the token count.
|
|
*/
|
|
costPer1kUsd?: number;
|
|
/** Hide the inline `$0.0123` cost line. */
|
|
hideUsd?: boolean;
|
|
/** Override the accessible label. */
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
const fmtUsd = (n: number) =>
|
|
n >= 1
|
|
? `$${n.toFixed(2)}`
|
|
: n >= 0.01
|
|
? `$${n.toFixed(3)}`
|
|
: `$${n.toFixed(4)}`;
|
|
|
|
/**
|
|
* `<CostMeter>` — honest live token + dollar readout for any AI flow.
|
|
*
|
|
* Trust surface (Wave 13.C.1). Designed for the chat sidebar / debug
|
|
* tray pattern: tiny pill that turns amber → red as the user
|
|
* approaches the configured budget. No surprises at the end of the
|
|
* billing cycle.
|
|
*
|
|
* The component is intentionally **passive** — it never warns, prompts,
|
|
* or blocks. Surfacing the number is enough; let the host decide UX.
|
|
*/
|
|
export function CostMeter({
|
|
tokensUsed,
|
|
budgetTokens,
|
|
costPer1kUsd,
|
|
hideUsd,
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: CostMeterProps) {
|
|
const safeTokens = Math.max(0, Math.floor(tokensUsed) || 0);
|
|
const pct =
|
|
budgetTokens && budgetTokens > 0
|
|
? Math.min(1, safeTokens / budgetTokens)
|
|
: null;
|
|
|
|
const tier =
|
|
pct === null
|
|
? 'neutral'
|
|
: pct >= 0.95
|
|
? 'danger'
|
|
: pct >= 0.7
|
|
? 'warn'
|
|
: 'ok';
|
|
|
|
const tint: Record<typeof tier, { bg: string; fg: string; ring: string }> = {
|
|
neutral: {
|
|
bg: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
|
fg: 'var(--bl-text-secondary, #555)',
|
|
ring: 'var(--bl-border, rgba(0,0,0,0.12))',
|
|
},
|
|
ok: {
|
|
bg: 'color-mix(in srgb, var(--bl-success, #10b981) 14%, transparent)',
|
|
fg: 'var(--bl-success, #047857)',
|
|
ring: 'color-mix(in srgb, var(--bl-success, #10b981) 35%, transparent)',
|
|
},
|
|
warn: {
|
|
bg: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 16%, transparent)',
|
|
fg: 'var(--bl-warning, #b45309)',
|
|
ring: 'color-mix(in srgb, var(--bl-warning, #f59e0b) 35%, transparent)',
|
|
},
|
|
danger: {
|
|
bg: 'color-mix(in srgb, var(--bl-danger, #ef4444) 16%, transparent)',
|
|
fg: 'var(--bl-danger, #b91c1c)',
|
|
ring: 'color-mix(in srgb, var(--bl-danger, #ef4444) 40%, transparent)',
|
|
},
|
|
};
|
|
|
|
const usd =
|
|
costPer1kUsd !== undefined && !hideUsd
|
|
? fmtUsd((safeTokens / 1000) * costPer1kUsd)
|
|
: null;
|
|
|
|
const label =
|
|
ariaLabel ??
|
|
(budgetTokens
|
|
? `${safeTokens} of ${budgetTokens} tokens used${usd ? `, ${usd}` : ''}`
|
|
: `${safeTokens} tokens used${usd ? `, ${usd}` : ''}`);
|
|
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-label={label}
|
|
data-testid="bl-cost-meter"
|
|
data-tier={tier}
|
|
className={className}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
padding: '4px 10px',
|
|
borderRadius: 999,
|
|
backgroundColor: tint[tier].bg,
|
|
border: `1px solid ${tint[tier].ring}`,
|
|
color: tint[tier].fg,
|
|
fontSize: 12,
|
|
fontFamily:
|
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
...style,
|
|
}}
|
|
>
|
|
<span aria-hidden="true">⛽</span>
|
|
<span style={{ fontWeight: 600 }}>
|
|
{safeTokens.toLocaleString()}
|
|
{budgetTokens && (
|
|
<span style={{ opacity: 0.6 }}>
|
|
{' / '}
|
|
{budgetTokens.toLocaleString()}
|
|
</span>
|
|
)}{' '}
|
|
<span style={{ fontWeight: 400 }}>tok</span>
|
|
</span>
|
|
{usd && (
|
|
<span style={{ opacity: 0.85 }} data-testid="bl-cost-meter-usd">
|
|
{usd}
|
|
</span>
|
|
)}
|
|
{pct !== null && (
|
|
<span
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'relative',
|
|
width: 32,
|
|
height: 4,
|
|
borderRadius: 999,
|
|
backgroundColor: tint[tier].ring,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
transform: `scaleX(${pct})`,
|
|
transformOrigin: '0 0',
|
|
backgroundColor: tint[tier].fg,
|
|
transition: 'transform 240ms ease',
|
|
}}
|
|
/>
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|