@linxiraos/pi-stats 1.1.27 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +10 -7
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +5 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +12 -0
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +4 -4
  51. package/src/aggregator.ts +28 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +73 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +87 -71
  90. package/src/client/routes/OverviewRoute.tsx +107 -62
  91. package/src/client/routes/ProjectsRoute.tsx +71 -45
  92. package/src/client/routes/ProvidersRoute.tsx +149 -113
  93. package/src/client/routes/RequestsRoute.tsx +68 -45
  94. package/src/client/routes/ToolsRoute.tsx +105 -92
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +4 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/live.ts +36 -11
  115. package/src/locale.ts +29 -0
  116. package/src/parser.ts +37 -72
  117. package/src/port-conflict.ts +1 -1
  118. package/src/prompts/judge/frustration.json +19 -0
  119. package/src/rollup.ts +41 -20
  120. package/src/server.ts +67 -27
  121. package/src/shared-types.ts +7 -0
  122. package/src/trace.ts +54 -40
  123. package/src/types.ts +12 -0
  124. package/src/user-metrics.ts +220 -6
@@ -7,6 +7,7 @@ import {
7
7
  startFrustrationRun,
8
8
  } from "../api";
9
9
  import { Chart, type ChartSeries, Legend, useHiddenSeries } from "../charts";
10
+ import { t } from "../i18n";
10
11
  import { SERIES_COLORS } from "../data/colors";
11
12
  import { formatCost, formatInteger, formatPercent } from "../data/formatters";
12
13
  import { useQuery } from "../data/query";
@@ -53,7 +54,7 @@ const ALL_CLASSES = "*";
53
54
 
54
55
  export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
55
56
  const [running, setRunning] = useState(false);
