@linxiraos/pi-stats 1.3.2 → 1.4.1

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.js +91 -91
  5. package/dist/types/client/app/LanguageToggle.d.ts +7 -0
  6. package/dist/types/client/i18n/index.d.ts +11 -3
  7. package/dist/types/client/i18n/messages/common.d.ts +4 -0
  8. package/dist/types/client/i18n/messages/en.d.ts +76 -0
  9. package/dist/types/client/i18n/messages/frustration.d.ts +72 -0
  10. package/dist/types/client/i18n/messages/zh-CN.d.ts +1 -1
  11. package/dist/types/client/i18n/useLocale.d.ts +7 -0
  12. package/dist/types/db.d.ts +34 -1
  13. package/dist/types/model-identity.d.ts +29 -0
  14. package/dist/types/model-metrics.d.ts +53 -0
  15. package/dist/types/model-side.d.ts +6 -0
  16. package/dist/types/parser.d.ts +2 -1
  17. package/dist/types/shared-types.d.ts +88 -0
  18. package/dist/types/types.d.ts +47 -1
  19. package/dist/types/user-metrics.d.ts +22 -2
  20. package/package.json +4 -4
  21. package/src/aggregator.ts +1 -0
  22. package/src/client/App.tsx +4 -0
  23. package/src/client/app/LanguageToggle.tsx +30 -0
  24. package/src/client/app/Shell.tsx +12 -7
  25. package/src/client/app/ThemeToggle.tsx +11 -8
  26. package/src/client/i18n/index.ts +49 -6
  27. package/src/client/i18n/messages/common.ts +4 -0
  28. package/src/client/i18n/messages/frustration.ts +77 -0
  29. package/src/client/i18n/useLocale.ts +11 -0
  30. package/src/client/routes/FrustrationRoute.tsx +439 -4
  31. package/src/client/routes/ProvidersRoute.tsx +2 -4
  32. package/src/client/routes/ToolsRoute.tsx +7 -9
  33. package/src/client/routes/frustration.css +13 -0
  34. package/src/client/styles.css +13 -0
  35. package/src/db.ts +321 -2
  36. package/src/frustration.ts +8 -33
  37. package/src/model-identity.ts +61 -0
  38. package/src/model-metrics.ts +153 -0
  39. package/src/model-side.ts +77 -0
  40. package/src/parser.ts +104 -11
  41. package/src/shared-types.ts +93 -0
  42. package/src/types.ts +48 -1
  43. package/src/user-metrics.ts +9 -7
@@ -174,11 +174,57 @@ export interface UserMessageStats {
174
174
  emphasis: number;
175
175
  /** 1 when the prose leans on a script no lexicon covers; such rows are unscored, not calm. */
176
176
  unsupportedScript: number;
177
- /** Judge-facing prose (see `judgeProse`); empty when the message was all markup. */
177
+ /** Judge-facing prose (see `analysisProse`); empty when the message was all markup. */
178
178
  prose: string;
179
179
  /** Hex `Bun.hash` of `prose`, keying the shared verdict cache; empty when `prose` is. */
180
180
  proseHash: string;
181
181
  }
182
+ /**
183
+ * Model-side analysis for one assistant turn: the text body stored for
184
+ * offline analysis plus the signals computed from it at ingest (see
185
+ * `model-metrics.ts`). Keyed like `user_messages` by (session_file, entry_id).
186
+ * Turns without prose (tool-call-only replies) emit no row.
187
+ */
188
+ export interface AssistantTextStats {
189
+ /** Database ID */
190
+ id?: number;
191
+ /** Session file path */
192
+ sessionFile: string;
193
+ /** Assistant message entry ID within the session */
194
+ entryId: string;
195
+ /** Folder/project path (extracted from session filename) */
196
+ folder: string;
197
+ /** Unix timestamp in ms */
198
+ timestamp: number;
199
+ /** Model that produced the turn */
200
+ model: string;
201
+ /** Provider that served the turn */
202
+ provider: string;
203
+ /** Which agent produced the turn (main agent, task subagent, advisor) */
204
+ agentType: AgentType;
205
+ /** Analyzed-prose characters (code and markup stripped, capped) */
206
+ chars: number;
207
+ /** Analyzed-prose words */
208
+ words: number;
209
+ /** Apology / self-blame markers */
210
+ selfBlame: number;
211
+ /** Repeated-restatement markers plus near-duplicate previous turn */
212
+ restatement: number;
213
+ /** Hedged fallback markers */
214
+ hedge: number;
215
+ /** 1 when the turn is long-winded */
216
+ verbose: number;
217
+ /** Failed tool results preceding the turn */
218
+ toolErrors: number;
219
+ /** 1 when the preceding failures form a spiral */
220
+ toolSpiral: number;
221
+ /** Per-turn aggregate (see `AssistantMessageMetrics`) */
222
+ collapse: number;
223
+ /** 1 when the prose leans on a script no lexicon covers */
224
+ unsupportedScript: number;
225
+ /** Stored analysis prose (see `analysisProse`); never empty for an emitted row. */
226
+ prose: string;
227
+ }
182
228
  /**
183
229
  * Pair emitted by the parser when it sees an assistant message whose
184
230
  * `parentId` points to a user message that wasn't parsed in the same pass
@@ -91,14 +91,34 @@ export interface UserMessageMetrics {
91
91
  */
