@data-club/ai-hub 0.0.1 → 0.0.3

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 (164) hide show
  1. package/dist/auth/locales/cs.d.ts +16 -0
  2. package/dist/auth/locales/cs.d.ts.map +1 -0
  3. package/dist/auth/locales/cs.js +394 -0
  4. package/dist/auth/locales/cs.js.map +1 -0
  5. package/dist/auth/locales/index.d.ts +24 -0
  6. package/dist/auth/locales/index.d.ts.map +1 -0
  7. package/dist/auth/locales/index.js +31 -0
  8. package/dist/auth/locales/index.js.map +1 -0
  9. package/dist/auth/locales/sk.d.ts +16 -0
  10. package/dist/auth/locales/sk.d.ts.map +1 -0
  11. package/dist/auth/locales/sk.js +394 -0
  12. package/dist/auth/locales/sk.js.map +1 -0
  13. package/dist/auth/texts.d.ts +24 -2
  14. package/dist/auth/texts.d.ts.map +1 -1
  15. package/dist/auth/texts.js +20 -7
  16. package/dist/auth/texts.js.map +1 -1
  17. package/dist/client/AIHubClient.d.ts +11 -0
  18. package/dist/client/AIHubClient.d.ts.map +1 -1
  19. package/dist/client/AIHubClient.js +10 -0
  20. package/dist/client/AIHubClient.js.map +1 -1
  21. package/dist/components/AgentsPicker/index.d.ts.map +1 -1
  22. package/dist/components/AgentsPicker/index.js +3 -2
  23. package/dist/components/AgentsPicker/index.js.map +1 -1
  24. package/dist/components/ChangePassword/index.d.ts.map +1 -1
  25. package/dist/components/ChangePassword/index.js +3 -2
  26. package/dist/components/ChangePassword/index.js.map +1 -1
  27. package/dist/components/ChatShell/ShellHeader.d.ts.map +1 -1
  28. package/dist/components/ChatShell/ShellHeader.js +4 -3
  29. package/dist/components/ChatShell/ShellHeader.js.map +1 -1
  30. package/dist/components/ChatShell/UserMenu.d.ts.map +1 -1
  31. package/dist/components/ChatShell/UserMenu.js +13 -1
  32. package/dist/components/ChatShell/UserMenu.js.map +1 -1
  33. package/dist/components/ChatShell/index.d.ts.map +1 -1
  34. package/dist/components/ChatShell/index.js +3 -2
  35. package/dist/components/ChatShell/index.js.map +1 -1
  36. package/dist/components/DataClubAIHub/index.d.ts +7 -1
  37. package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
  38. package/dist/components/DataClubAIHub/index.js +3 -1
  39. package/dist/components/DataClubAIHub/index.js.map +1 -1
  40. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.d.ts.map +1 -1
  41. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js +5 -23
  42. package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js.map +1 -1
  43. package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -1
  44. package/dist/components/DataClubAIHubAdmin/SettingsPage.js +45 -16
  45. package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
  46. package/dist/components/DataClubAIHubAdmin/index.d.ts.map +1 -1
  47. package/dist/components/DataClubAIHubAdmin/index.js +3 -2
  48. package/dist/components/DataClubAIHubAdmin/index.js.map +1 -1
  49. package/dist/components/DataClubAIHubLogin/index.d.ts.map +1 -1
  50. package/dist/components/DataClubAIHubLogin/index.js +3 -2
  51. package/dist/components/DataClubAIHubLogin/index.js.map +1 -1
  52. package/dist/components/EffortControl/index.d.ts.map +1 -1
  53. package/dist/components/EffortControl/index.js +3 -2
  54. package/dist/components/EffortControl/index.js.map +1 -1
  55. package/dist/components/LanguageSwitcher/index.d.ts +25 -0
  56. package/dist/components/LanguageSwitcher/index.d.ts.map +1 -0
  57. package/dist/components/LanguageSwitcher/index.js +73 -0
  58. package/dist/components/LanguageSwitcher/index.js.map +1 -0
  59. package/dist/components/MainColumn/index.d.ts.map +1 -1
  60. package/dist/components/MainColumn/index.js +3 -2
  61. package/dist/components/MainColumn/index.js.map +1 -1
  62. package/dist/components/MessageInput/index.d.ts.map +1 -1
  63. package/dist/components/MessageInput/index.js +3 -2
  64. package/dist/components/MessageInput/index.js.map +1 -1
  65. package/dist/components/MessageList/index.d.ts.map +1 -1
  66. package/dist/components/MessageList/index.js +3 -2
  67. package/dist/components/MessageList/index.js.map +1 -1
  68. package/dist/components/ReasoningView/index.d.ts.map +1 -1
  69. package/dist/components/ReasoningView/index.js +3 -2
  70. package/dist/components/ReasoningView/index.js.map +1 -1
  71. package/dist/components/ScrollToPresentButton/index.d.ts.map +1 -1
  72. package/dist/components/ScrollToPresentButton/index.js +3 -2
  73. package/dist/components/ScrollToPresentButton/index.js.map +1 -1
  74. package/dist/components/SessionsPanel/index.d.ts.map +1 -1
  75. package/dist/components/SessionsPanel/index.js +6 -34
  76. package/dist/components/SessionsPanel/index.js.map +1 -1
  77. package/dist/components/ThemeSwitcher/index.d.ts.map +1 -1
  78. package/dist/components/ThemeSwitcher/index.js +3 -2
  79. package/dist/components/ThemeSwitcher/index.js.map +1 -1
  80. package/dist/components/ToolCallView/index.d.ts.map +1 -1
  81. package/dist/components/ToolCallView/index.js +3 -2
  82. package/dist/components/ToolCallView/index.js.map +1 -1
  83. package/dist/components/TpmBanner/index.d.ts.map +1 -1
  84. package/dist/components/TpmBanner/index.js +3 -2
  85. package/dist/components/TpmBanner/index.js.map +1 -1
  86. package/dist/context/AIHubContext.d.ts +23 -0
  87. package/dist/context/AIHubContext.d.ts.map +1 -1
  88. package/dist/context/AIHubContext.js.map +1 -1
  89. package/dist/context/AIHubProvider.d.ts +13 -0
  90. package/dist/context/AIHubProvider.d.ts.map +1 -1
  91. package/dist/context/AIHubProvider.js +39 -2
  92. package/dist/context/AIHubProvider.js.map +1 -1
  93. package/dist/context/index.d.ts +1 -0
  94. package/dist/context/index.d.ts.map +1 -1
  95. package/dist/context/index.js +1 -0
  96. package/dist/context/index.js.map +1 -1
  97. package/dist/context/language.d.ts +44 -0
  98. package/dist/context/language.d.ts.map +1 -0
  99. package/dist/context/language.js +74 -0
  100. package/dist/context/language.js.map +1 -0
  101. package/dist/context/useAIHub.d.ts +8 -0
  102. package/dist/context/useAIHub.d.ts.map +1 -1
  103. package/dist/context/useAIHub.js +4 -0
  104. package/dist/context/useAIHub.js.map +1 -1
  105. package/dist/context/useAIHubTexts.d.ts +40 -0
  106. package/dist/context/useAIHubTexts.d.ts.map +1 -0
  107. package/dist/context/useAIHubTexts.js +52 -0
  108. package/dist/context/useAIHubTexts.js.map +1 -0
  109. package/dist/index.d.ts +4 -2
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +4 -1
  112. package/dist/index.js.map +1 -1
  113. package/dist/primitives/index.d.ts +3 -0
  114. package/dist/primitives/index.d.ts.map +1 -1
  115. package/dist/primitives/index.js +3 -0
  116. package/dist/primitives/index.js.map +1 -1
  117. package/dist/stories/_mockProvider.d.ts.map +1 -1
  118. package/dist/stories/_mockProvider.js +5 -0
  119. package/dist/stories/_mockProvider.js.map +1 -1
  120. package/dist/types.d.ts +13 -0
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/util/relativeTime.d.ts +49 -0
  123. package/dist/util/relativeTime.d.ts.map +1 -0
  124. package/dist/util/relativeTime.js +78 -0
  125. package/dist/util/relativeTime.js.map +1 -0
  126. package/package.json +1 -1
  127. package/src/auth/locales/cs.ts +452 -0
  128. package/src/auth/locales/index.ts +38 -0
  129. package/src/auth/locales/sk.ts +452 -0
  130. package/src/auth/texts.ts +45 -6
  131. package/src/client/AIHubClient.ts +22 -0
  132. package/src/components/AgentsPicker/index.tsx +3 -2
  133. package/src/components/ChangePassword/index.tsx +2 -2
  134. package/src/components/ChatShell/ShellHeader.tsx +2 -0
  135. package/src/components/ChatShell/UserMenu.tsx +14 -1
  136. package/src/components/ChatShell/index.tsx +2 -2
  137. package/src/components/DataClubAIHub/index.tsx +11 -1
  138. package/src/components/DataClubAIHubAdmin/SessionsOverviewPage.tsx +5 -19
  139. package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +138 -13
  140. package/src/components/DataClubAIHubAdmin/index.tsx +2 -2
  141. package/src/components/DataClubAIHubLogin/index.tsx +3 -2
  142. package/src/components/EffortControl/index.tsx +2 -2
  143. package/src/components/LanguageSwitcher/index.tsx +127 -0
  144. package/src/components/MainColumn/index.tsx +2 -2
  145. package/src/components/MessageInput/index.tsx +2 -2
  146. package/src/components/MessageList/index.tsx +2 -2
  147. package/src/components/ReasoningView/index.tsx +2 -2
  148. package/src/components/ScrollToPresentButton/index.tsx +2 -2
  149. package/src/components/SessionsPanel/index.tsx +5 -27
  150. package/src/components/ThemeSwitcher/index.tsx +3 -2
  151. package/src/components/ToolCallView/index.tsx +2 -2
  152. package/src/components/TpmBanner/index.tsx +2 -2
  153. package/src/context/AIHubContext.ts +25 -0
  154. package/src/context/AIHubProvider.tsx +68 -1
  155. package/src/context/index.ts +1 -0
  156. package/src/context/language.ts +81 -0
  157. package/src/context/useAIHub.ts +12 -0
  158. package/src/context/useAIHubTexts.ts +64 -0
  159. package/src/index.ts +13 -0
  160. package/src/primitives/index.ts +3 -0
  161. package/src/stories/_mockProvider.tsx +5 -0
  162. package/src/styles/default.css +79 -0
  163. package/src/types.ts +14 -0
  164. package/src/util/relativeTime.ts +90 -0