56
- const stats = useQuery(["frustration", range], () => getFrustrationDashboardStats(range), {
57
+ const stats = useQuery(["frustration", range], ({ signal }) => getFrustrationDashboardStats(range, signal), {
57
58
  enabled: active,
58
59
  pollMs: running ? RUNNING_POLL_MS : undefined,
59
60
  });
@@ -73,8 +74,8 @@ export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
73
74
  return (
74
75
  <div className="page">
75
76
  <PageHeader
76
- title="Frustration"
77
- description={`How often your messages sound annoyed, per model version, in ${meta.windowLabel}.`}
77
+ title={t("frustration.title")}
78
+ description={t("frustration.description", { window: t(meta.windowKey) })}
78
79
  />
79
80
 
80
81
  <QueryView query={stats} skeleton={<ChartSkeleton height={96} />}>
@@ -101,7 +102,7 @@ export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
101
102
  <QueryView
102
103
  query={stats}
103
104
  skeleton={
104
- <Card index={2} title="Frustration by model version">
105
+ <Card index={2} title={t("frustration.by-model")}>
105
106
  <ChartSkeleton height={340} />
106
107
  <TableSkeleton rows={6} />
107
108
  </Card>
@@ -135,38 +136,45 @@ function formatElapsed(ms: number): string {
135
136
 
136
137
  function FrustrationSummary({ overall }: { overall: FrustrationCounts }) {
137
138
  const regex = overall.messages - overall.judged;
139
+ const unscored =
140
+ overall.unscored > 0 ? t("frustration.coverage-unscored", { count: formatInteger(overall.unscored) }) : "";
138
141
  return (
139
- <StatGrid min={170}>
140
- <Stat
141
- label="User messages"
142
- title="User messages with prose in range"
143
- value={formatInteger(overall.messages)}
144
- />
145
- <Stat
146
- label="Judge coverage"
147
- title="Messages with a cached judge verdict; the rest use regex signals"
148
- value={rate(overall.judged, overall.messages)}
149
- hint={`${formatInteger(overall.judged)} judged · ${formatInteger(regex)} regex`}
150
- />
151
- <Stat
152
- label="Annoyed"
153
- title="Messages annoyed at any target"
154
- value={rate(overall.annoyed, overall.messages)}
155
- hint={`${formatInteger(overall.annoyed)} messages`}
156
- />
157
- <Stat
158
- label="At assistant"
159
- title="Messages annoyed at the assistant"
160
- value={rate(overall.atAssistant, overall.messages)}
161
- hint={`${formatInteger(overall.atAssistant)} messages`}
162
- />
163
- <Stat
164
- label="Angry"
165
- title="Messages angry or hostile toward the assistant"
166
- value={rate(overall.angry, overall.messages)}
167
- hint={`${formatInteger(overall.angry)} messages`}
168
- />
169
- </StatGrid>
142
+ <>
143
+ <StatGrid min={170}>
144
+ <Stat
145
+ label={t("frustration.user-messages")}
146
+ title={t("frustration.user-messages-title")}
147
+ value={formatInteger(overall.messages)}
148
+ />
149
+ <Stat
150
+ label={t("frustration.judge-coverage")}
151
+ title={t("frustration.judge-coverage-title")}
152
+ value={rate(overall.judged, overall.messages)}
153
+ hint={`${t("frustration.coverage-hint", { judged: formatInteger(overall.judged), regex: formatInteger(regex) })}${unscored}`}
154
+ />
155
+ <Stat
156
+ label={t("frustration.annoyed")}
157
+ title={t("frustration.annoyed-title")}
158
+ value={rate(overall.annoyed, overall.messages)}
159
+ hint={t("frustration.messages-n", { count: formatInteger(overall.annoyed) })}
160
+ />
161
+ <Stat
162
+ label={t("frustration.at-assistant")}
163
+ title={t("frustration.at-assistant-title")}
164
+ value={rate(overall.atAssistant, overall.messages)}
165
+ hint={t("frustration.messages-n", { count: formatInteger(overall.atAssistant) })}
166
+ />
167
+ <Stat
168
+ label={t("frustration.angry")}
169
+ title={t("frustration.angry-title")}
170
+ value={rate(overall.angry, overall.messages)}
171
+ hint={t("frustration.messages-n", { count: formatInteger(overall.angry) })}
172
+ />
173
+ </StatGrid>
174
+ {overall.unscored > 0 && (
175
+ <p className="micro dim">{t("frustration.unscored-line", { count: formatInteger(overall.unscored) })}</p>
176
+ )}
177
+ </>
170
178
  );
171
179
  }
172
180
 
@@ -279,10 +287,10 @@ function JudgePanel({ active, range, judgeAvailable, job, onRunStarted, onRunCha
279
287
  index={1}
280
288
  title={
281
289
  <>
282
- Judge classification <JobBadge job={job} judgeAvailable={judgeAvailable} />
290
+ {t("frustration.judge-panel")} <JobBadge job={job} judgeAvailable={judgeAvailable} />
283
291
  </>
284
292
  }
285
- description="Cached LLM verdicts replace the regex heuristics; unjudged messages fall back to regex signals."
293
+ description={t("frustration.judge-description")}
286
294
  actions={
287
295
  <button
288
296
  type="button"
@@ -295,11 +303,11 @@ function JudgePanel({ active, range, judgeAvailable, job, onRunStarted, onRunCha
295
303
  !judgeAvailable
296
304
  ? UNAVAILABLE_HINT
297
305
  : running
298
- ? "A judge run is in progress"
299
- : "Quote the cost of judging every unjudged message in range"
306
+ ? t("frustration.judge-running")
307
+ : t("frustration.quote-title")
300
308
  }
301
309
  >
302
- <Sparkles size={13} /> Classify with judge
310
+ <Sparkles size={13} /> {t("frustration.classify")}
303
311
  </button>
304
312
  }
305
313
  >
@@ -317,7 +325,7 @@ function JudgePanel({ active, range, judgeAvailable, job, onRunStarted, onRunCha
317
325
  )}
318
326
  </div>
319
327
 
320
- <Modal open={modalOpen} title="Classify with judge" onClose={closeModal} primaryAction={primaryAction}>
328
+ <Modal open={modalOpen} title={t("frustration.classify")} onClose={closeModal} primaryAction={primaryAction}>
321
329
  <EstimateBody estimate={estimate} />
322
330
  {startError && (
323
331
  <p className="micro tone-bad" role="alert">
@@ -334,46 +342,43 @@ function JobBadge({ job, judgeAvailable }: { job: FrustrationJobStatus; judgeAva
334
342
  case "running":
335
343
  return (
336
344
  <Badge tone="ok">
337
- <Dot tone="live" pulse /> Running
345
+ <Dot tone="live" pulse /> {t("frustration.running")}
338
346
  </Badge>
339
347
  );
340
348
  case "done":
341
- return <Badge tone="ok">Last run done</Badge>;
349
+ return <Badge tone="ok">{t("frustration.last-run-done")}</Badge>;
342
350
  case "cancelled":
343
- return <Badge tone="warn">Last run cancelled</Badge>;
351
+ return <Badge tone="warn">{t("frustration.last-run-cancelled")}</Badge>;
344
352
  case "failed":
345
- return <Badge tone="bad">Last run failed</Badge>;
353
+ return <Badge tone="bad">{t("frustration.last-run-failed")}</Badge>;
346
354
  case "idle":
347
- return <Badge>{judgeAvailable ? "Not run yet" : "Regex only"}</Badge>;
355
+ return <Badge>{judgeAvailable ? t("frustration.not-run-yet") : t("frustration.regex-only")}</Badge>;
348
356
  }
349
357
  }
350
358
 
351
359
  function EstimateBody({ estimate }: { estimate: EstimateState }) {
352
- if (estimate.status === "loading") return <p className="muted">Estimating cost…</p>;
360
+ if (estimate.status === "loading") return <p className="muted">{t("frustration.estimating")}</p>;
353
361
  if (estimate.status === "error") return <p className="tone-bad">{estimate.message}</p>;
354
362
  const quote = estimate.estimate;
355
363
  if (!quote.available) return <p>{quote.reason}</p>;
356
364
  if (quote.messages === 0) {
357
- return <p>Everything in this range is already judged. There is nothing left to classify.</p>;
365
+ return <p>{t("frustration.all-judged")}</p>;
358
366
  }
359
367
  return (
360
368
  <>
361
369
  <div className="frustration-quote">
362
- <span className="dim micro">Estimated cost</span>
370
+ <span className="dim micro">{t("frustration.estimated-cost")}</span>
363
371
  <span className="num frustration-quote-value">≈ {formatCost(quote.cost)}</span>
364
372
  </div>
365
373
  <KeyValues
366
374
  items={[
367
- { key: "messages", label: "Unjudged unique messages", value: formatInteger(quote.messages) },
368
- { key: "chars", label: "Prose characters", value: formatInteger(quote.chars) },
369
- { key: "tokens", label: "Estimated input tokens", value: formatInteger(quote.inputTokens) },
370
- { key: "judge", label: "Judge", value: quote.judge },
375
+ { key: "messages", label: t("frustration.unjudged-messages"), value: formatInteger(quote.messages) },
376
+ { key: "chars", label: t("frustration.prose-chars"), value: formatInteger(quote.chars) },
377
+ { key: "tokens", label: t("frustration.input-tokens"), value: formatInteger(quote.inputTokens) },
378
+ { key: "judge", label: t("frustration.judge-model"), value: quote.judge },
371
379
  ]}
372
380
  />
373
- <p className="micro dim">
374
- Identical messages share one verdict, and verdicts are cached, so later runs only pay for new messages.
375
- Nothing is spent until you proceed.
376
- </p>
381
+ <p className="micro dim">{t("frustration.estimate-note")}</p>
377
382
  </>
378
383
  );
379
384
  }
@@ -405,7 +410,7 @@ function JudgeProgress({
405
410
  onClick={onCancel}
406
411
  disabled={cancelling}
407
412
  >
408
- {cancelling ? "Cancelling…" : "Cancel run"}
413
+ {cancelling ? t("frustration.cancelling") : t("frustration.cancel-run")}
409
414
  </button>
410
415
  </div>
411
416
  <div
@@ -418,58 +423,70 @@ function JudgeProgress({
418
423
  <div className="meter-fill frustration-progress-fill" style={{ width: `${share * 100}%` }} />
419
424
  </div>
420
425
  <div className="row micro muted frustration-progress-meta">
421
- <span className={job.failed > 0 ? "tone-bad" : undefined}>{formatInteger(job.failed)} failed</span>
426
+ <span className={job.failed > 0 ? "tone-bad" : undefined}>
427
+ {t("frustration.failed-n", { count: formatInteger(job.failed) })}
428
+ </span>
422
429
  <span>
423
- cost so far <span className="num">{formatCost(job.cost)}</span>
430
+ {t("frustration.cost-so-far")} <span className="num">{formatCost(job.cost)}</span>
424
431
  </span>
425
432
  {job.judge && <span className="mono">{job.judge}</span>}
426
- {job.startedAt !== null && <span>elapsed {formatElapsed(Date.now() - job.startedAt)}</span>}
433
+ {job.startedAt !== null && (
434
+ <span>{t("frustration.elapsed", { time: formatElapsed(Date.now() - job.startedAt) })}</span>
435
+ )}
427
436
  {job.startedAt !== null && job.done > 0 && (
428
437
  <span className="num">{(job.done / Math.max(1, (Date.now() - job.startedAt) / 1000)).toFixed(0)}/s</span>
429
438
  )}
430
- {job.concurrency > 0 && <span className="num">{formatInteger(job.concurrency)} in flight</span>}
439
+ {job.concurrency > 0 && (
440
+ <span className="num">{t("frustration.in-flight", { count: formatInteger(job.concurrency) })}</span>
441
+ )}
431
442
  </div>
432
443
  </div>
433
444
  );
434
445
  }
435
446
 
436
447
  function JudgeResultLine({ job }: { job: FrustrationJobStatus }) {
437
- const progress = `${formatInteger(job.done)}/${formatInteger(job.total)} messages`;
438
- const failed = job.failed > 0 ? `, ${formatInteger(job.failed)} failed` : "";
439
- const judge = job.judge ? ` with ${job.judge}` : "";
448
+ const progress = t("frustration.messages-progress", {
449
+ done: formatInteger(job.done),
450
+ total: formatInteger(job.total),
451
+ });
452
+ const failed = job.failed > 0 ? t("frustration.failed-suffix", { count: formatInteger(job.failed) }) : "";
453
+ const judge = job.judge ? t("frustration.judge-with", { judge: job.judge }) : "";
440
454
  const elapsed =
441
- job.startedAt !== null && job.finishedAt !== null ? ` in ${formatElapsed(job.finishedAt - job.startedAt)}` : "";
455
+ job.startedAt !== null && job.finishedAt !== null
456
+ ? t("frustration.in-time", { time: formatElapsed(job.finishedAt - job.startedAt) })
457
+ : "";
442
458
  switch (job.state) {
443
459
  case "idle":
444
- return (
445
- <p className="muted">
446
- No judge run yet. Classify the unjudged messages to replace regex estimates with verdicts.
447
- </p>
448
- );
460
+ return <p className="muted">{t("frustration.no-run-yet")}</p>;
449
461
  case "running":
450
462
  return null;
451
463
  case "done":
452
464
  return (
453
465
  <p className="muted">
454
- Last run judged {progress}
455
- {failed}
456
- {judge}
457
- {elapsed} for <span className="num">{formatCost(job.cost)}</span>.
466
+ {t("frustration.run-done-line", {
467
+ progress,
468
+ failed,
469
+ judge,
470
+ elapsed,
471
+ cost: formatCost(job.cost),
472
+ })}
458
473
  </p>
459
474
  );
460
475
  case "cancelled":
461
476
  return (
462
477
  <p className="muted">
463
- Last run cancelled after {progress}
464
- {failed}; spent <span className="num">{formatCost(job.cost)}</span>.
478
+ {t("frustration.run-cancelled-line", { progress, failed, cost: formatCost(job.cost) })}
465
479
  </p>
466
480
  );
467
481
  case "failed":
468
482
  return (
469
483
  <p className="muted">
470
- Last run failed after {progress}
471
- {failed}: <span className="tone-bad">{job.error ?? "unknown error"}</span>. Spent{" "}
472
- <span className="num">{formatCost(job.cost)}</span>.
484
+ {t("frustration.run-failed-line", {
485
+ progress,
486
+ failed,
487
+ error: job.error ?? t("frustration.unknown-error"),
488
+ cost: formatCost(job.cost),
489
+ })}
473
490
  </p>
474
491
  );
475
492
  }
@@ -638,8 +655,8 @@ function FrustrationModelsSection({ models, stale }: { models: FrustrationModelS
638
655
 
639
656
  if (models.length === 0) {
640
657
  return (
641
- <Card index={2} title="Frustration by model version" stale={stale}>
642
- <EmptyState title="No user messages in this range" hint="Try a longer range." />
658
+ <Card index={2} title={t("frustration.by-model")} stale={stale}>
659
+ <EmptyState title={t("frustration.no-messages")} hint={t("frustration.try-longer-range")} />
643
660
  </Card>
644
661
  );
645
662
  }
@@ -650,15 +667,15 @@ function FrustrationModelsSection({ models, stale }: { models: FrustrationModelS
650
667
  <>
651
668
  <Card
652
669
  index={2}
653
- title="Frustration by model version"
654
- description="Share of user messages per model version. Each bar reaches the annoyed rate; the subsets aimed at the assistant stack inside it."
670
+ title={t("frustration.by-model")}
671
+ description={t("frustration.by-model-description")}
655
672
  stale={stale}
656
673
  >
657
674
  <div className="stack frustration-chart-body">
658
675
  <div className="frustration-classes">
659
676
  <Segmented
660
677
  size="sm"
661
- aria-label="Model class"
678
+ aria-label={t("frustration.model-class")}
662
679
  options={classOptions}
663
680
  value={activeClass}
664
681
  onChange={setSelectedClass}
@@ -676,14 +693,12 @@ function FrustrationModelsSection({ models, stale }: { models: FrustrationModelS
676
693
  onToggle={toggleFamily}
677
694
  />
678
695
  <span className="frustration-spacer" />
679
- <label className="check" title={`Models with fewer than ${MIN_MESSAGES} messages have noisy rates`}>
696
+ <label className="check" title={t("frustration.small-models-title", { count: MIN_MESSAGES })}>
680
697
  <input type="checkbox" checked={showSmall} onChange={e => setShowSmall(e.target.checked)} />
681
- Include &lt; {MIN_MESSAGES} messages{smallCount > 0 ? ` (${smallCount})` : ""}
698
+ {t("frustration.include-small", { count: MIN_MESSAGES })}
699
+ {smallCount > 0 ? ` (${smallCount})` : ""}
682
700
  </label>
683
- <label
684
- className="check"
685
- title={`Hide models with under ${MIN_JUDGED_SHARE * 100}% judged messages (rates from regex signals)`}
686
- >
701
+ <label className="check" title={t("frustration.hide-regex-title", { share: MIN_JUDGED_SHARE * 100 })}>
687
702
  <input type="checkbox" checked={hideRegex} onChange={e => setHideRegex(e.target.checked)} />
688
703
  Hide mostly regex{regexCount > 0 ? ` (${regexCount})` : ""}
689
704
  </label>
@@ -715,16 +730,16 @@ function EncodingKey({ hasRegexRows }: { hasRegexRows: boolean }) {
715
730
  </span>
716
731
  <span className="legend-item">
717
732
  <span className="frustration-key-line" />
718
- At assistant trend
733
+ {t("frustration.legend-trend")}
719
734
  </span>
720
735
  <span
721
736
  className="legend-item frustration-key-wide"
722
- title={`Under ${MIN_JUDGED_SHARE * 100}% of these messages are judged; their rates come from regex signals`}
737
+ title={t("frustration.hatch-title", { share: MIN_JUDGED_SHARE * 100 })}
723
738
  >
724
739
  <span className="swatch frustration-key-swatch" data-variant="hatch" />
725
- Hatched: mostly regex fallback{hasRegexRows ? " (run the judge)" : ""}
740
+ {hasRegexRows ? t("frustration.legend-hatched-run") : t("frustration.legend-hatched")}
726
741
  </span>
727
- <span className="legend-item dim">Hue = model family</span>
742
+ <span className="legend-item dim">{t("frustration.legend-hue")}</span>
728
743
  </div>
729
744
  );
730
745
  }
@@ -777,7 +792,7 @@ function FrustrationChart({
777
792
  if (rows.length === 0) {
778
793
  return (
779
794
  <div className="frustration-chart-empty">
780
- <EmptyState title="No model versions match the filters" />
795
+ <EmptyState title={t("frustration.no-model-match")} />
781
796
  </div>
782
797
  );
783
798
  }
@@ -797,12 +812,12 @@ function FrustrationChart({
797
812
  return (
798
813
  <div className="frustration-tooltip-extra">
799
814
  <div className="chart-tooltip-row chart-tooltip-total">
800
- <span className="chart-tooltip-label">Annoyed, any target</span>
815
+ <span className="chart-tooltip-label">{t("frustration.legend-annoyed-any")}</span>
801
816
  <span className="chart-tooltip-value">{rate(row.annoyed, row.messages)}</span>
802
817
  </div>
803
818
  <div className="micro dim">
804
819
  {formatInteger(row.messages)} messages · judged {formatInteger(row.judged)} (
805
- {rate(row.judged, row.messages)}){isMostlyRegex(row) ? " · mostly regex fallback" : ""}
820
+ {rate(row.judged, row.messages)}){isMostlyRegex(row) ? t("frustration.row-mostly-regex") : ""}
806
821
  </div>
807
822
  </div>
808
823
  );
@@ -824,7 +839,7 @@ function FrustrationTable({
824
839
  (): Column<FrustrationModelStats>[] => [
825
840
  {
826
841
  key: "model",
827
- header: "Model",
842
+ header: t("common.model"),
828
843
  sort: row => row.label,
829
844
  render: row => (
830
845
  <span className="row" title={row.key}>
@@ -838,29 +853,26 @@ function FrustrationTable({
838
853
  },
839
854
  {
840
855
  key: "ids",
841
- header: "Model IDs",
856
+ header: t("frustration.column-model-ids"),
842
857
  wrap: true,
843
858
  render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
844
859
  },
845
860
  {
846
861
  key: "messages",
847
- header: "Messages",
862
+ header: t("frustration.column-messages"),
848
863
  align: "right",
849
864
  sort: row => row.messages,
850
865
  render: row => <span className="num">{formatInteger(row.messages)}</span>,
851
866
  },
852
867
  {
853
868
  key: "judged",
854
- header: "Judged",
869
+ header: t("frustration.column-judged"),
855
870
  align: "right",
856
871
  sort: row => (row.messages > 0 ? row.judged / row.messages : 0),
857
872
  render: row =>
858
873
  isMostlyRegex(row) ? (
859
- <span
860
- className="row frustration-judged"
861
- title="Mostly regex fallback: run the judge for accurate rates"
862
- >
863
- <Badge tone="warn">regex</Badge>
874
+ <span className="row frustration-judged" title={t("frustration.mostly-regex")}>
875
+ <Badge tone="warn">{t("frustration.regex-badge")}</Badge>
864
876
  <span className="num dim">{rate(row.judged, row.messages)}</span>
865
877
  </span>
866
878
  ) : (
@@ -869,33 +881,36 @@ function FrustrationTable({
869
881
  },
870
882
  {
871
883
  key: "annoyed",
872
- header: "Annoyed",
884
+ header: t("frustration.annoyed"),
873
885
  align: "right",
874
886
  sort: row => percent(row.annoyed, row.messages),
875
887
  render: row => (
876
- <span className="num" title={`${formatInteger(row.annoyed)} messages`}>
888
+ <span className="num" title={t("frustration.messages-n", { count: formatInteger(row.annoyed) })}>
877
889
  {rate(row.annoyed, row.messages)}
878
890
  </span>
879
891
  ),
880
892
  },
881
893
  {
882
894
  key: "atAssistant",
883
- header: "At assistant",
895
+ header: t("frustration.at-assistant"),
884
896
  align: "right",
885
897
  sort: row => percent(row.atAssistant, row.messages),
886
898
  render: row => (
887
- <span className="num" title={`${formatInteger(row.atAssistant)} messages`}>
899
+ <span className="num" title={t("frustration.messages-n", { count: formatInteger(row.atAssistant) })}>
888
900
  {rate(row.atAssistant, row.messages)}
889
901
  </span>
890
902
  ),
891
903
  },
892
904
  {
893
905
  key: "angry",
894
- header: "Angry",
906
+ header: t("frustration.angry"),
895
907
  align: "right",
896
908
  sort: row => percent(row.angry, row.messages),
897
909
  render: row => (
898
- <span className={row.angry > 0 ? "num tone-bad" : "num"} title={`${formatInteger(row.angry)} messages`}>
910
+ <span
911
+ className={row.angry > 0 ? "num tone-bad" : "num"}
912
+ title={t("frustration.messages-n", { count: formatInteger(row.angry) })}
913
+ >
899
914
  {rate(row.angry, row.messages)}
900
915
  </span>
901
916
  ),
@@ -907,8 +922,8 @@ function FrustrationTable({
907
922
  return (
908
923
  <Card
909
924
  index={3}
910
- title="Model versions"
911
- description="Rates are shares of user messages; rows follow the chart filters."
925
+ title={t("frustration.model-versions")}
926
+ description={t("frustration.table-description")}
912
927
  flush
913
928
  stale={stale}
914
929
  >
@@ -917,7 +932,7 @@ function FrustrationTable({
917
932
  rowKey={row => row.key}
918
933
  columns={columns}
919
934
  dense
920
- empty={<EmptyState title="No model versions match the filters" />}
935
+ empty={<EmptyState title={t("frustration.no-model-match")} />}
921
936
  />
922
937
  </Card>
923
938
  );