92
92
  unsupportedScript: number;
93
93
  }
94
+ export declare const WORD_RE: RegExp;
95
+ /** 1 when the prose leans on a script the lexicons do not cover. */
96
+ export declare function detectUnsupportedScript(prose: string): number;
97
+ /**
98
+ * 1 when the message says essentially the same thing as the one before it -
99
+ * the one frustration signal that needs no lexicon and therefore works for
100
+ * any language and survives every transcription error.
101
+ */
102
+ export declare function detectRepeatedMessage(prose: string, prevProse: string): number;
103
+ /** Count regex hits without materializing the match array. */
104
+ export declare function countMatches(text: string, re: RegExp): number;
105
+ /**
106
+ * Strip structured content so that pasted code, harness wrappers, file
107
+ * mentions and quoted blocks don't dilute or fake behavior signals.
108
+ * Each strip is replaced with a newline so subsequent line counting
109
+ * reflects what was removed instead of merging neighbors.
110
+ */
111
+ export declare function stripStructuredContent(text: string): string;
94
112
  /** Cap on the judge-facing prose stored per user message; longer prose is truncated. */
95
113
  export declare const PROSE_MAX_CHARS = 4000;
96
114
  /**
97
- * The prose a user message contributes to frustration judging: the same
115
+ * The prose a message contributes to behavioral analysis: the same
98
116
  * structured-content strip the regex signals score on, trimmed and capped at
99
117
  * {@link PROSE_MAX_CHARS}. Empty when the message was nothing but markup.
118
+ * Shared by the user-message judge and the model-side analyzer so both store
119
+ * exactly the same body.
100
120
  */
