@linxiraos/pi-stats 1.4.0 → 1.4.2

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.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Topbar language switch: EN ⇄ 中文. The label shows the language the click
3
+ * will install (the dashboard is already in the other one), and the choice is
4
+ * remembered in localStorage so a reload keeps it over the host's injected
5
+ * locale.
6
+ */
7
+ export declare function LanguageToggle(): import("react").JSX.Element;
@@ -1,12 +1,20 @@
1
1
  import { type MessageKey } from "./messages/en.js";
2
2
  import type { StatsLocale, TranslationParams } from "./types.js";
3
- /** The locale the dashboard renders in. Fixed at boot; there is no in-page switcher. */
3
+ /** The locale the dashboard renders in. */
4
4
  export declare function getLocale(): StatsLocale;
5
+ /** Subscribe to runtime switches (the topbar toggle); returns the unsubscribe. */
6
+ export declare function subscribeLocale(listener: () => void): () => void;
5
7
  /** Install a locale by tag (`zh`, `zh-CN`, `zh-Hans`; anything else is English). */
6
8
  export declare function setLocale(tag: string | undefined | null): StatsLocale;
7
9
  /**
8
- * The locale to boot with: the host's language when `zetacode stats` injected it,
9
- * then the browser's own preference, then English.
10
+ * The user's explicit pick from the topbar: install it, remember it, and
11
+ * re-render every subscriber.
12
+ */
13
+ export declare function setUserLocale(tag: string): StatsLocale;
14
+ /**
15
+ * The locale to boot with: an explicit topbar choice, then the host's language
16
+ * when `zetacode stats` injected it, then the browser's own preference, then
17
+ * English.
10
18
  */
11
19
  export declare function resolveInitialLocale(): StatsLocale;
12
20
  /**
@@ -77,6 +77,8 @@ export declare const common: {
77
77
  readonly "shell.light-theme": "Light theme";
78
78
  readonly "shell.range-suffix": "range";
79
79
  readonly "shell.jump-hint": "then letter to jump";
80
+ readonly "shell.language": "Language";
81
+ readonly "shell.switch-language": "Switch dashboard language";
80
82
  };
81
83
  readonly "zh-CN": {
82
84
  readonly "common.provider": "服务商";
@@ -149,5 +151,7 @@ export declare const common: {
149
151
  readonly "shell.light-theme": "浅色主题";
150
152
  readonly "shell.range-suffix": "范围";
151
153
  readonly "shell.jump-hint": "再按字母跳转";
154
+ readonly "shell.language": "语言";
155
+ readonly "shell.switch-language": "切换仪表盘语言";
152
156
  };
153
157
  };
@@ -88,6 +88,8 @@ export declare const enMessages: {
88
88
  readonly "shell.light-theme": "Light theme";
89
89
  readonly "shell.range-suffix": "range";
90
90
  readonly "shell.jump-hint": "then letter to jump";
91
+ readonly "shell.language": "Language";
92
+ readonly "shell.switch-language": "Switch dashboard language";
91
93
  readonly "overview.title": "Overview";
92
94
  readonly "overview.description": "Everything zeta did across your sessions in {window}.";
93
95
  readonly "overview.api-equivalent-cost": "API-equivalent cost";
@@ -753,6 +755,8 @@ export declare const enLocale: {
753
755
  readonly "shell.light-theme": "Light theme";
754
756
  readonly "shell.range-suffix": "range";
755
757
  readonly "shell.jump-hint": "then letter to jump";
758
+ readonly "shell.language": "Language";
759
+ readonly "shell.switch-language": "Switch dashboard language";
756
760
  readonly "overview.title": "Overview";
757
761
  readonly "overview.description": "Everything zeta did across your sessions in {window}.";
758
762
  readonly "overview.api-equivalent-cost": "API-equivalent cost";
@@ -9,5 +9,5 @@ export declare const zhCNMessages: Partial<Record<MessageKey, string>>;
9
9
  /** The Simplified Chinese locale pack. */