@@ -30,10 +30,11 @@
30
30
  import { useEffect, useMemo, useState, type ReactNode } from "react";
31
31
 
32
32
  import {
33
- resolveAIHubTexts,
34
33
  type AIHubTexts,
35
34
  type DeepPartial,
36
35
  } from "../../auth/texts.js";
36
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
37
+ import { formatRelativeTime } from "../../util/relativeTime.js";
37
38
  import { useAIHub } from "../../context/useAIHub.js";
38
39
  import type { Session } from "../../types.js";
39
40
 
@@ -125,30 +126,6 @@ export interface SessionsPanelProps {
125
126
  texts?: DeepPartial<AIHubTexts>;
126
127
  }
127
128
 
128
- /**
129
- * Formats an ISO-8601 timestamp into a short "N min ago" / "yesterday" /
130
- * "N days ago" line. Intentionally minimal — i18n libraries can take this
131
- * over later via the `texts.*` map if needed.
132
- *
133
- * @param iso - ISO timestamp.
134
- * @returns the short relative-time string.
135
- */
136
- function formatRelative(iso: string): string {
137
- const then = Date.parse(iso);
138
- if (Number.isNaN(then)) return "";
139
- const diffMs = Date.now() - then;
140
- const minutes = Math.round(diffMs / 60_000);
141
- if (minutes < 1) return "just now";
142
- if (minutes === 1) return "1 min ago";
143
- if (minutes < 60) return `${minutes} min ago`;
144
- const hours = Math.round(minutes / 60);
145
- if (hours === 1) return "1 hr ago";
146
- if (hours < 24) return `${hours} hr ago`;
147
- const days = Math.round(hours / 24);
148
- if (days === 1) return "yesterday";
149
- return `${days} days ago`;
150
- }
151
-
152
129
  /**
153
130
  * Renders the sessions panel.
154
131
  *
@@ -165,8 +142,9 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
165
142
  patchSession,
166
143
  includeArchivedSessions,
167
144
  setIncludeArchivedSessions,
145
+ language,
168
146
  } = useAIHub();
169
- const texts = resolveAIHubTexts(props.texts);
147
+ const texts = useResolvedTexts(props.texts);
170
148
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
171
149
  const showCreate = props.showCreateButton ?? true;
172
150
  const collapsible = props.collapsible ?? true;
@@ -313,7 +291,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
313
291
  >
314
292
  <span>{displayTitle}</span>
315
293
  <span className={cls.itemMeta}>
316
- {formatRelative(session.updated_at)}
294
+ {formatRelativeTime(session.updated_at, language)}
317
295
  </span>
318
296
  </button>
319
297
  <div className={cls.itemActions} data-testid="session-item-actions">
@@ -24,7 +24,8 @@
24
24
  import { type ReactNode } from "react";
25
25
 
26
26
  import type { AIHubChatTexts } from "../../auth/texts.js";
27
- import { resolveAIHubTexts, type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
27
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
28
+ import { type AIHubTexts, type DeepPartial } from "../../auth/texts.js";
28
29
  import { useAIHub } from "../../context/useAIHub.js";
29
30
 
30
31
  /**
@@ -79,7 +80,7 @@ export interface ThemeSwitcherProps {
79
80
  */