101
- export declare function judgeProse(text: string): string;
121
+ export declare function analysisProse(text: string): string;
102
122
  /**
103
123
  * Compute behavioral metrics for a user message.
104
124
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-stats",
4
- "version": "1.3.2",
4
+ "version": "1.4.1",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Linxira-OS",
@@ -39,9 +39,9 @@
39
39
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
40
  },
41
41
  "dependencies": {
42
- "@linxiraos/pi-ai": "1.3.2",
43
- "@linxiraos/pi-catalog": "1.3.2",
44
- "@linxiraos/pi-utils": "1.3.2",
42
+ "@linxiraos/pi-ai": "1.4.1",
43
+ "@linxiraos/pi-catalog": "1.4.1",
44
+ "@linxiraos/pi-utils": "1.4.1",
45
45
  "lucide-react": "^1.24.0",
46
46
  "react": "19.2.7",
47
47
  "react-dom": "19.2.7"
package/src/aggregator.ts CHANGED
@@ -415,6 +415,7 @@ async function syncAllSessionsLocked(
415
415
  pendingRows +=
416
416
  result.stats.length +
417
417
  result.userStats.length +
418
+ result.assistantTexts.length +
418
419
  result.userLinks.length +
419
420
  result.toolCalls.length +
420
421
  result.toolResults.length;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
2
  import type { DashboardSection } from "./app/nav";
3
3
  import { Shell } from "./app/Shell";
4
+ import { useLocale } from "./i18n/useLocale";
4
5
  import { LiveProvider } from "./data/live";
5
6
  import { useHashRoute } from "./data/useHashRoute";
6
7
  import {
@@ -27,6 +28,9 @@ export default function App() {
27
28
  }
28
29
 
29
30
  function Dashboard() {
31
+ // A topbar language switch re-renders the whole tree: translated copy is
32
+ // read through `t()` at render time, including the mounted-but-hidden pages.
33
+ useLocale();
30
34
  const { section, setSection, range, setRange, session, setSession } = useHashRoute();
31
35
  const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
32
36
  // Stable identity so the drawer's effects don't tear down on every render.
@@ -0,0 +1,30 @@
1
+ import { Languages } from "lucide-react";
2
+ import { setUserLocale, t } from "../i18n";
3
+ import { useLocale } from "../i18n/useLocale";
4
+
5
+ /**
6
+ * Topbar language switch: EN ⇄ 中文. The label shows the language the click
7
+ * will install (the dashboard is already in the other one), and the choice is
8
+ * remembered in localStorage so a reload keeps it over the host's injected
9
+ * locale.
10
+ */
11
+ export function LanguageToggle() {
12
+ const locale = useLocale();
13
+ const next = locale === "en" ? "zh-CN" : "en";
14
+ const label = locale === "en" ? "中文" : "EN";
15
+
16
+ return (
17
+ <button
18
+ type="button"
19
+ className="btn"
20
+ data-variant="ghost"
21
+ data-locale-toggle={locale}
22
+ onClick={() => setUserLocale(next)}
23
+ aria-label={t("shell.switch-language")}
24
+ title={`${t("shell.language")}: ${label}`}
25
+ >
26
+ <Languages size={15} />
27
+ <span className="topbar-locale-label">{label}</span>
28
+ </button>
29
+ );
30
+ }
@@ -2,9 +2,11 @@ import { Menu } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { rangeMeta, TIME_RANGES } from "../data/range";
4
4
  import { t } from "../i18n";
5
+ import { useLocale } from "../i18n/useLocale";
5
6
  import { useLiveStatus } from "../data/live";
6
7
  import type { TimeRange } from "../types";
7
8
  import { Segmented } from "../ui";
9
+ import { LanguageToggle } from "./LanguageToggle";
8
10
  import { LiveChip } from "./LiveChip";
9
11
  import { type DashboardSection, NAV } from "./nav";
10
12
  import { ThemeToggle } from "./ThemeToggle";
@@ -17,18 +19,20 @@ export interface ShellProps {
17
19
  children: ReactNode;
18
20
  }
19
21
 
20
- const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
21
- value: range,
22
- label: t(rangeMeta(range).labelKey),
23
- title: `${t(rangeMeta(range).windowKey)} (${i + 1})`,
24
- }));
25
-
26
22
  /**
27
23
  * Fixed glass topbar + naked sidebar framing one inset content panel. Owns
28
24
  * the global keyboard shortcuts: `1`–`6` pick a range, `g` then a letter jumps
29
25
  * to a page.
30
26
  */
31
27
  export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
28
+ // Range labels are translated copy, so they are rebuilt on a locale switch
29
+ // instead of being frozen in a module-level table.
30
+ useLocale();
31
+ const rangeOptions = TIME_RANGES.map((range, i) => ({
32
+ value: range,
33
+ label: t(rangeMeta(range).labelKey),
34
+ title: `${t(rangeMeta(range).windowKey)} (${i + 1})`,
35
+ }));
32
36
  const [menuOpen, setMenuOpen] = useState(false);
33
37
  const scrolled = useScrolled();
34
38
  const { sync } = useLiveStatus();
@@ -87,11 +91,12 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
87
91
  <LiveChip />
88
92
  </span>
89
93
  <Segmented
90
- options={RANGE_OPTIONS}
94
+ options={rangeOptions}
91
95
  value={range}
92
96
  onChange={onRangeChange}
93
97
  aria-label={t("common.time-range")}
94
98
  />
99
+ <LanguageToggle />
95
100
  <ThemeToggle />
96
101
  </div>