10
10
  export declare const zhCNLocale: {
11
11
  readonly id: "zh-CN";
12
- readonly messages: Partial<Record<"common.aborted" | "common.average" | "common.cache-read" | "common.cache-write" | "common.cancel" | "common.close" | "common.copied" | "common.copy" | "common.cost" | "common.duration" | "common.entry-id" | "common.error" | "common.error-rate" | "common.failed" | "common.filter" | "common.input" | "common.model" | "common.models" | "common.no-data-in-range" | "common.nothing-here-yet" | "common.other-n" | "common.output" | "common.output-message" | "common.output-tokens-per-second" | "common.pages" | "common.premium" | "common.premium-requests" | "common.project" | "common.provider" | "common.providers" | "common.range-1h" | "common.range-24h" | "common.range-30d" | "common.range-7d" | "common.range-90d" | "common.range-all" | "common.request" | "common.request-id" | "common.requests" | "common.root" | "common.session-entry" | "common.share" | "common.show-more" | "common.started" | "common.time-range" | "common.tokens" | "common.tokens-per-second" | "common.total" | "common.unknown" | "common.window-1h" | "common.window-24h" | "common.window-30d" | "common.window-7d" | "common.window-90d" | "common.window-all" | "costs.active-days-n" | "costs.active-days-one" | "costs.all-priced" | "costs.api-equivalent-estimate" | "costs.api-estimate-title" | "costs.average-per-day" | "costs.average-per-day-title" | "costs.by-component" | "costs.by-model" | "costs.by-model-description" | "costs.daily-by-component" | "costs.daily-by-model" | "costs.daily-estimate" | "costs.description" | "costs.empty-hint" | "costs.estimate" | "costs.estimate-by-component" | "costs.estimate-title" | "costs.no-priced-usage" | "costs.no-usage-in-range" | "costs.nothing-priced-yet" | "costs.per-priced-request" | "costs.per-priced-request-title" | "costs.per-request" | "costs.per-request-title" | "costs.priced-n" | "costs.requests-n" | "costs.split" | "costs.split-by" | "costs.split-title" | "costs.title" | "costs.top-model" | "costs.top-model-hint" | "costs.unpriced" | "costs.unpriced-column-title" | "costs.unpriced-hint" | "costs.unpriced-note-n" | "costs.unpriced-note-one" | "costs.unpriced-requests" | "costs.unpriced-title" | "costs.where-it-went" | "errors.affected-models" | "errors.by-model" | "errors.by-model-description" | "errors.cost-hint" | "errors.description" | "errors.empty-all-succeeded" | "errors.empty-no-affected-models" | "errors.empty-no-failures" | "errors.error-signatures" | "errors.failures" | "errors.filtered-of-total" | "errors.footer" | "errors.group-meta" | "errors.hint-in-window" | "errors.hint-latest-loaded" | "errors.last-failure" | "errors.last-seen" | "errors.load-latest" | "errors.loading" | "errors.loading-failures" | "errors.more-models" | "errors.no-match" | "errors.no-match-hint" | "errors.none-in-window" | "errors.open-latest" | "errors.search-placeholder" | "errors.signature" | "errors.signatures" | "errors.signatures-card-description" | "errors.signatures-hint" | "errors.signatures-top" | "errors.title" | "errors.unknown-error" | "errors.when" | "frustration.all-classes" | "frustration.all-judged" | "frustration.angry" | "frustration.angry-title" | "frustration.annoyed" | "frustration.annoyed-title" | "frustration.at-assistant" | "frustration.at-assistant-title" | "frustration.by-model" | "frustration.by-model-description" | "frustration.cancel-run" | "frustration.cancelled" | "frustration.cancelling" | "frustration.classify" | "frustration.column-judged" | "frustration.column-mean" | "frustration.column-messages" | "frustration.column-model-ids" | "frustration.column-session" | "frustration.column-tool-errors" | "frustration.column-turns" | "frustration.cost-so-far" | "frustration.coverage-hint" | "frustration.coverage-unscored" | "frustration.description" | "frustration.done" | "frustration.elapsed" | "frustration.estimate-note" | "frustration.estimated-cost" | "frustration.estimating" | "frustration.every-model-class" | "frustration.failed" | "frustration.failed-n" | "frustration.failed-suffix" | "frustration.hatch-title" | "frustration.hide-regex-title" | "frustration.in-flight" | "frustration.in-time" | "frustration.include-small" | "frustration.input-tokens" | "frustration.judge-coverage" | "frustration.judge-coverage-title" | "frustration.judge-description" | "frustration.judge-model" | "frustration.judge-panel" | "frustration.judge-running" | "frustration.judge-with" | "frustration.last-run-cancelled" | "frustration.last-run-done" | "frustration.last-run-failed" | "frustration.legend-angry" | "frustration.legend-annoyed-any" | "frustration.legend-assistant" | "frustration.legend-hatched" | "frustration.legend-hatched-run" | "frustration.legend-hue" | "frustration.legend-other" | "frustration.legend-trend" | "frustration.messages-n" | "frustration.messages-progress" | "frustration.model-breakdown" | "frustration.model-breakdown-title" | "frustration.model-by-model" | "frustration.model-by-model-description" | "frustration.model-class" | "frustration.model-hedge" | "frustration.model-hedge-title" | "frustration.model-hint-mean" | "frustration.model-legend-breakdown" | "frustration.model-mean" | "frustration.model-mean-title" | "frustration.model-no-turns" | "frustration.model-over-time" | "frustration.model-over-time-description" | "frustration.model-rambling" | "frustration.model-rambling-title" | "frustration.model-restatement" | "frustration.model-restatement-title" | "frustration.model-self-blame" | "frustration.model-self-blame-title" | "frustration.model-sessions" | "frustration.model-sessions-description" | "frustration.model-side-description" | "frustration.model-side-title" | "frustration.model-spiral" | "frustration.model-spiral-title" | "frustration.model-table-description" | "frustration.model-table-title" | "frustration.model-try-longer-range" | "frustration.model-turns" | "frustration.model-turns-title" | "frustration.model-unscored-line" | "frustration.model-versions" | "frustration.mostly-regex" | "frustration.no-messages" | "frustration.no-model-match" | "frustration.no-run-yet" | "frustration.not-run-yet" | "frustration.proceed" | "frustration.prose-chars" | "frustration.quote-title" | "frustration.regex-badge" | "frustration.regex-only" | "frustration.row-mostly-regex" | "frustration.run-cancelled-after" | "frustration.run-cancelled-line" | "frustration.run-done-line" | "frustration.run-failed-after" | "frustration.run-failed-line" | "frustration.run-judged" | "frustration.running" | "frustration.small-models-title" | "frustration.stack-relation" | "frustration.starting" | "frustration.table-description" | "frustration.title" | "frustration.try-longer-range" | "frustration.turns-n" | "frustration.unclassified" | "frustration.unjudged-messages" | "frustration.unknown-error" | "frustration.unscored-line" | "frustration.user-messages" | "frustration.user-messages-title" | "gain.all-projects" | "gain.by-source" | "gain.by-source-description" | "gain.cumulative" | "gain.description" | "gain.description-scoped" | "gain.empty-hint-all" | "gain.empty-hint-longer-range" | "gain.empty-title" | "gain.empty-title-scoped" | "gain.hits" | "gain.reduction" | "gain.reduction-title" | "gain.reduction-unknown" | "gain.saved-bytes" | "gain.saved-per-day" | "gain.saved-per-hit" | "gain.saved-tokens" | "gain.savings-over-time" | "gain.savings-over-time-description" | "gain.source" | "gain.title" | "models.across-providers" | "models.across-providers-one" | "models.all-models" | "models.api-cost" | "models.api-cost-title" | "models.avg-duration" | "models.avg-ttft" | "models.bucket-5m" | "models.bucket-day" | "models.bucket-hour" | "models.cache-rate" | "models.cache-savings" | "models.cache-title" | "models.cost-title" | "models.count-desc" | "models.efficiency" | "models.empty-hint" | "models.empty-title" | "models.errors" | "models.failed-n" | "models.first-last-seen" | "models.latency" | "models.most-used" | "models.most-used-hint" | "models.no-samples-hint" | "models.no-samples-title" | "models.performance-per-bucket" | "models.request-share" | "models.share-desc" | "models.share-mode-aria" | "models.subtitle" | "models.table-desc" | "models.tok-per-second" | "models.tokens-title" | "models.tps-title" | "models.trend" | "models.trend-title" | "models.ttft" | "models.ttft-title" | "models.uncached-input" | "models.unpriced-n" | "models.used" | "nav.activity" | "nav.breadcrumb-stats" | "nav.costs" | "nav.dashboard" | "nav.errors" | "nav.frustration" | "nav.gain" | "nav.insights" | "nav.models" | "nav.open-navigation" | "nav.overview" | "nav.pages" | "nav.projects" | "nav.providers" | "nav.requests" | "nav.tools" | "nav.traces" | "nav.usage" | "overview.activity" | "overview.advisor" | "overview.all-requests" | "overview.api-cost-title" | "overview.api-equivalent" | "overview.api-equivalent-cost" | "overview.avg-latency" | "overview.avg-ttft" | "overview.by-agent" | "overview.cache-rate" | "overview.cache-rate-title" | "overview.cache-saved" | "overview.conversation-tokens" | "overview.description" | "overview.failed-n" | "overview.latest-requests" | "overview.latest-requests-description" | "overview.main-agent" | "overview.ok" | "overview.output-n" | "overview.per-5m" | "overview.per-day" | "overview.per-hour" | "overview.requests-short" | "overview.status" | "overview.subagents" | "overview.succeeded" | "overview.succeeded-n" | "overview.title" | "overview.token-mix" | "overview.token-mix-description" | "overview.tokens-title" | "overview.uncached-input" | "overview.unpriced-n" | "overview.when" | "projects.api-equivalent-cost" | "projects.api-equivalent-cost-hint" | "projects.api-equivalent-cost-rates" | "projects.avg-duration" | "projects.cache-rate" | "projects.cache-rate-hint" | "projects.cache-rate-hint-all" | "projects.cache-savings" | "projects.cache-savings-hint" | "projects.conversation-tokens" | "projects.conversation-tokens-hint" | "projects.description" | "projects.empty-no-folders" | "projects.empty-no-match" | "projects.empty-only-temporary" | "projects.errors" | "projects.failed-n" | "projects.filter-placeholder" | "projects.folder" | "projects.folders" | "projects.folders-n" | "projects.folders-of-n" | "projects.hide-temporary" | "projects.hide-temporary-hint" | "projects.hint-longer-range" | "projects.hint-temporary-hidden" | "projects.last-active" | "projects.share-of-cost" | "projects.share-of-requests" | "projects.temp" | "projects.temporary-n" | "projects.title" | "projects.top-by-cost" | "projects.top-by-cost-description" | "projects.top-by-requests" | "projects.top-by-requests-description" | "projects.unpriced-n" | "providers.account" | "providers.accounts" | "providers.accounts-needed" | "providers.accounts-needed-title" | "providers.accounts-title" | "providers.all-providers" | "providers.api-cost-title" | "providers.api-equivalent-cost" | "providers.bucket-5m" | "providers.bucket-day" | "providers.bucket-hour" | "providers.burn" | "providers.burn-description" | "providers.burn-estimate-description" | "providers.cost-title" | "providers.description" | "providers.exhausted" | "providers.exhausted-n" | "providers.exhaustions" | "providers.exhaustions-title" | "providers.failed-n" | "providers.high" | "providers.hours-description" | "providers.ideal-accounts" | "providers.latest-used" | "providers.models-title" | "providers.most-tokens" | "providers.no-activity-in-range" | "providers.no-provider-activity" | "providers.no-reading" | "providers.no-requests-in-range" | "providers.no-snapshots-in-range" | "providers.no-utilization-readings" | "providers.peak-burn-hours" | "providers.peak-hours-description" | "providers.peak-in-range" | "providers.peak-utilization" | "providers.peak-utilization-title" | "providers.premium-title" | "providers.recorded" | "providers.resets" | "providers.resets-title" | "providers.share-title" | "providers.snapshot-hint" | "providers.snapshots" | "providers.status" | "providers.subscription-windows" | "providers.subscription-windows-description" | "providers.succeeded-n" | "providers.tokens-peak-hour" | "providers.tokens-per-window" | "providers.tokens-per-window-title" | "providers.tokens-title" | "providers.too-little-consumed" | "providers.totals" | "providers.totals-description" | "providers.totals-description-unpriced-n" | "providers.totals-description-unpriced-one" | "providers.tps-title" | "providers.unpriced-n" | "providers.utilization" | "providers.utilization-description" | "providers.utilization-description-plain" | "providers.window" | "providers.windows-burned" | "providers.windows-burned-title" | "requests.api-equivalent-cost" | "requests.cache-read-title" | "requests.cache-write" | "requests.cost-estimate" | "requests.cost-hint" | "requests.description" | "requests.empty-in-window" | "requests.empty-no-match" | "requests.empty-no-match-hint" | "requests.failed-hint" | "requests.footer" | "requests.load-latest" | "requests.loading" | "requests.log-count-in-window" | "requests.log-count-latest" | "requests.log-loading" | "requests.log-title" | "requests.median-duration" | "requests.median-ttft" | "requests.none-in-window" | "requests.p95" | "requests.search-placeholder" | "requests.since" | "requests.status" | "requests.status-all" | "requests.tokens-hint" | "requests.ttft" | "requests.ttft-hint" | "requests.uncached-input" | "requests.unpriced-n" | "requests.when" | "shell.building-rollups" | "shell.dark-theme" | "shell.indexing" | "shell.ingesting" | "shell.jump-hint" | "shell.just-now" | "shell.light-theme" | "shell.live" | "shell.lost-connection" | "shell.range-suffix" | "shell.reconnecting" | "shell.sync-failed" | "shell.sync-now" | "shell.syncing" | "shell.system-theme" | "tools.all-tools" | "tools.api-equivalent" | "tools.args" | "tools.args-chars-title" | "tools.attr-cost" | "tools.attr-tokens" | "tools.attributed-cost" | "tools.attributed-tokens" | "tools.attribution-estimate" | "tools.attribution-note" | "tools.avg-args-per-call" | "tools.avg-result-per-call" | "tools.bucket-5m" | "tools.bucket-day" | "tools.bucket-hour" | "tools.by-tool" | "tools.by-tool-and-model" | "tools.by-tool-and-model-description" | "tools.by-tool-description" | "tools.call-arguments" | "tools.calls" | "tools.calls-over-time" | "tools.chars" | "tools.chart-description" | "tools.clear-tool-filter" | "tools.description" | "tools.distinct-tools" | "tools.error-rate-title" | "tools.errors" | "tools.errors-over-time" | "tools.errors-title" | "tools.failed-n" | "tools.filter-by-tool" | "tools.last-used" | "tools.most-used" | "tools.no-calls-to-tool" | "tools.no-tool-calls" | "tools.no-tool-errors" | "tools.result" | "tools.result-chars-title" | "tools.result-per-call" | "tools.result-per-call-title" | "tools.result-text" | "tools.share-of-calls" | "tools.share-of-cost" | "tools.share-of-tokens" | "tools.succeeded-n" | "tools.title" | "tools.tokens-output" | "tools.tool" | "tools.tool-calls" | "tools.trend" | "tools.trend-title" | "tools.unpriced-requests-n" | "trace.agent" | "trace.agents" | "trace.args" | "trace.avg" | "trace.axis-mode" | "trace.background" | "trace.bg" | "trace.calls" | "trace.canvas-aria" | "trace.collapse-all" | "trace.collapse-track" | "trace.compress-idle" | "trace.copy-json" | "trace.description" | "trace.empty-hint" | "trace.empty-title" | "trace.error-message" | "trace.errors" | "trace.expand-all" | "trace.expand-track" | "trace.failed-n" | "trace.filter-placeholder" | "trace.idle" | "trace.io-cached" | "trace.load-failed" | "trace.max" | "trace.minimap-aria" | "trace.mode-calls-title" | "trace.mode-time-title" | "trace.mode-turns-title" | "trace.model-time" | "trace.most-recent" | "trace.most-recent-filtered" | "trace.next-match" | "trace.no-matching-events" | "trace.no-matching-hint" | "trace.no-matching-title" | "trace.of-wall" | "trace.open-child-track" | "trace.previous-match" | "trace.raw-entry" | "trace.refresh" | "trace.search-placeholder" | "trace.session" | "trace.sessions" | "trace.start" | "trace.stop-reason" | "trace.task" | "trace.text" | "trace.thinking" | "trace.timeline" | "trace.timeline-hint" | "trace.title" | "trace.tok" | "trace.tool" | "trace.tool-aggregates" | "trace.tool-aggregates-desc" | "trace.tool-calls-n" | "trace.tool-time" | "trace.tools" | "trace.track" | "trace.transcript" | "trace.transcript-description" | "trace.ttft" | "trace.turns" | "trace.unterminated" | "trace.wall-time" | "ui.api" | "ui.delta-new" | "ui.json" | "ui.open-trace" | "ui.retry" | "ui.section-cost" | "ui.section-identity" | "ui.section-timing" | "ui.session-file" | "ui.show-fewer" | "ui.stats-row" | "ui.status-ok" | "ui.stop-reason" | "ui.table-shown-of" | "ui.time-to-first-token" | "ui.trace" | "ui.uncached-input", string>>;
12
+ readonly messages: Partial<Record<"common.aborted" | "common.average" | "common.cache-read" | "common.cache-write" | "common.cancel" | "common.close" | "common.copied" | "common.copy" | "common.cost" | "common.duration" | "common.entry-id" | "common.error" | "common.error-rate" | "common.failed" | "common.filter" | "common.input" | "common.model" | "common.models" | "common.no-data-in-range" | "common.nothing-here-yet" | "common.other-n" | "common.output" | "common.output-message" | "common.output-tokens-per-second" | "common.pages" | "common.premium" | "common.premium-requests" | "common.project" | "common.provider" | "common.providers" | "common.range-1h" | "common.range-24h" | "common.range-30d" | "common.range-7d" | "common.range-90d" | "common.range-all" | "common.request" | "common.request-id" | "common.requests" | "common.root" | "common.session-entry" | "common.share" | "common.show-more" | "common.started" | "common.time-range" | "common.tokens" | "common.tokens-per-second" | "common.total" | "common.unknown" | "common.window-1h" | "common.window-24h" | "common.window-30d" | "common.window-7d" | "common.window-90d" | "common.window-all" | "costs.active-days-n" | "costs.active-days-one" | "costs.all-priced" | "costs.api-equivalent-estimate" | "costs.api-estimate-title" | "costs.average-per-day" | "costs.average-per-day-title" | "costs.by-component" | "costs.by-model" | "costs.by-model-description" | "costs.daily-by-component" | "costs.daily-by-model" | "costs.daily-estimate" | "costs.description" | "costs.empty-hint" | "costs.estimate" | "costs.estimate-by-component" | "costs.estimate-title" | "costs.no-priced-usage" | "costs.no-usage-in-range" | "costs.nothing-priced-yet" | "costs.per-priced-request" | "costs.per-priced-request-title" | "costs.per-request" | "costs.per-request-title" | "costs.priced-n" | "costs.requests-n" | "costs.split" | "costs.split-by" | "costs.split-title" | "costs.title" | "costs.top-model" | "costs.top-model-hint" | "costs.unpriced" | "costs.unpriced-column-title" | "costs.unpriced-hint" | "costs.unpriced-note-n" | "costs.unpriced-note-one" | "costs.unpriced-requests" | "costs.unpriced-title" | "costs.where-it-went" | "errors.affected-models" | "errors.by-model" | "errors.by-model-description" | "errors.cost-hint" | "errors.description" | "errors.empty-all-succeeded" | "errors.empty-no-affected-models" | "errors.empty-no-failures" | "errors.error-signatures" | "errors.failures" | "errors.filtered-of-total" | "errors.footer" | "errors.group-meta" | "errors.hint-in-window" | "errors.hint-latest-loaded" | "errors.last-failure" | "errors.last-seen" | "errors.load-latest" | "errors.loading" | "errors.loading-failures" | "errors.more-models" | "errors.no-match" | "errors.no-match-hint" | "errors.none-in-window" | "errors.open-latest" | "errors.search-placeholder" | "errors.signature" | "errors.signatures" | "errors.signatures-card-description" | "errors.signatures-hint" | "errors.signatures-top" | "errors.title" | "errors.unknown-error" | "errors.when" | "frustration.all-classes" | "frustration.all-judged" | "frustration.angry" | "frustration.angry-title" | "frustration.annoyed" | "frustration.annoyed-title" | "frustration.at-assistant" | "frustration.at-assistant-title" | "frustration.by-model" | "frustration.by-model-description" | "frustration.cancel-run" | "frustration.cancelled" | "frustration.cancelling" | "frustration.classify" | "frustration.column-judged" | "frustration.column-mean" | "frustration.column-messages" | "frustration.column-model-ids" | "frustration.column-session" | "frustration.column-tool-errors" | "frustration.column-turns" | "frustration.cost-so-far" | "frustration.coverage-hint" | "frustration.coverage-unscored" | "frustration.description" | "frustration.done" | "frustration.elapsed" | "frustration.estimate-note" | "frustration.estimated-cost" | "frustration.estimating" | "frustration.every-model-class" | "frustration.failed" | "frustration.failed-n" | "frustration.failed-suffix" | "frustration.hatch-title" | "frustration.hide-regex-title" | "frustration.in-flight" | "frustration.in-time" | "frustration.include-small" | "frustration.input-tokens" | "frustration.judge-coverage" | "frustration.judge-coverage-title" | "frustration.judge-description" | "frustration.judge-model" | "frustration.judge-panel" | "frustration.judge-running" | "frustration.judge-with" | "frustration.last-run-cancelled" | "frustration.last-run-done" | "frustration.last-run-failed" | "frustration.legend-angry" | "frustration.legend-annoyed-any" | "frustration.legend-assistant" | "frustration.legend-hatched" | "frustration.legend-hatched-run" | "frustration.legend-hue" | "frustration.legend-other" | "frustration.legend-trend" | "frustration.messages-n" | "frustration.messages-progress" | "frustration.model-breakdown" | "frustration.model-breakdown-title" | "frustration.model-by-model" | "frustration.model-by-model-description" | "frustration.model-class" | "frustration.model-hedge" | "frustration.model-hedge-title" | "frustration.model-hint-mean" | "frustration.model-legend-breakdown" | "frustration.model-mean" | "frustration.model-mean-title" | "frustration.model-no-turns" | "frustration.model-over-time" | "frustration.model-over-time-description" | "frustration.model-rambling" | "frustration.model-rambling-title" | "frustration.model-restatement" | "frustration.model-restatement-title" | "frustration.model-self-blame" | "frustration.model-self-blame-title" | "frustration.model-sessions" | "frustration.model-sessions-description" | "frustration.model-side-description" | "frustration.model-side-title" | "frustration.model-spiral" | "frustration.model-spiral-title" | "frustration.model-table-description" | "frustration.model-table-title" | "frustration.model-try-longer-range" | "frustration.model-turns" | "frustration.model-turns-title" | "frustration.model-unscored-line" | "frustration.model-versions" | "frustration.mostly-regex" | "frustration.no-messages" | "frustration.no-model-match" | "frustration.no-run-yet" | "frustration.not-run-yet" | "frustration.proceed" | "frustration.prose-chars" | "frustration.quote-title" | "frustration.regex-badge" | "frustration.regex-only" | "frustration.row-mostly-regex" | "frustration.run-cancelled-after" | "frustration.run-cancelled-line" | "frustration.run-done-line" | "frustration.run-failed-after" | "frustration.run-failed-line" | "frustration.run-judged" | "frustration.running" | "frustration.small-models-title" | "frustration.stack-relation" | "frustration.starting" | "frustration.table-description" | "frustration.title" | "frustration.try-longer-range" | "frustration.turns-n" | "frustration.unclassified" | "frustration.unjudged-messages" | "frustration.unknown-error" | "frustration.unscored-line" | "frustration.user-messages" | "frustration.user-messages-title" | "gain.all-projects" | "gain.by-source" | "gain.by-source-description" | "gain.cumulative" | "gain.description" | "gain.description-scoped" | "gain.empty-hint-all" | "gain.empty-hint-longer-range" | "gain.empty-title" | "gain.empty-title-scoped" | "gain.hits" | "gain.reduction" | "gain.reduction-title" | "gain.reduction-unknown" | "gain.saved-bytes" | "gain.saved-per-day" | "gain.saved-per-hit" | "gain.saved-tokens" | "gain.savings-over-time" | "gain.savings-over-time-description" | "gain.source" | "gain.title" | "models.across-providers" | "models.across-providers-one" | "models.all-models" | "models.api-cost" | "models.api-cost-title" | "models.avg-duration" | "models.avg-ttft" | "models.bucket-5m" | "models.bucket-day" | "models.bucket-hour" | "models.cache-rate" | "models.cache-savings" | "models.cache-title" | "models.cost-title" | "models.count-desc" | "models.efficiency" | "models.empty-hint" | "models.empty-title" | "models.errors" | "models.failed-n" | "models.first-last-seen" | "models.latency" | "models.most-used" | "models.most-used-hint" | "models.no-samples-hint" | "models.no-samples-title" | "models.performance-per-bucket" | "models.request-share" | "models.share-desc" | "models.share-mode-aria" | "models.subtitle" | "models.table-desc" | "models.tok-per-second" | "models.tokens-title" | "models.tps-title" | "models.trend" | "models.trend-title" | "models.ttft" | "models.ttft-title" | "models.uncached-input" | "models.unpriced-n" | "models.used" | "nav.activity" | "nav.breadcrumb-stats" | "nav.costs" | "nav.dashboard" | "nav.errors" | "nav.frustration" | "nav.gain" | "nav.insights" | "nav.models" | "nav.open-navigation" | "nav.overview" | "nav.pages" | "nav.projects" | "nav.providers" | "nav.requests" | "nav.tools" | "nav.traces" | "nav.usage" | "overview.activity" | "overview.advisor" | "overview.all-requests" | "overview.api-cost-title" | "overview.api-equivalent" | "overview.api-equivalent-cost" | "overview.avg-latency" | "overview.avg-ttft" | "overview.by-agent" | "overview.cache-rate" | "overview.cache-rate-title" | "overview.cache-saved" | "overview.conversation-tokens" | "overview.description" | "overview.failed-n" | "overview.latest-requests" | "overview.latest-requests-description" | "overview.main-agent" | "overview.ok" | "overview.output-n" | "overview.per-5m" | "overview.per-day" | "overview.per-hour" | "overview.requests-short" | "overview.status" | "overview.subagents" | "overview.succeeded" | "overview.succeeded-n" | "overview.title" | "overview.token-mix" | "overview.token-mix-description" | "overview.tokens-title" | "overview.uncached-input" | "overview.unpriced-n" | "overview.when" | "projects.api-equivalent-cost" | "projects.api-equivalent-cost-hint" | "projects.api-equivalent-cost-rates" | "projects.avg-duration" | "projects.cache-rate" | "projects.cache-rate-hint" | "projects.cache-rate-hint-all" | "projects.cache-savings" | "projects.cache-savings-hint" | "projects.conversation-tokens" | "projects.conversation-tokens-hint" | "projects.description" | "projects.empty-no-folders" | "projects.empty-no-match" | "projects.empty-only-temporary" | "projects.errors" | "projects.failed-n" | "projects.filter-placeholder" | "projects.folder" | "projects.folders" | "projects.folders-n" | "projects.folders-of-n" | "projects.hide-temporary" | "projects.hide-temporary-hint" | "projects.hint-longer-range" | "projects.hint-temporary-hidden" | "projects.last-active" | "projects.share-of-cost" | "projects.share-of-requests" | "projects.temp" | "projects.temporary-n" | "projects.title" | "projects.top-by-cost" | "projects.top-by-cost-description" | "projects.top-by-requests" | "projects.top-by-requests-description" | "projects.unpriced-n" | "providers.account" | "providers.accounts" | "providers.accounts-needed" | "providers.accounts-needed-title" | "providers.accounts-title" | "providers.all-providers" | "providers.api-cost-title" | "providers.api-equivalent-cost" | "providers.bucket-5m" | "providers.bucket-day" | "providers.bucket-hour" | "providers.burn" | "providers.burn-description" | "providers.burn-estimate-description" | "providers.cost-title" | "providers.description" | "providers.exhausted" | "providers.exhausted-n" | "providers.exhaustions" | "providers.exhaustions-title" | "providers.failed-n" | "providers.high" | "providers.hours-description" | "providers.ideal-accounts" | "providers.latest-used" | "providers.models-title" | "providers.most-tokens" | "providers.no-activity-in-range" | "providers.no-provider-activity" | "providers.no-reading" | "providers.no-requests-in-range" | "providers.no-snapshots-in-range" | "providers.no-utilization-readings" | "providers.peak-burn-hours" | "providers.peak-hours-description" | "providers.peak-in-range" | "providers.peak-utilization" | "providers.peak-utilization-title" | "providers.premium-title" | "providers.recorded" | "providers.resets" | "providers.resets-title" | "providers.share-title" | "providers.snapshot-hint" | "providers.snapshots" | "providers.status" | "providers.subscription-windows" | "providers.subscription-windows-description" | "providers.succeeded-n" | "providers.tokens-peak-hour" | "providers.tokens-per-window" | "providers.tokens-per-window-title" | "providers.tokens-title" | "providers.too-little-consumed" | "providers.totals" | "providers.totals-description" | "providers.totals-description-unpriced-n" | "providers.totals-description-unpriced-one" | "providers.tps-title" | "providers.unpriced-n" | "providers.utilization" | "providers.utilization-description" | "providers.utilization-description-plain" | "providers.window" | "providers.windows-burned" | "providers.windows-burned-title" | "requests.api-equivalent-cost" | "requests.cache-read-title" | "requests.cache-write" | "requests.cost-estimate" | "requests.cost-hint" | "requests.description" | "requests.empty-in-window" | "requests.empty-no-match" | "requests.empty-no-match-hint" | "requests.failed-hint" | "requests.footer" | "requests.load-latest" | "requests.loading" | "requests.log-count-in-window" | "requests.log-count-latest" | "requests.log-loading" | "requests.log-title" | "requests.median-duration" | "requests.median-ttft" | "requests.none-in-window" | "requests.p95" | "requests.search-placeholder" | "requests.since" | "requests.status" | "requests.status-all" | "requests.tokens-hint" | "requests.ttft" | "requests.ttft-hint" | "requests.uncached-input" | "requests.unpriced-n" | "requests.when" | "shell.building-rollups" | "shell.dark-theme" | "shell.indexing" | "shell.ingesting" | "shell.jump-hint" | "shell.just-now" | "shell.language" | "shell.light-theme" | "shell.live" | "shell.lost-connection" | "shell.range-suffix" | "shell.reconnecting" | "shell.switch-language" | "shell.sync-failed" | "shell.sync-now" | "shell.syncing" | "shell.system-theme" | "tools.all-tools" | "tools.api-equivalent" | "tools.args" | "tools.args-chars-title" | "tools.attr-cost" | "tools.attr-tokens" | "tools.attributed-cost" | "tools.attributed-tokens" | "tools.attribution-estimate" | "tools.attribution-note" | "tools.avg-args-per-call" | "tools.avg-result-per-call" | "tools.bucket-5m" | "tools.bucket-day" | "tools.bucket-hour" | "tools.by-tool" | "tools.by-tool-and-model" | "tools.by-tool-and-model-description" | "tools.by-tool-description" | "tools.call-arguments" | "tools.calls" | "tools.calls-over-time" | "tools.chars" | "tools.chart-description" | "tools.clear-tool-filter" | "tools.description" | "tools.distinct-tools" | "tools.error-rate-title" | "tools.errors" | "tools.errors-over-time" | "tools.errors-title" | "tools.failed-n" | "tools.filter-by-tool" | "tools.last-used" | "tools.most-used" | "tools.no-calls-to-tool" | "tools.no-tool-calls" | "tools.no-tool-errors" | "tools.result" | "tools.result-chars-title" | "tools.result-per-call" | "tools.result-per-call-title" | "tools.result-text" | "tools.share-of-calls" | "tools.share-of-cost" | "tools.share-of-tokens" | "tools.succeeded-n" | "tools.title" | "tools.tokens-output" | "tools.tool" | "tools.tool-calls" | "tools.trend" | "tools.trend-title" | "tools.unpriced-requests-n" | "trace.agent" | "trace.agents" | "trace.args" | "trace.avg" | "trace.axis-mode" | "trace.background" | "trace.bg" | "trace.calls" | "trace.canvas-aria" | "trace.collapse-all" | "trace.collapse-track" | "trace.compress-idle" | "trace.copy-json" | "trace.description" | "trace.empty-hint" | "trace.empty-title" | "trace.error-message" | "trace.errors" | "trace.expand-all" | "trace.expand-track" | "trace.failed-n" | "trace.filter-placeholder" | "trace.idle" | "trace.io-cached" | "trace.load-failed" | "trace.max" | "trace.minimap-aria" | "trace.mode-calls-title" | "trace.mode-time-title" | "trace.mode-turns-title" | "trace.model-time" | "trace.most-recent" | "trace.most-recent-filtered" | "trace.next-match" | "trace.no-matching-events" | "trace.no-matching-hint" | "trace.no-matching-title" | "trace.of-wall" | "trace.open-child-track" | "trace.previous-match" | "trace.raw-entry" | "trace.refresh" | "trace.search-placeholder" | "trace.session" | "trace.sessions" | "trace.start" | "trace.stop-reason" | "trace.task" | "trace.text" | "trace.thinking" | "trace.timeline" | "trace.timeline-hint" | "trace.title" | "trace.tok" | "trace.tool" | "trace.tool-aggregates" | "trace.tool-aggregates-desc" | "trace.tool-calls-n" | "trace.tool-time" | "trace.tools" | "trace.track" | "trace.transcript" | "trace.transcript-description" | "trace.ttft" | "trace.turns" | "trace.unterminated" | "trace.wall-time" | "ui.api" | "ui.delta-new" | "ui.json" | "ui.open-trace" | "ui.retry" | "ui.section-cost" | "ui.section-identity" | "ui.section-timing" | "ui.session-file" | "ui.show-fewer" | "ui.stats-row" | "ui.status-ok" | "ui.stop-reason" | "ui.table-shown-of" | "ui.time-to-first-token" | "ui.trace" | "ui.uncached-input", string>>;
13
13
  };