80
81
  export function ThemeSwitcher(props: ThemeSwitcherProps): ReactNode {
81
82
  const { theme, setTheme, themeSwitcherEnabled } = useAIHub();
82
- const texts = resolveAIHubTexts(props.texts);
83
+ const texts = useResolvedTexts(props.texts);
83
84
  const chatTexts: AIHubChatTexts = texts.chat;
84
85
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
85
86
  const showLabel = props.showLabel ?? true;
@@ -20,10 +20,10 @@
20
20
  import { useState, type ReactNode } from "react";
21
21
 
22
22
  import {
23
- resolveAIHubTexts,
24
23
  type AIHubTexts,
25
24
  type DeepPartial,
26
25
  } from "../../auth/texts.js";
26
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
27
27
  import { formatToolContentForDisplay } from "./formatContent.js";
28
28
 
29
29
  /** Status of a tool call. */
@@ -182,7 +182,7 @@ function statusText(
182
182
  * @returns the rendered card.
183
183
  */
184
184
  export function ToolCallView(props: ToolCallViewProps): ReactNode {
185
- const texts = resolveAIHubTexts(props.texts);
185
+ const texts = useResolvedTexts(props.texts);
186
186
  const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
187
187
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(
188
188
  props.defaultExpanded ?? false,
@@ -26,10 +26,10 @@
26
26
  import { useEffect, useState, type ReactNode } from "react";
27
27
 
28
28
  import {
29
- resolveAIHubTexts,
30
29
  type AIHubTexts,
31
30
  type DeepPartial,
32
31
  } from "../../auth/texts.js";
32
+ import { useResolvedTexts } from "../../context/useAIHubTexts.js";
33
33
  import { useAIHub } from "../../context/useAIHub.js";
34
34
 
35
35
  import { SystemNotice } from "../SystemNotice/index.js";
@@ -90,7 +90,7 @@ export interface TpmBannerProps {
90
90
  */
91
91
  export function TpmBanner(props: TpmBannerProps): ReactNode {
92
92
  const { state, lastTurnError } = useAIHub();
93
- const texts = resolveAIHubTexts(props.texts);
93
+ const texts = useResolvedTexts(props.texts);
94
94
 
95
95
  // Tick at 1Hz while we're in the wait phase. The interval is
96
96
  // re-installed when the deadline changes (new wait) or removed when
@@ -18,6 +18,7 @@ import type {
18
18
  PatchSessionOptions,
19
19
  } from "../client/AIHubClient.js";
20
20
  import type { AIHubEffort } from "../client/AIHubClient.js";
21
+ import type { AIHubLanguage, AIHubTexts } from "../auth/texts.js";
21
22
  import type {
22
23
  AIHubAuthContext,
23
24
  AIHubFeatureToggles,
@@ -219,6 +220,30 @@ export interface AIHubContextValue {
219
220
  */
220
221
  themeSwitcherEnabled: boolean;
221
222
 
223
+ /* ── language / i18n ────────────────────────────────────────────── */
224
+ /**
225
+ * Resolved UI language (`en` | `cs` | `sk`) — computed per the same
226
+ * precedence as theme: when switching is unlocked, the user's cached
227
+ * choice wins; otherwise the deployment default. Drives `texts`.
228
+ */
229
+ language: AIHubLanguage;
230
+ /**
231
+ * Switches the UI language and caches the choice on this device. No-op
232
+ * (and the switcher is hidden) when `languageSwitcherEnabled` is false.
233
+ */
234
+ setLanguage: (next: AIHubLanguage) => void;
235
+ /**
236
+ * Mirror of `!deployment.user_language_locked`. When false, the
237
+ * `<LanguageSwitcher />` renders nothing and every user sees the default.
238
+ */
239
+ languageSwitcherEnabled: boolean;
240
+ /**
241
+ * The fully-resolved, language-aware `AIHubTexts` — the selected built-in
242
+ * locale with any wrapper `texts` override merged on top. Components read
243
+ * this via `useAIHubTexts()` so a `setLanguage` re-renders the whole tree.
244
+ */
245
+ texts: AIHubTexts;
246
+
222
247
  /* ── feature toggles (§8.4, wired in Step 10) ──────────────────── */
223
248
  /**
224
249
  * The deployment-wide UI feature toggles fetched from public
@@ -57,6 +57,14 @@ import type {
57
57
  Session,
58
58
  } from "../types.js";
59
59
 
60
+ import {
61
+ resolveAIHubTexts,
62
+ type AIHubLanguage,
63
+ type AIHubTexts,
64
+ type DeepPartial,
65
+ } from "../auth/texts.js";
66
+ import { AIHUB_LOCALES } from "../auth/locales/index.js";
67
+
60
68
  import { aiHubContext, type AIHubContextValue } from "./AIHubContext.js";
61
69
  import {
62
70
  readCachedTheme,
@@ -64,6 +72,11 @@ import {
64
72
  writeCachedTheme,
65
73
  type ResolvedTheme,
66
74
  } from "./theme.js";
75
+ import {
76
+ readCachedLanguage,
77
+ resolveLanguage,
78
+ writeCachedLanguage,
79
+ } from "./language.js";
67
80
 
68
81
  /**
69
82
  * When the post-stream canonicalize refetches messages from the server,
@@ -154,6 +167,18 @@ export interface AIHubProviderProps {
154
167
  * is empty. Server config (`theme_default`) wins after hydration.
155
168
  */
156
169
  defaultTheme?: ResolvedTheme;
170
+ /**
171
+ * Fallback UI language used when no `/auth/me` has resolved yet and the
172
+ * cache is empty. Server config (`language_default`) wins after hydration.
173
+ * Defaults to `"en"`.
174
+ */
175
+ defaultLanguage?: AIHubLanguage;
176
+ /**
177
+ * Wrapper-supplied `texts` override, merged on top of the selected
178
+ * built-in locale (`en` | `cs` | `sk`). Any key the wrapper sets wins over
179
+ * the locale; omitted keys fall back to the locale, then English.
180
+ */
181
+ texts?: DeepPartial<AIHubTexts>;
157
182
  /** The tree under the provider. */
158
183
  children: ReactNode;
159
184
  }
@@ -171,6 +196,8 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
171
196
  children,
172
197
  client: clientProp,
173
198
  defaultTheme = "light",
199
+ defaultLanguage = "en",
200
+ texts: textsOverride,
174
201
  fetch: fetchProp,
175
202
  onError,
176
203
  tokenStorage = "localStorage",
@@ -207,6 +234,12 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
207
234
  const [theme, setThemeState] = useState<ResolvedTheme>(() => {
208
235
  return readCachedTheme() ?? defaultTheme;
209
236
  });
237
+ // ── language / i18n ──────────────────────────────────────────────────
238
+ // Seed from the device cache (FOUC-free first paint); reconciled against
239
+ // the deployment default once /auth/me resolves (see effect below).
240
+ const [language, setLanguageState] = useState<AIHubLanguage>(() => {
241
+ return readCachedLanguage() ?? defaultLanguage;
242
+ });
210
243
 
211
244
  // ── chat state (Step 9) ─────────────────────────────────────────────
212
245
  const [agents, setAgents] = useState<AgentSummary[]>([]);
@@ -381,6 +414,21 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
381
414
  writeCachedTheme(resolved);
382
415
  }, [user, deployment, defaultTheme]);
383
416
 
417
+ // ── reconcile resolved language once the deployment default lands ──
418
+ // Seed-only: when switching is unlocked the device-cached choice wins and
419
+ // we keep it; when locked we force the deployment default WITHOUT touching
420
+ // the cache, so unlocking later restores the user's prior choice.
421
+ useEffect(() => {
422
+ if (!deployment) return;
423
+ const resolved = resolveLanguage(
424
+ readCachedLanguage(),
425
+ deployment,
426
+ defaultLanguage,
427
+ );
428
+ setLanguageState(resolved);
429
+ if (!deployment.user_language_locked) writeCachedLanguage(resolved);
430
+ }, [deployment, defaultLanguage]);
431
+
384
432
  // ── load /agents whenever an authenticated user becomes available ───
385
433
  const refreshAgents = useCallback(async (): Promise<void> => {
386
434
  setAgentsLoading(true);
@@ -526,6 +574,14 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
526
574
  }
527
575
  };
528
576
 
577
+ // Language is a device-local preference (no server round-trip): set state
578
+ // and cache it. No-op when the deployment has locked the language.
579
+ const setLanguage: AIHubContextValue["setLanguage"] = (next) => {
580
+ if (deployment?.user_language_locked) return;
581
+ setLanguageState(next);
582
+ writeCachedLanguage(next);
583
+ };
584
+
529
585
  // ── chat actions ───────────────────────────────────────────────────
530
586
  const setActiveAgent: AIHubContextValue["setActiveAgent"] = (id) => {
531
587
  streamRef.current?.controller.abort();
@@ -1003,6 +1059,13 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1003
1059
  };
1004
1060
 
1005
1061
  const themeSwitcherEnabled = deployment?.theme_switcher_enabled ?? true;
1062
+ const languageSwitcherEnabled = !(deployment?.user_language_locked ?? false);
1063
+ // Resolve language-aware texts: the selected built-in locale with the
1064
+ // wrapper override merged on top. Recomputes when `language` flips.
1065
+ const texts: AIHubTexts = useMemo(
1066
+ () => resolveAIHubTexts(textsOverride, AIHUB_LOCALES[language]),
1067
+ [textsOverride, language],
1068
+ );
1006
1069
  const role: AIHubRole | null = user?.role ?? null;
1007
1070
 
1008
1071
  // Refresh the feature toggles on demand — used after the super-admin
@@ -1039,6 +1102,10 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1039
1102
  theme,
1040
1103
  setTheme,
1041
1104
  themeSwitcherEnabled,
1105
+ language,
1106
+ setLanguage,
1107
+ languageSwitcherEnabled,
1108
+ texts,
1042
1109
  agents,
1043
1110
  agentsLoading,
1044
1111
  refreshAgents,
@@ -1074,7 +1141,7 @@ export function AIHubProvider(props: AIHubProviderProps): ReactNode {
1074
1141
 
1075
1142
  return (
1076
1143
  <aiHubContext.Provider value={value}>
1077
- <div className="data-club-ai-hub" data-theme={theme}>
1144
+ <div className="data-club-ai-hub" data-theme={theme} lang={language}>
1078
1145
  {children}
1079
1146
  </div>
1080
1147
  </aiHubContext.Provider>
@@ -7,3 +7,4 @@
7
7
  export { AIHubProvider, type AIHubProviderProps } from "./AIHubProvider.js";
8
8
  export { useAIHub, type UseAIHubResult } from "./useAIHub.js";
9
9
  export { useAIHubAuth, type UseAIHubAuthResult } from "./useAIHubAuth.js";
10
+ export { useAIHubTexts, useResolvedTexts } from "./useAIHubTexts.js";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Language resolution helpers used by `<AIHubProvider>` — the i18n analogue
3
+ * of `theme.ts`.
4
+ *
5
+ * Precedence (mirrors §7.6 theming):
6
+ *
7
+ * 1. When `deployment.user_language_locked` is true → `deployment.language_default`
8
+ * (the user's cached choice is ignored and the switcher is hidden).
9
+ * 2. Otherwise the value cached in `localStorage` (the user's last pick on
10
+ * this device), then `deployment.language_default`, then `fallback`.
11
+ *
12
+ * The cache also gives a FOUC-free first paint: before `/auth/me` resolves we
13
+ * render in the last-known language rather than flashing English.
14
+ *
15
+ * @see ./theme.ts — the theme counterpart this mirrors
16
+ */
17
+
18
+ import type { AIHubLanguage } from "../auth/texts.js";
19
+ import type { AIHubDeployment } from "../types.js";
20
+
21
+ /** localStorage key for the last-chosen language. Namespaced to ai-hub. */
22
+ const CACHE_KEY = "data-club-ai-hub:language";
23
+
24
+ /** The languages the library ships built-in locales for. */
25
+ const KNOWN: ReadonlyArray<AIHubLanguage> = ["en", "cs", "sk"];
26
+
27
+ /** Type guard: is `value` one of the known language codes? */
28
+ function isLanguage(value: unknown): value is AIHubLanguage {
29
+ return typeof value === "string" && (KNOWN as string[]).includes(value);
30
+ }
31
+
32
+ /**
33
+ * Computes the resolved language from the `/auth/me` deployment context, the
34
+ * cached user choice, and a fallback.
35
+ *
36
+ * @param cached - the user's cached choice (or null).
37
+ * @param deployment - deployment context, or null when unauthenticated.
38
+ * @param fallback - default applied when nothing else resolves (usually `"en"`).
39
+ * @returns the resolved language.
40
+ */
41
+ export function resolveLanguage(
42
+ cached: AIHubLanguage | null,
43
+ deployment: AIHubDeployment | null,
44
+ fallback: AIHubLanguage,
45
+ ): AIHubLanguage {
46
+ const deploymentDefault = deployment?.language_default ?? fallback;
47
+ if (deployment?.user_language_locked) return deploymentDefault;
48
+ return cached ?? deploymentDefault;
49
+ }
50
+
51
+ /**
52
+ * Reads the cached language from `localStorage`. Returns null when no cache
53
+ * exists, the value is malformed, or storage is unavailable (Safari private
54
+ * mode, SSR, etc.).
55
+ *
56
+ * @returns the cached language or null.
57
+ */
58
+ export function readCachedLanguage(): AIHubLanguage | null {
59
+ try {
60
+ if (typeof window === "undefined" || !window.localStorage) return null;
61
+ const raw = window.localStorage.getItem(CACHE_KEY);
62
+ return isLanguage(raw) ? raw : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Persists the chosen language into `localStorage` for FOUC avoidance on the
70
+ * next page load. Silent no-op on storage failures.
71
+ *
72
+ * @param language - the language to cache.
73
+ */
74
+ export function writeCachedLanguage(language: AIHubLanguage): void {
75
+ try {
76
+ if (typeof window === "undefined" || !window.localStorage) return;
77
+ window.localStorage.setItem(CACHE_KEY, language);
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
@@ -36,6 +36,14 @@ export interface UseAIHubResult {
36
36
  setTheme: AIHubContextValue["setTheme"];
37
37
  /** Mirror of `deployment.theme_switcher_enabled`. */
38
38
  themeSwitcherEnabled: AIHubContextValue["themeSwitcherEnabled"];
39
+ /** Resolved UI language (`en` | `cs` | `sk`). */
40
+ language: AIHubContextValue["language"];
41
+ /** Switches the UI language (device-cached). No-op when locked. */
42
+ setLanguage: AIHubContextValue["setLanguage"];
43
+ /** Mirror of `!deployment.user_language_locked`. */
44
+ languageSwitcherEnabled: AIHubContextValue["languageSwitcherEnabled"];
45
+ /** Fully-resolved, language-aware texts. Prefer `useAIHubTexts()`. */
46
+ texts: AIHubContextValue["texts"];
39
47
  /** Non-archived agents available to the caller. */
40
48
  agents: AIHubContextValue["agents"];
41
49
  /** True while the initial `/agents` request is in flight. */
@@ -120,6 +128,10 @@ export function useAIHub(): UseAIHubResult {
120
128
  theme: ctx.theme,
121
129
  setTheme: ctx.setTheme,
122
130
  themeSwitcherEnabled: ctx.themeSwitcherEnabled,
131
+ language: ctx.language,
132
+ setLanguage: ctx.setLanguage,
133
+ languageSwitcherEnabled: ctx.languageSwitcherEnabled,
134
+ texts: ctx.texts,
123
135
  agents: ctx.agents,
124
136
  agentsLoading: ctx.agentsLoading,
125
137
  refreshAgents: ctx.refreshAgents,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `useAIHubTexts()` — returns the fully-resolved, language-aware
3
+ * {@link AIHubTexts} from the nearest `<AIHubProvider>`.
4
+ *
5
+ * This is the supported way for built-in screens to read user-facing
6
+ * strings: the provider has already merged the active built-in locale
7
+ * (`en` | `cs` | `sk`) with any wrapper `texts` override, so flipping the
8
+ * language via `setLanguage` re-renders every consumer.
9
+ *
10
+ * @see ./AIHubProvider.tsx — where `texts` is computed
11
+ */
12
+
13
+ import { useContext, useMemo } from "react";
14
+
15
+ import {
16
+ defaultAIHubTexts,
17
+ resolveAIHubTexts,
18
+ type AIHubTexts,
19
+ type DeepPartial,
20
+ } from "../auth/texts.js";
21
+ import { aiHubContext } from "./AIHubContext.js";
22
+
23
+ /**
24
+ * Returns the resolved texts map.
25
+ *
26
+ * Throws when called outside a provider — surfaces mounting bugs early
27
+ * instead of cascading through context-null deref.
28
+ *
29
+ * @returns the resolved `AIHubTexts`.
30
+ */
31
+ export function useAIHubTexts(): AIHubTexts {
32
+ const ctx = useContext(aiHubContext);
33
+ if (!ctx) {
34
+ throw new Error(
35
+ "useAIHubTexts() was called outside <AIHubProvider>. Wrap the tree in <AIHubProvider> first.",
36
+ );
37
+ }
38
+ return ctx.texts;
39
+ }
40
+
41
+ /**
42
+ * Resolves a component's optional `texts` override against the **active
43
+ * language locale** from the nearest `<AIHubProvider>` (the supported way
44
+ * for built-in components to be language-aware).
45
+ *
46
+ * - Inside a provider: the provider's already-resolved, language-aware
47
+ * `texts` is the base; the component's own `override` (if any) is merged on
48
+ * top. Flipping `setLanguage` re-renders the provider, so every consumer
49
+ * re-resolves automatically.
50
+ * - Outside a provider (standalone component / Storybook / tests): falls back
51
+ * to the English `defaultAIHubTexts` as the base, preserving the original
52
+ * "merge over English defaults" behavior.
53
+ *
54
+ * @param override - the component's wrapper-supplied partial (usually
55
+ * `props.texts`). `undefined` is fine.
56
+ * @returns the fully resolved `AIHubTexts`.
57
+ */
58
+ export function useResolvedTexts(
59
+ override: DeepPartial<AIHubTexts> | undefined,
60
+ ): AIHubTexts {
61
+ const ctx = useContext(aiHubContext);
62
+ const base = ctx?.texts ?? defaultAIHubTexts;
63
+ return useMemo(() => resolveAIHubTexts(override, base), [override, base]);
64
+ }
package/src/index.ts CHANGED
@@ -90,6 +90,8 @@ export {
90
90
  AIHubProvider,
91
91
  useAIHub,
92
92
  useAIHubAuth,
93
+ useAIHubTexts,
94
+ useResolvedTexts,
93
95
  } from "./context/index.js";
94
96
  export type {
95
97
  AIHubProviderProps,
@@ -205,6 +207,8 @@ export type {
205
207
  ThemeSwitcherClassNames,
206
208
  } from "./components/ThemeSwitcher/index.js";
207
209
 
210
+ export { LanguageSwitcher } from "./components/LanguageSwitcher/index.js";
211
+
208
212
  export { SystemNotice } from "./components/SystemNotice/index.js";
209
213
  export type {
210
214
  SystemNoticeProps,
@@ -249,6 +253,7 @@ export {
249
253
  } from "./auth/texts.js";
250
254
  export type {
251
255
  AIHubTexts,
256
+ AIHubLanguage,
252
257
  AIHubLoginTexts,
253
258
  AIHubChangePasswordTexts,
254
259
  AIHubChatTexts,
@@ -256,6 +261,14 @@ export type {
256
261
  DeepPartial,
257
262
  } from "./auth/texts.js";
258
263
 
264
+ /* Built-in locales (Czech / Slovak) + the switcher's language metadata. */
265
+ export {
266
+ AIHUB_LOCALES,
267
+ AIHUB_LANGUAGE_OPTIONS,
268
+ csTexts,
269
+ skTexts,
270
+ } from "./auth/locales/index.js";
271
+
259
272
  /* ── Password policy (client-side mirror of §8.5) ────────────────── */
260
273
 
261
274
  export {
@@ -29,6 +29,7 @@ import { ReasoningView } from "../components/ReasoningView/index.js";
29
29
  import { ScrollToPresentButton } from "../components/ScrollToPresentButton/index.js";
30
30
  import { SessionsPanel } from "../components/SessionsPanel/index.js";
31
31
  import { SystemNotice } from "../components/SystemNotice/index.js";
32
+ import { LanguageSwitcher } from "../components/LanguageSwitcher/index.js";
32
33
  import { ThemeSwitcher } from "../components/ThemeSwitcher/index.js";
33
34
  import { ToolCallView } from "../components/ToolCallView/index.js";
34
35
  import { TpmBanner } from "../components/TpmBanner/index.js";
@@ -87,6 +88,8 @@ export const AIHub = {
87
88
  TurnStatusIndicator,
88
89
  /** Sun/moon theme toggle. */
89
90
  ThemeSwitcher,
91
+ /** Globe language switcher (en/cs/sk). */
92
+ LanguageSwitcher,
90
93
  /** Floating "↓ Jump to latest" button. */
91
94
  ScrollToPresentButton,
92
95
  /** Collapsible reasoning block inside an assistant bubble. */
@@ -15,6 +15,7 @@
15
15
  import { type ReactNode } from "react";
16
16
 
17
17
  import { aiHubContext, type AIHubContextValue } from "../context/AIHubContext.js";
18
+ import { defaultAIHubTexts } from "../auth/texts.js";
18
19
  import type { AIHubEffort } from "../client/AIHubClient.js";
19
20
  import type {
20
21
  AIHubFeatureToggles,
@@ -82,6 +83,10 @@ export function MockProvider(props: {
82
83
  theme: opts.theme ?? "light",
83
84
  setTheme: noopAsync,
84
85
  themeSwitcherEnabled: opts.themeSwitcherEnabled ?? true,
86
+ language: "en",
87
+ setLanguage: () => undefined,
88
+ languageSwitcherEnabled: true,
89
+ texts: defaultAIHubTexts,
85
90
  agents: [],
86
91
  agentsLoading: false,
87
92
  refreshAgents: noopAsync,
@@ -664,6 +664,85 @@
664
664
  text-decoration: none;
665
665
  }
666
666
 
667
+ /* ── Language switcher (left of the user menu in the shell header) ────── */
668
+
669
+ .data-club-ai-hub__language-switcher {
670
+ position: relative;
671
+ }
672
+
673
+ .data-club-ai-hub__language-switcher__button {
674
+ display: inline-flex;
675
+ align-items: center;
676
+ gap: var(--dc-aih-space-xs);
677
+ padding: var(--dc-aih-space-xs) var(--dc-aih-space-sm);
678
+ font: inherit;
679
+ color: var(--dc-aih-fg);
680
+ background: transparent;
681
+ border: 1px solid transparent;
682
+ border-radius: var(--dc-aih-radius-md);
683
+ transition: background-color var(--dc-aih-transition),
684
+ border-color var(--dc-aih-transition);
685
+ }
686
+
687
+ .data-club-ai-hub__language-switcher__button:hover {
688
+ background-color: color-mix(in srgb, var(--dc-aih-fg) 6%, transparent);
689
+ }
690
+
691
+ .data-club-ai-hub__language-switcher__button:focus-visible {
692
+ outline: none;
693
+ border-color: var(--dc-aih-accent);
694
+ box-shadow: 0 0 0 3px var(--dc-aih-focus-ring);
695
+ }
696
+
697
+ .data-club-ai-hub__language-switcher__icon {
698
+ font-size: var(--dc-aih-font-size-md);
699
+ line-height: 1;
700
+ }
701
+
702
+ .data-club-ai-hub__language-switcher__code {
703
+ font-size: var(--dc-aih-font-size-sm);
704
+ font-weight: 600;
705
+ letter-spacing: 0.03em;
706
+ }
707
+
708
+ .data-club-ai-hub__language-switcher__dropdown {
709
+ position: absolute;
710
+ right: 0;
711
+ top: calc(100% + var(--dc-aih-space-xs));
712
+ min-width: 160px;
713
+ padding: var(--dc-aih-space-sm) 0;
714
+ background-color: var(--dc-aih-bg-elevated);
715
+ border: 1px solid var(--dc-aih-border);
716
+ border-radius: var(--dc-aih-radius-md);
717
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
718
+ z-index: 30;
719
+ }
720
+
721
+ .data-club-ai-hub__language-switcher__item {
722
+ display: block;
723
+ width: 100%;
724
+ padding: var(--dc-aih-space-sm) var(--dc-aih-space-md);
725
+ font: inherit;
726
+ text-align: left;
727
+ color: var(--dc-aih-fg);
728
+ background: transparent;
729
+ border: none;
730
+ }
731
+
732
+ .data-club-ai-hub__language-switcher__item:hover {
733
+ background-color: color-mix(in srgb, var(--dc-aih-fg) 6%, transparent);
734
+ }
735
+
736
+ .data-club-ai-hub__language-switcher__item:focus-visible {
737
+ outline: none;
738
+ background-color: color-mix(in srgb, var(--dc-aih-accent) 12%, transparent);
739
+ }
740
+
741
+ .data-club-ai-hub__language-switcher__item--active {
742
+ font-weight: 600;
743
+ color: var(--dc-aih-accent);
744
+ }
745
+
667
746
  /* ────────────────────────────────────────────────────────────────────── */
668
747
  /* Admin shell + Users sub-page — Step 11 */
669
748
  /* ui-sketches/04-admin-overview.md, ui-sketches/05-admin-users.md */
package/src/types.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  * @see SPECIFICATIONS.md §11 — data model
17
17
  */
18
18
 
19
+ import type { AIHubLanguage } from "./auth/texts.js";
20
+
19
21
  /* ────────────────────────────────────────────────────────────────────────── */
20
22
  /* Roles + users */
21
23
  /* ────────────────────────────────────────────────────────────────────────── */
@@ -98,6 +100,18 @@ export interface AIHubDeployment {
98
100
  * `user.theme_preference`.
99
101
  */
100
102
  theme_switcher_enabled: boolean;
103
+ /**
104
+ * Default UI language for the deployment (`en` | `cs` | `sk`). Seeds a
105
+ * user's language when they have no per-device choice cached, and is the
106
+ * only language used when `user_language_locked` is true.
107
+ */
108
+ language_default: AIHubLanguage;
109
+ /**
110
+ * When `true`, the deployment is single-language — `AIHub.LanguageSwitcher`
111
+ * is hidden and every user sees `language_default` regardless of any cached
112
+ * per-device choice.
113
+ */
114
+ user_language_locked: boolean;
101
115
  /**
102
116
  * Browser-facing URL of the self-hosted Letta ADE — only present when the
103
117
  * caller's role is `power_user` or higher. Absent for `user`-role callers.