97
102
  {progress !== undefined && (
@@ -1,6 +1,7 @@
1
1
  import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
2
2
  import { type ThemePreference, useThemePreference } from "../useSystemTheme";
3
3
  import { t } from "../i18n";
4
+ import { useLocale } from "../i18n/useLocale";
4
5
 
5
6
  const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
6
7
  system: "light",
@@ -8,22 +9,24 @@ const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
8
9
  dark: "system",
9
10
  };
10
11
 
12
+ const PREFERENCE_LABEL_KEY = {
13
+ system: "shell.system-theme",
14
+ light: "shell.light-theme",
15
+ dark: "shell.dark-theme",
16
+ } as const;
17
+
11
18
  const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
12
19
  system: Monitor,
13
20
  light: Sun,
14
21
  dark: Moon,
15
22
  };
16
23
 
17
- const PREFERENCE_LABEL: Record<ThemePreference, string> = {
18
- system: t("shell.system-theme"),
19
- light: t("shell.light-theme"),
20
- dark: t("shell.dark-theme"),
21
- };
22
-
23
24
  /** Cycles system → light → dark. */
24
25
  export function ThemeToggle() {
26
+ useLocale();
25
27
  const { preference, setPreference } = useThemePreference();
26
28
  const Icon = PREFERENCE_ICON[preference];
29
+ const label = t(PREFERENCE_LABEL_KEY[preference]);
27
30
 
28
31
  return (
29
32
  <button
@@ -32,8 +35,8 @@ export function ThemeToggle() {
32
35
  data-variant="ghost"
33
36
  data-icon="true"
34
37
  onClick={() => setPreference(NEXT_PREFERENCE[preference])}
35
- aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
36
- title={`${PREFERENCE_LABEL[preference]} — click to switch`}
38
+ aria-label={`${label} (click to switch)`}
39
+ title={`${label} — click to switch`}
37
40
  >
38
41
  <Icon size={15} />
39
42
  </button>
@@ -11,19 +11,32 @@ function injectedLocale(): string | undefined {
11
11
 
12
12
  const messages: MessagesByLocale = { en: enLocale.messages, "zh-CN": zhCNLocale.messages };
13
13
 
14
+ /** Local override written by the in-page switcher; survives reloads. */
15
+ const LOCALE_STORAGE_KEY = "zeta.stats.locale";
16
+
17
+ const localeListeners = new Set<() => void>();
18
+
14
19
  let activeLocale: StatsLocale = "en";
15
20
 
16
21
  // Resolve once at module load. Every `t()` caller imports this module first, so
17
- // the host language the server injected into `<head>` (or the browser's own
18
- // preference) is already in effect by the time a component module is evaluated
19
- // — including module-level label tables such as the range selector's.
22
+ // the stored choice, the host language the server injected into `<head>`, or
23
+ // the browser's own preference is already in effect by the time a component
24
+ // module is evaluated.
20
25
  resolveInitialLocale();
21
26
 
22
- /** The locale the dashboard renders in. Fixed at boot; there is no in-page switcher. */
27
+ /** The locale the dashboard renders in. */
23
28
  export function getLocale(): StatsLocale {
24
29
  return activeLocale;
25
30
  }
26
31
 
32
+ /** Subscribe to runtime switches (the topbar toggle); returns the unsubscribe. */
33
+ export function subscribeLocale(listener: () => void): () => void {
34
+ localeListeners.add(listener);
35
+ return () => {
36
+ localeListeners.delete(listener);
37
+ };
38
+ }
39
+
27
40
  /** Install a locale by tag (`zh`, `zh-CN`, `zh-Hans`; anything else is English). */
28
41
  export function setLocale(tag: string | undefined | null): StatsLocale {
29
42
  activeLocale = resolveLocaleTag(tag);
@@ -31,10 +44,40 @@ export function setLocale(tag: string | undefined | null): StatsLocale {
31
44
  }
32
45
 
33
46
  /**
34
- * The locale to boot with: the host's language when `zetacode stats` injected it,
35
- * then the browser's own preference, then English.
47
+ * The user's explicit pick from the topbar: install it, remember it, and
48
+ * re-render every subscriber.
49
+ */
50
+ export function setUserLocale(tag: string): StatsLocale {
51
+ const next = setLocale(tag);
52
+ try {
53
+ globalThis.localStorage?.setItem(LOCALE_STORAGE_KEY, next);
54
+ } catch {
55
+ // Private-mode storage denial only costs the choice at next reload.
56
+ }
57
+ for (const listener of [...localeListeners]) listener();
58
+ return next;
59
+ }
60
+
61
+ /** A stored topbar choice, when it still parses as a supported locale. */
62
+ function storedLocale(): StatsLocale | undefined {
63
+ try {
64
+ const raw = globalThis.localStorage?.getItem(LOCALE_STORAGE_KEY);
65
+ if (!raw) return undefined;
66
+ const resolved = resolveLocaleTag(raw);
67
+ return resolved === "en" || resolved === "zh-CN" ? resolved : undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The locale to boot with: an explicit topbar choice, then the host's language
75
+ * when `zetacode stats` injected it, then the browser's own preference, then
76
+ * English.
36
77
  */
37
78
  export function resolveInitialLocale(): StatsLocale {
79
+ const stored = storedLocale();
80
+ if (stored) return setLocale(stored);
38
81
  const injected = injectedLocale();
39
82
  if (injected) return setLocale(injected);
40
83
  if (typeof navigator === "undefined") return "en";
@@ -81,6 +81,8 @@ export const common = {
81
81
  "shell.light-theme": "Light theme",
82
82
  "shell.range-suffix": "range",
83
83
  "shell.jump-hint": "then letter to jump",
84
+ "shell.language": "Language",
85
+ "shell.switch-language": "Switch dashboard language",
84
86
  },
85
87
  "zh-CN": {
86
88
  "common.provider": "服务商",
@@ -153,5 +155,7 @@ export const common = {
153
155
  "shell.light-theme": "浅色主题",
154
156
  "shell.range-suffix": "范围",
155
157
  "shell.jump-hint": "再按字母跳转",
158
+ "shell.language": "语言",
159
+ "shell.switch-language": "切换仪表盘语言",
156
160
  },
157
161
  } as const;
@@ -101,6 +101,46 @@ export const frustration = {
101
101
  "frustration.cancelled": "Cancelled",
102
102
  "frustration.failed": "Failed",
103
103
  "frustration.done": "Done",
104
+ "frustration.model-side-title": "AI breakdown",
105
+ "frustration.model-side-description": "How the model's own replies read, per model version, in {window}.",
106
+ "frustration.model-turns": "Assistant turns",
107
+ "frustration.model-turns-title":
108
+ "Assistant replies with text in range; tool-call-only replies and thinking blocks are not analyzed",
109
+ "frustration.model-breakdown": "Breakdown rate",
110
+ "frustration.model-breakdown-title": "Share of analyzed turns with at least one breakdown signal",
111
+ "frustration.model-mean": "Signals / turn",
112
+ "frustration.model-mean-title": "Average per-turn aggregate: text markers plus capped failed tool results",
113
+ "frustration.model-self-blame": "Self-blame",
114
+ "frustration.model-self-blame-title": "Turns containing an apology or admission",
115
+ "frustration.model-restatement": "Restatement",
116
+ "frustration.model-restatement-title":
117
+ "Turns repeating earlier content, by explicit marker or near-duplicate reply",
118
+ "frustration.model-hedge": "Hedged",
119
+ "frustration.model-hedge-title": "Turns with hedged fallbacks: uncertainty or suggested alternatives",
120
+ "frustration.model-spiral": "Tool spirals",
121
+ "frustration.model-spiral-title": "Turns following three or more failed tool results",
122
+ "frustration.model-rambling": "Rambling",
123
+ "frustration.model-rambling-title": "Turns past the verbosity threshold",
124
+ "frustration.model-unscored-line": "Turns in a language without a lexicon: {count} — unscored, not calm.",
125
+ "frustration.model-by-model": "AI breakdown by model version",
126
+ "frustration.model-table-title": "Model breakdown detail",
127
+ "frustration.model-by-model-description":
128
+ "Share of each model's analyzed turns carrying a breakdown signal. Hue = model family.",
129
+ "frustration.model-over-time": "AI breakdown over time",
130
+ "frustration.model-over-time-description":
131
+ "Breakdown rate per bucket: turns with a signal over analyzed turns. Gaps are buckets with no turns.",
132
+ "frustration.model-sessions": "Sessions by breakdown",
133
+ "frustration.model-sessions-description": "Sessions with the most analyzed assistant turns.",
134
+ "frustration.model-table-description": "Rates are shares of analyzed assistant turns.",
135
+ "frustration.column-session": "Session",
136
+ "frustration.column-turns": "Turns",
137
+ "frustration.column-mean": "Signals/turn",
138
+ "frustration.column-tool-errors": "Tool errors",
139
+ "frustration.model-no-turns": "No assistant turns with text in this range",
140
+ "frustration.model-try-longer-range": "Try a longer range.",
141
+ "frustration.model-legend-breakdown": "Breakdown rate",
142
+ "frustration.turns-n": "{count} turns",
143
+ "frustration.model-hint-mean": "{value} signals per turn on average",
104
144
  },
105
145
  "zh-CN": {
106
146
  "frustration.estimate-note":
@@ -194,5 +234,42 @@ export const frustration = {
194
234
  "frustration.cancelled": "已取消",
195
235
  "frustration.failed": "失败",
196
236
  "frustration.done": "完成",
237
+ "frustration.model-side-title": "AI 崩溃度",
238
+ "frustration.model-side-description": "各模型版本自身回复的读感,统计窗口:{window}。",
239
+ "frustration.model-turns": "助手回合",
240
+ "frustration.model-turns-title": "时间范围内带文本的助手回复;仅含工具调用的回复与思考块不参与分析",
241
+ "frustration.model-breakdown": "崩溃率",
242
+ "frustration.model-breakdown-title": "至少带一个崩溃信号的已分析回合占比",
243
+ "frustration.model-mean": "信号 / 回合",
244
+ "frustration.model-mean-title": "每回合聚合均值:文本标记加上封顶的工具失败数",
245
+ "frustration.model-self-blame": "自责",
246
+ "frustration.model-self-blame-title": "包含道歉或认错的回合",
247
+ "frustration.model-restatement": "重复陈述",
248
+ "frustration.model-restatement-title": "以显式标记或与上一条回复高度相似的方式重复先前内容的回合",
249
+ "frustration.model-hedge": "含糊兜底",
250
+ "frustration.model-hedge-title": "带有含糊兜底的回合:不确定表述或建议替代方案",
251
+ "frustration.model-spiral": "工具失败螺旋",
252
+ "frustration.model-spiral-title": "紧随三个及以上工具失败之后的回合",
253
+ "frustration.model-rambling": "冗长",
254
+ "frustration.model-rambling-title": "超过冗长阈值的回合",
255
+ "frustration.model-unscored-line": "使用无词典语言的回合:{count} — 未评分,而非平静。",
256
+ "frustration.model-by-model": "各模型版本的 AI 崩溃度",
257
+ "frustration.model-table-title": "模型崩溃明细",
258
+ "frustration.model-by-model-description": "每个模型已分析回合中带崩溃信号的占比。色相 = 模型家族。",
259
+ "frustration.model-over-time": "AI 崩溃度随时间变化",
260
+ "frustration.model-over-time-description":
261
+ "每个时间桶的崩溃率:带信号的回合除以已分析回合。空白表示该桶没有回合。",
262
+ "frustration.model-sessions": "按崩溃度查看会话",
263
+ "frustration.model-sessions-description": "已分析助手回合最多的会话。",
264
+ "frustration.model-table-description": "比例为已分析助手回合的占比。",
265
+ "frustration.column-session": "会话",
266
+ "frustration.column-turns": "回合数",
267
+ "frustration.column-mean": "信号/回合",
268
+ "frustration.column-tool-errors": "工具错误",
269
+ "frustration.model-no-turns": "该时间范围内没有带文本的助手回合",
270
+ "frustration.model-try-longer-range": "试试更长的时间范围。",
271
+ "frustration.model-legend-breakdown": "崩溃率",
272
+ "frustration.turns-n": "{count} 个回合",
273
+ "frustration.model-hint-mean": "平均每回合 {value} 个信号",
197
274
  },
198
275
  } as const;
@@ -0,0 +1,11 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { getLocale, subscribeLocale, type StatsLocale } from "./index";
3
+
4
+ /**
5
+ * Subscribe a component to the active locale. `t()` reads the module-level
6
+ * catalogue, so a component that renders translated copy must call this —
7
+ * the topbar switch re-renders subscribers, and everything under them.
8
+ */
9
+ export function useLocale(): StatsLocale {
10
+ return useSyncExternalStore(subscribeLocale, getLocale, getLocale);
11
+ }