@@ -0,0 +1,7 @@
1
+ import { type StatsLocale } from "./index.js";
2
+ /**
3
+ * Subscribe a component to the active locale. `t()` reads the module-level
4
+ * catalogue, so a component that renders translated copy must call this —
5
+ * the topbar switch re-renders subscribers, and everything under them.
6
+ */
7
+ export declare function useLocale(): StatsLocale;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-stats",
4
- "version": "1.4.0",
4
+ "version": "1.4.2",
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.4.0",
43
- "@linxiraos/pi-catalog": "1.4.0",
44
- "@linxiraos/pi-utils": "1.4.0",
42
+ "@linxiraos/pi-ai": "1.4.2",
43
+ "@linxiraos/pi-catalog": "1.4.2",
44
+ "@linxiraos/pi-utils": "1.4.2",
45
45
  "lucide-react": "^1.24.0",
46
46
  "react": "19.2.7",
47
47
  "react-dom": "19.2.7"
@@ -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;
@@ -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
+ }
@@ -68,8 +68,6 @@ const TOKEN_MIX = [
68
68
  { key: "output", label: t("common.output"), color: "var(--chart-secondary)" },
69
69
  ] as const;
70
70
 
71
- const SNAPSHOT_HINT = t("providers.snapshot-hint");
72
-
73
71
  interface WindowRef {
74
72
  provider: string;
75
73
  windowKey: string;
@@ -608,7 +606,7 @@ function WindowInsightsTable({
608
606
  limit={12}
609
607
  selectedKey={selected ? `${selected.provider}::${selected.windowKey}` : null}
610
608
  onRowClick={i => onSelect({ provider: i.provider, windowKey: i.windowKey })}
611
- empty={<EmptyState title={t("providers.no-snapshots-in-range")} hint={SNAPSHOT_HINT} />}
609
+ empty={<EmptyState title={t("providers.no-snapshots-in-range")} hint={t("providers.snapshot-hint")} />}
612
610
  />
613
611
  );
614
612
  }
@@ -882,7 +880,7 @@ function WindowUtilizationCard({
882
880
  {insights === null ? (
883
881
  <ChartSkeleton height={260} />
884
882
  ) : selected === null ? (
885
- <EmptyState title={t("providers.no-snapshots-in-range")} hint={SNAPSHOT_HINT} />
883
+ <EmptyState title={t("providers.no-snapshots-in-range")} hint={t("providers.snapshot-hint")} />
886
884
  ) : (
887
885
  <QueryView query={accounts} skeleton={<ChartSkeleton height={260} />}>
888
886
  {() =>
@@ -52,8 +52,6 @@ const METRIC_OPTIONS = [
52
52
  /** Tools stacked individually in the calls chart; the rest fold into "Other". */
53
53
  const TOP_TOOLS = 6;
54
54
 
55
- const ATTRIBUTION_NOTE = t("tools.attribution-note");
56
-
57
55
  /** Bucket granularity label for the calls chart description. */
58
56
  function bucketUnit(bucketMs: number): string {
59
57
  if (bucketMs < 3_600_000) return t("tools.bucket-5m");
@@ -108,13 +106,13 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
108
106
  />
109
107
  <Stat
110
108
  label={t("tools.attributed-tokens")}
111
- title={ATTRIBUTION_NOTE}
109
+ title={t("tools.attribution-note")}
112
110
  value={formatCompact(Math.round(totals.tokens))}
113
111
  hint={t("tools.tokens-output", { tokens: formatCompact(Math.round(totals.output)) })}
114
112
  />
115
113
  <Stat
116
114
  label={t("tools.attributed-cost")}
117
- title={t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE })}
115
+ title={t("tools.attribution-estimate", { note: t("tools.attribution-note") })}
118
116
  value={formatEstimatedCost(totals.cost, totals.unpriced)}
119
117
  hint={
120
118
  totals.unpriced > 0
@@ -195,7 +193,7 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
195
193
  <Card
196
194
  index={2}
197
195
  title={t("tools.by-tool")}
198
- description={t("tools.by-tool-description", { note: ATTRIBUTION_NOTE })}
196
+ description={t("tools.by-tool-description", { note: t("tools.attribution-note") })}
199
197
  flush
200
198
  stale={tools.stale}
201
199
  >
@@ -432,7 +430,7 @@ function buildToolColumns(
432
430
  {
433
431
  key: "tokens",
434
432
  header: t("tools.attr-tokens"),
435
- title: ATTRIBUTION_NOTE,
433
+ title: t("tools.attribution-note"),
436
434
  align: "right",
437
435
  sort: row => row.totalTokensShare,
438
436
  render: row => (
@@ -445,7 +443,7 @@ function buildToolColumns(
445
443
  {
446
444
  key: "cost",
447
445
  header: t("tools.attr-cost"),
448
- title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
446
+ title: t("tools.attribution-estimate", { note: t("tools.attribution-note") }),
449
447
  align: "right",
450
448
  sort: row => row.costShare,
451
449
  render: row => (
@@ -549,7 +547,7 @@ function ToolModelTable({
549
547
  {
550
548
  key: "tokens",
551
549
  header: t("tools.attr-tokens"),
552
- title: ATTRIBUTION_NOTE,
550
+ title: t("tools.attribution-note"),
553
551
  align: "right",
554
552
  sort: row => row.totalTokensShare,
555
553
  render: row => <span className="num">{formatCompact(Math.round(row.totalTokensShare))}</span>,
@@ -557,7 +555,7 @@ function ToolModelTable({
557
555
  {
558
556
  key: "cost",
559
557
  header: t("tools.attr-cost"),
560
- title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
558
+ title: t("tools.attribution-estimate", { note: t("tools.attribution-note") }),
561
559
  align: "right",
562
560
  sort: row => row.costShare,
563
561
  render: row => <span className="num">{formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}</span>,
@@ -460,6 +460,19 @@ kbd {
460
460
  gap: 8px;
461
461
  }
462
462
 
463
+ /* Language switch: icon plus the language a click installs. */
464
+ .btn .topbar-locale-label {
465
+ font-size: 12px;
466
+ font-weight: 600;
467
+ letter-spacing: 0.02em;
468
+ }
469
+
470
+ @media (max-width: 640px) {
471
+ .btn .topbar-locale-label {
472
+ display: none;
473
+ }
474
+ }
475
+
463
476
  @media (min-width: 901px) {
464
477
  .btn.topbar-menu {
465
478
  display: none;
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { parseArgs } from "node:util";
4
- import { formatDuration, formatNumber, formatPercent, normalizePremiumRequests } from "@linxiraos/pi-utils";
4
+ import {
5
+ formatDuration,
6
+ formatNumber,
7
+ formatPercent,
8
+ normalizePremiumRequests,
9
+ ZETA_STATS_PORT,
10
+ } from "@linxiraos/pi-utils";
5
11
  import chalk from "@linxiraos/pi-utils/chalk";
6
12
  import { formatErrorRate } from "./client/data/formatters";
7
13
  import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
@@ -108,7 +114,7 @@ export function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs {
108
114
  const { values } = parseArgs({
109
115
  args,
110
116
  options: {
111
- port: { type: "string", short: "p", default: "3847" },
117
+ port: { type: "string", short: "p", default: String(ZETA_STATS_PORT) },
112
118
  host: { type: "string", default: "127.0.0.1" },
113
119
  json: { type: "boolean", short: "j", default: false },
114
120
  sync: { type: "boolean", short: "s", default: false },
@@ -117,7 +123,7 @@ export function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs {
117
123
  allowPositionals: true,
118
124
  });
119
125
  return {
120
- port: parseInt(values.port || "3847", 10),
126
+ port: parseInt(values.port || String(ZETA_STATS_PORT), 10),
121
127
  host: values.host || "127.0.0.1",
122
128
  json: values.json ?? false,
123
129
  sync: values.sync ?? false,
@@ -139,7 +145,7 @@ Usage:
139
145
  zeta-stats [options]
140
146
 
141
147
  Options:
142
- -p, --port <port> Port for the dashboard server (default: 3847)
148
+ -p, --port <port> Port for the dashboard server (default: ${ZETA_STATS_PORT})
143
149
  --host <host> Host to bind (default: 127.0.0.1)
144
150
  -j, --json Output stats as JSON and exit
145
151
  -s, --sync Sync session files and show summary