ltcai 3.6.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -10,12 +10,13 @@ import { store } from "./store.js";
10
10
  import { api } from "./api.js";
11
11
  import * as c from "./components.js";
12
12
  import { createRouter } from "./router.js";
13
- import { GROUPS, ROUTES, ROUTE_BY_KEY, MODE_RANK, visibleRoutes, loadView } from "./routes.js";
13
+ import { GROUPS, ROUTE_BY_KEY, MODE_RANK, visibleRoutes, loadView, groupLabel, localizeRoute } from "./routes.js";
14
+ import { setI18nLanguage, t } from "./i18n.js";
14
15
 
15
16
  const MODES = [
16
- { key: "basic", label: "Basic", icon: "circle" },
17
- { key: "advanced", label: "Advanced", icon: "circles" },
18
- { key: "admin", label: "Admin", icon: "shield-half" },
17
+ { key: "basic", labelKey: "shell.mode.basic", icon: "circle" },
18
+ { key: "advanced", labelKey: "shell.mode.advanced", icon: "circles" },
19
+ { key: "admin", labelKey: "shell.mode.admin", icon: "shield-half" },
19
20
  ];
20
21
 
21
22
  const ctxBase = { h, icon, api, store, c };
@@ -27,7 +28,7 @@ let currentRoute = null;
27
28
  export function boot(rootEl) {
28
29
  rootEl.classList.add("lt3-app");
29
30
  rootEl.append(
30
- h("a.lt3-skip", { href: "#lt3-view" }, "Skip to content"),
31
+ h("a.lt3-skip", { href: "#lt3-view" }, t("shell.skip")),
31
32
  h("div.lt3-rail__scrim", { on: { click: closeDrawer } }),
32
33
  buildRail(),
33
34
  buildMain(),
@@ -36,7 +37,7 @@ export function boot(rootEl) {
36
37
  cacheEls(rootEl);
37
38
  store.subscribe(onStateChange);
38
39
 
39
- router = createRouter({ onRoute: renderRoute, fallback: "home" });
40
+ router = createRouter({ onRoute: renderRoute, fallback: "knowledge-graph" });
40
41
  wireGlobalKeys();
41
42
  router.start();
42
43
 
@@ -46,19 +47,19 @@ export function boot(rootEl) {
46
47
 
47
48
  /* ── Rail ────────────────────────────────────────────────────────────────── */
48
49
  function buildRail() {
49
- return h("aside.lt3-rail", { id: "lt3-rail", "aria-label": "Primary" },
50
+ return h("aside.lt3-rail", { id: "lt3-rail", "aria-label": t("shell.primary") },
50
51
  h("div.lt3-rail__brand",
51
52
  h("div.lt3-rail__logo", { html: latticeMark() }),
52
- h("div.lt3-rail__word", h("b", "Lattice AI"), h("small", "Private runtime")),
53
- h("button.lt3-iconbtn.lt3-iconbtn--sm.lt3-rail__close", { "aria-label": "Close menu", on: { click: closeDrawer } }, icon("x")),
53
+ h("div.lt3-rail__word", h("b", "Lattice AI"), h("small", t("shell.privateRuntime"))),
54
+ h("button.lt3-iconbtn.lt3-iconbtn--sm.lt3-rail__close", { "aria-label": t("shell.closeMenu"), on: { click: closeDrawer } }, icon("x")),
54
55
  ),
55
56
  h("div.lt3-rail__scope", { id: "lt3-scope" }),
56
57
  h("nav.lt3-rail__nav", { id: "lt3-nav", "aria-label": "Sections" }),
57
58
  h("div.lt3-rail__foot",
58
59
  h("div.lt3-rail__status", { id: "lt3-rail-status" }),
59
60
  h("div.lt3-rail__foot-row",
60
- h("button.lt3-rail__user", { id: "lt3-user", "aria-label": "Account", on: { click: () => router.navigate("settings") } }),
61
- h("button.lt3-iconbtn", { id: "lt3-theme", "aria-label": "Toggle theme", title: "Toggle theme", on: { click: () => store.toggleTheme() } }, icon("moon")),
61
+ h("button.lt3-rail__user", { id: "lt3-user", "aria-label": t("shell.account"), on: { click: () => router.navigate("account") } }),
62
+ h("button.lt3-iconbtn", { id: "lt3-theme", "aria-label": t("shell.toggleTheme"), title: t("shell.toggleTheme"), on: { click: () => store.toggleTheme() } }, icon("moon")),
62
63
  ),
63
64
  ),
64
65
  );
@@ -73,7 +74,7 @@ function renderNav() {
73
74
  const items = routes.filter((r) => r.group === group.id);
74
75
  if (!items.length) continue;
75
76
  const groupEl = h("div.lt3-navgroup",
76
- h("div.lt3-navgroup__label", group.label),
77
+ h("div.lt3-navgroup__label", groupLabel(group)),
77
78
  items.map((r) => navItem(r)),
78
79
  );
79
80
  nav.append(groupEl);
@@ -109,7 +110,7 @@ function renderScope() {
109
110
  els.scope.replaceChildren(
110
111
  h("button.lt3-scope", { "aria-haspopup": "listbox", on: { click: openScopeMenu } },
111
112
  h("div.lt3-scope__icon", icon(ws.type === "organization" ? "building-community" : "user")),
112
- h("div.lt3-scope__meta", h("b", ws.name), h("small", `${ws.type} · ${ws.your_role || "member"}`)),
113
+ h("div.lt3-scope__meta", h("b", ws.name), h("small", `${ws.type} · ${ws.your_role || t("shell.member")}`)),
113
114
  icon("selector"),
114
115
  ),
115
116
  );
@@ -120,7 +121,7 @@ function renderUser() {
120
121
  const initials = (u.nickname || u.email || "U").slice(0, 2);
121
122
  els.user.replaceChildren(
122
123
  h("span.lt3-avatar", initials),
123
- h("div.lt3-rail__user-meta", h("b", u.nickname || u.email || "You"), h("small", u.role || "local")),
124
+ h("div.lt3-rail__user-meta", h("b", u.nickname || u.email || t("shell.you")), h("small", u.role || t("shell.local"))),
124
125
  );
125
126
  }
126
127
 
@@ -134,17 +135,17 @@ function updateThemeIcon() {
134
135
  function buildMain() {
135
136
  return h("div.lt3-main",
136
137
  h("header.lt3-topbar",
137
- h("button.lt3-iconbtn.lt3-topbar__menu", { "aria-label": "Open menu", on: { click: openDrawer } }, icon("menu-2")),
138
+ h("button.lt3-iconbtn.lt3-topbar__menu", { "aria-label": t("shell.openMenu"), on: { click: openDrawer } }, icon("menu-2")),
138
139
  h("div.lt3-topbar__crumbs", { id: "lt3-crumbs" }),
139
140
  h("div.lt3-spacer"),
140
- h("button.lt3-cmd-trigger", { "aria-label": "Search and commands", on: { click: openPalette } },
141
- icon("search"), h("span", "Search & commands"), h("span.lt3-kbd", "⌘K")),
141
+ h("button.lt3-cmd-trigger", { "aria-label": t("shell.searchCommands"), on: { click: openPalette } },
142
+ icon("search"), h("span", { id: "lt3-cmd-text" }, t("shell.searchCommands")), h("span.lt3-kbd", "⌘K")),
142
143
  h("div", { id: "lt3-idxchip" }),
143
- h("div.lt3-mode", { id: "lt3-mode", role: "tablist", "aria-label": "Workspace mode" },
144
+ h("div.lt3-mode", { id: "lt3-mode", role: "tablist", "aria-label": t("shell.workspaceMode") },
144
145
  MODES.map((m) => h("button", {
145
146
  type: "button", role: "tab", dataset: { mode: m.key },
146
147
  on: { click: () => store.setMode(m.key) },
147
- }, icon(m.icon), h("span", m.label))),
148
+ }, icon(m.icon), h("span", t(m.labelKey)))),
148
149
  ),
149
150
  ),
150
151
  h("main.lt3-view", { id: "lt3-view", tabindex: "-1" },
@@ -155,13 +156,18 @@ function buildMain() {
155
156
 
156
157
  function renderMode() {
157
158
  $$("#lt3-mode button", els.root).forEach((b) => b.dataset.active = String(b.dataset.mode === store.get().mode));
159
+ $$("#lt3-mode button", els.root).forEach((b) => {
160
+ const mode = MODES.find((m) => m.key === b.dataset.mode);
161
+ const span = $("span", b);
162
+ if (mode && span) span.textContent = t(mode.labelKey);
163
+ });
158
164
  }
159
165
 
160
166
  function renderCrumbs() {
161
167
  const r = currentRoute;
162
168
  if (!r) return;
163
169
  const parts = [h("span.lt3-crumb", store.activeWorkspace().name)];
164
- if (r.group === "admin") parts.push(icon("chevron-right"), h("span.lt3-crumb", "Admin"));
170
+ if (r.group === "admin") parts.push(icon("chevron-right"), h("span.lt3-crumb", t("shell.adminCrumb")));
165
171
  parts.push(icon("chevron-right"), h("span.lt3-crumb.lt3-crumb--current", r.title || r.label));
166
172
  els.crumbs.replaceChildren(...parts);
167
173
  }
@@ -171,6 +177,23 @@ function renderIndexChip() {
171
177
  renderRailStatus();
172
178
  }
173
179
 
180
+ function renderChromeText() {
181
+ const skip = $(".lt3-skip", els.root);
182
+ if (skip) skip.textContent = t("shell.skip");
183
+ if (els.cmdText) els.cmdText.textContent = t("shell.searchCommands");
184
+ const rail = $("#lt3-rail", els.root);
185
+ if (rail) rail.setAttribute("aria-label", t("shell.primary"));
186
+ const mode = $("#lt3-mode", els.root);
187
+ if (mode) mode.setAttribute("aria-label", t("shell.workspaceMode"));
188
+ const user = $("#lt3-user", els.root);
189
+ if (user) user.setAttribute("aria-label", t("shell.account"));
190
+ const theme = $("#lt3-theme", els.root);
191
+ if (theme) {
192
+ theme.setAttribute("aria-label", t("shell.toggleTheme"));
193
+ theme.setAttribute("title", t("shell.toggleTheme"));
194
+ }
195
+ }
196
+
174
197
  function renderRailStatus() {
175
198
  if (!els.railStatus) return;
176
199
  const status = store.get().indexStatus;
@@ -181,21 +204,21 @@ function renderRailStatus() {
181
204
  els.railStatus.replaceChildren(
182
205
  h("div.lt3-rail__status-top",
183
206
  h("span.lt3-rail__status-dot", { dataset: { state: unavailable ? "pending" : ready === keys.length ? "ready" : "partial" } }),
184
- h("span", unavailable ? "Local index pending" : `${ready}/${keys.length} retrieval signals ready`),
207
+ h("span", unavailable ? t("shell.indexPending") : t("shell.indexReady", { ready, total: keys.length })),
185
208
  ),
186
- h("div.lt3-rail__status-sub", unavailable ? "Start backend to sync live state" : "Graph · vector · hybrid"),
209
+ h("div.lt3-rail__status-sub", unavailable ? t("shell.startBackend") : t("shell.graphVectorHybrid")),
187
210
  );
188
211
  }
189
212
 
190
213
  /* ── View rendering ─────────────────────────────────────────────────────── */
191
214
  async function renderRoute({ key, params }) {
192
- let route = ROUTE_BY_KEY[key] || ROUTE_BY_KEY.home;
215
+ let route = localizeRoute(ROUTE_BY_KEY[key] || ROUTE_BY_KEY.home);
193
216
  // Deep-linking into an admin area surfaces Admin mode so the rail matches.
194
217
  if (route.admin && store.get().mode !== "admin") store.setMode("admin");
195
218
  currentRoute = route;
196
219
  store.setRoute({ key: route.key, params });
197
220
 
198
- document.title = `${route.title || route.label} · Lattice AI`;
221
+ document.title = t("shell.documentTitle", { title: route.title || route.label });
199
222
  markActive();
200
223
  renderCrumbs();
201
224
 
@@ -213,7 +236,7 @@ async function renderRoute({ key, params }) {
213
236
  outlet.replaceChildren(node);
214
237
  } catch (err) {
215
238
  console.error("[shell] view render failed:", route.view, err);
216
- outlet.replaceChildren(c.errorState(`View "${route.label}" failed to load.`, () => renderRoute({ key: route.key, params })));
239
+ outlet.replaceChildren(c.errorState(t("shell.viewFailed", { label: route.label }), () => renderRoute({ key: route.key, params })));
217
240
  }
218
241
  }
219
242
 
@@ -230,6 +253,10 @@ function onStateChange(_state, change) {
230
253
  case "user": renderUser(); break;
231
254
  case "theme": updateThemeIcon(); break;
232
255
  case "index": renderIndexChip(); break;
256
+ case "language":
257
+ setI18nLanguage(store.get().lang);
258
+ renderNav(); renderScope(); renderUser(); renderMode(); renderCrumbs(); renderIndexChip(); renderChromeText(); renderCurrent();
259
+ break;
233
260
  }
234
261
  }
235
262
 
@@ -249,8 +276,8 @@ function openScopeMenu(ev) {
249
276
  w.workspace_id === store.get().workspaceId ? icon("check", "") : null,
250
277
  )),
251
278
  h("div.lt3-menu__sep"),
252
- h("button.lt3-menu__item", { on: { click: () => { c.toast("Organization creation opens in Settings", "info"); closeMenus(); router.navigate("settings"); } } },
253
- icon("plus"), "New organization"),
279
+ h("button.lt3-menu__item", { on: { click: () => { c.toast(t("shell.orgCreationOpens"), "info"); closeMenus(); router.navigate("workspace-admin"); } } },
280
+ icon("plus"), t("shell.newOrganization")),
254
281
  );
255
282
  document.body.append(menu);
256
283
  setTimeout(() => document.addEventListener("click", closeMenusOnce, { once: true }), 0);
@@ -267,16 +294,16 @@ function paletteItems() {
267
294
  const mode = store.get().mode;
268
295
  const currentRoutes = visibleRoutes(mode);
269
296
  const nav = currentRoutes.map((r) => ({
270
- group: "Go to", label: r.title || r.label, icon: r.icon, hint: r.label === r.title ? r.group : r.label,
297
+ group: t("shell.goTo"), label: r.title || r.label, icon: r.icon, hint: r.label === r.title ? groupLabel(GROUPS.find((g) => g.id === r.group) || { labelKey: r.group }) : r.label,
271
298
  run: () => router.navigate(r.key),
272
299
  }));
273
300
  const actions = [
274
- { group: "Actions", label: "Toggle light / dark theme", icon: "contrast", run: () => store.toggleTheme() },
275
- { group: "Actions", label: "Mode: Basic", icon: "circle", run: () => store.setMode("basic") },
276
- { group: "Actions", label: "Mode: Advanced", icon: "circles", run: () => store.setMode("advanced") },
277
- { group: "Actions", label: "Mode: Admin", icon: "shield-half", run: () => store.setMode("admin") },
278
- { group: "Actions", label: "New chat", icon: "message-plus", run: () => router.navigate("chat", { new: "1" }) },
279
- { group: "Actions", label: "Run hybrid search", icon: "arrows-join", run: () => router.navigate("hybrid-search") },
301
+ { group: t("shell.actions"), label: t("shell.toggleLightDark"), icon: "contrast", run: () => store.toggleTheme() },
302
+ { group: t("shell.actions"), label: `${t("common.status")}: ${t("shell.mode.basic")}`, icon: "circle", run: () => store.setMode("basic") },
303
+ { group: t("shell.actions"), label: `${t("common.status")}: ${t("shell.mode.advanced")}`, icon: "circles", run: () => store.setMode("advanced") },
304
+ { group: t("shell.actions"), label: `${t("common.status")}: ${t("shell.mode.admin")}`, icon: "shield-half", run: () => store.setMode("admin") },
305
+ { group: t("shell.actions"), label: t("shell.newChat"), icon: "message-plus", run: () => router.navigate("chat", { new: "1" }) },
306
+ { group: t("shell.actions"), label: t("shell.runHybridSearch"), icon: "arrows-join", run: () => router.navigate("hybrid-search") },
280
307
  ];
281
308
  return [...nav, ...actions];
282
309
  }
@@ -287,8 +314,8 @@ function openPalette() {
287
314
  let active = 0, filtered = all;
288
315
 
289
316
  const listEl = h("div.lt3-palette__list");
290
- const input = h("input", { type: "text", placeholder: "Search views, run a command…", "aria-label": "Command palette", autocomplete: "off" });
291
- const palette = h("div.lt3-palette", { id: "lt3-palette", role: "dialog", "aria-modal": "true", "aria-label": "Command palette" },
317
+ const input = h("input", { type: "text", placeholder: t("shell.palettePlaceholder"), "aria-label": t("shell.commandPalette"), autocomplete: "off" });
318
+ const palette = h("div.lt3-palette", { id: "lt3-palette", role: "dialog", "aria-modal": "true", "aria-label": t("shell.commandPalette") },
292
319
  h("div.lt3-palette__input", icon("search"), input, h("span.lt3-kbd", "Esc")),
293
320
  listEl,
294
321
  );
@@ -298,7 +325,7 @@ function openPalette() {
298
325
 
299
326
  function renderList() {
300
327
  listEl.replaceChildren();
301
- if (!filtered.length) { listEl.append(h("div.lt3-palette__empty", "No matches")); return; }
328
+ if (!filtered.length) { listEl.append(h("div.lt3-palette__empty", t("shell.noMatches"))); return; }
302
329
  let lastGroup = null;
303
330
  filtered.forEach((item, i) => {
304
331
  if (item.group !== lastGroup) { listEl.append(h("div.lt3-palette__group-label", item.group)); lastGroup = item.group; }
@@ -366,6 +393,7 @@ function cacheEls(root) {
366
393
  theme: $("#lt3-theme", root),
367
394
  crumbs: $("#lt3-crumbs", root),
368
395
  idxchip: $("#lt3-idxchip", root),
396
+ cmdText: $("#lt3-cmd-text", root),
369
397
  railStatus: $("#lt3-rail-status", root),
370
398
  outlet: $("#lt3-outlet", root),
371
399
  view: $("#lt3-view", root),
@@ -377,6 +405,7 @@ function cacheEls(root) {
377
405
  updateThemeIcon();
378
406
  renderIndexChip();
379
407
  renderRailStatus();
408
+ renderChromeText();
380
409
  }
381
410
 
382
411
  function latticeMark() {
@@ -9,6 +9,7 @@ const LS = {
9
9
  theme: "lt-theme", // shared with the rest of Lattice (data-lt-theme)
10
10
  mode: "lt3-mode",
11
11
  workspace: "lt3-workspace",
12
+ lang: "lt3-lang",
12
13
  };
13
14
 
14
15
  function load(key, fallback) {
@@ -23,6 +24,7 @@ const VALID_MODES = ["basic", "advanced", "admin"];
23
24
 
24
25
  const state = {
25
26
  theme: load(LS.theme, ""), // "" → follow OS
27
+ lang: load(LS.lang, "en"),
26
28
  mode: VALID_MODES.includes(load(LS.mode)) ? load(LS.mode) : "basic",
27
29
  workspaceId: load(LS.workspace, "personal"),
28
30
  workspaces: [
@@ -30,7 +32,7 @@ const state = {
30
32
  ],
31
33
  user: { email: "", nickname: "You", role: "user" },
32
34
  indexStatus: null,
33
- route: { key: "home", params: {} },
35
+ route: { key: "knowledge-graph", params: {} },
34
36
  };
35
37
 
36
38
  const subscribers = new Set();
@@ -67,6 +69,14 @@ export const store = {
67
69
  store.setTheme(effective === "dark" ? "light" : "dark");
68
70
  },
69
71
 
72
+ setLang(lang) {
73
+ const value = lang === "ko" ? "ko" : "en";
74
+ if (value === state.lang) return;
75
+ state.lang = value;
76
+ save(LS.lang, value);
77
+ emit({ type: "language" });
78
+ },
79
+
70
80
  /* ── Mode ──────────────────────────────────────────────── */
71
81
  setMode(mode) {
72
82
  if (!VALID_MODES.includes(mode) || mode === state.mode) return;
@@ -9,6 +9,7 @@ const LS = {
9
9
  theme: "lt-theme", // shared with the rest of Lattice (data-lt-theme)
10
10
  mode: "lt3-mode",
11
11
  workspace: "lt3-workspace",
12
+ lang: "lt3-lang",
12
13
  };
13
14
 
14
15
  function load(key, fallback) {
@@ -23,6 +24,7 @@ const VALID_MODES = ["basic", "advanced", "admin"];
23
24
 
24
25
  const state = {
25
26
  theme: load(LS.theme, ""), // "" → follow OS
27
+ lang: load(LS.lang, "en"),
26
28
  mode: VALID_MODES.includes(load(LS.mode)) ? load(LS.mode) : "basic",
27
29
  workspaceId: load(LS.workspace, "personal"),
28
30
  workspaces: [
@@ -30,7 +32,7 @@ const state = {
30
32
  ],
31
33
  user: { email: "", nickname: "You", role: "user" },
32
34
  indexStatus: null,
33
- route: { key: "home", params: {} },
35
+ route: { key: "knowledge-graph", params: {} },
34
36
  };
35
37
 
36
38
  const subscribers = new Set();
@@ -67,6 +69,14 @@ export const store = {
67
69
  store.setTheme(effective === "dark" ? "light" : "dark");
68
70
  },
69
71
 
72
+ setLang(lang) {
73
+ const value = lang === "ko" ? "ko" : "en";
74
+ if (value === state.lang) return;
75
+ state.lang = value;
76
+ save(LS.lang, value);
77
+ emit({ type: "language" });
78
+ },
79
+
70
80
  /* ── Mode ──────────────────────────────────────────────── */
71
81
  setMode(mode) {
72
82
  if (!VALID_MODES.includes(mode) || mode === state.mode) return;
@@ -0,0 +1,143 @@
1
+ import { t } from "../core/i18n.880e1fec.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, store, c, toast } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 4 }));
6
+
7
+ async function load() {
8
+ const [profile, sso] = await Promise.all([api.profile(), api.ssoConfig()]);
9
+ host.replaceChildren(
10
+ c.viewHeader({
11
+ eyebrow: t("account.eyebrow"),
12
+ title: t("account.title"),
13
+ sub: t("account.sub"),
14
+ actions: [c.sourceBadge(profile.ok ? "live" : "unavailable")],
15
+ }),
16
+ profile.ok && profile.data ? signedInPanel(profile.data, sso.data || {}) : authPanel(sso.data || {}),
17
+ );
18
+ if (profile.ok && profile.data) {
19
+ store.setUser({
20
+ email: profile.data.email || "",
21
+ nickname: profile.data.nickname || profile.data.name || profile.data.email || t("shell.you"),
22
+ role: profile.data.role || "user",
23
+ });
24
+ }
25
+ }
26
+
27
+ function authPanel(sso) {
28
+ const email = input("email", t("account.email"));
29
+ const password = input("password", t("account.password"));
30
+ const regEmail = input("email", t("account.email"));
31
+ const regName = input("text", t("account.name"));
32
+ const regNick = input("text", t("account.nickname"));
33
+ const regPassword = input("password", t("account.password"));
34
+
35
+ async function doLogin() {
36
+ const res = await api.login(email.value.trim(), password.value);
37
+ toast(resultText(res, t("account.loginOk")), res.ok ? "ok" : "err");
38
+ if (res.ok) load();
39
+ }
40
+ async function doRegister() {
41
+ const res = await api.register({
42
+ email: regEmail.value.trim(),
43
+ password: regPassword.value,
44
+ name: regName.value.trim(),
45
+ nickname: regNick.value.trim(),
46
+ });
47
+ toast(resultText(res, t("account.registerOk")), res.ok ? "ok" : "err");
48
+ if (res.ok) load();
49
+ }
50
+
51
+ return h("div.lt3-grid-2",
52
+ c.panel({
53
+ title: t("account.login"),
54
+ children: h("div.lt3-stack-4",
55
+ field(ctx, t("account.email"), email),
56
+ field(ctx, t("account.password"), password),
57
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: doLogin } }, icon("login"), t("account.login")),
58
+ ),
59
+ }),
60
+ c.panel({
61
+ title: t("account.register"),
62
+ sub: t("account.passwordRule"),
63
+ children: h("div.lt3-stack-4",
64
+ field(ctx, t("account.email"), regEmail),
65
+ field(ctx, t("account.name"), regName),
66
+ field(ctx, t("account.nickname"), regNick),
67
+ field(ctx, t("account.password"), regPassword),
68
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: doRegister } }, icon("user-plus"), t("account.register")),
69
+ sso && sso.enabled ? c.banner(t("account.sso"), "info") : null,
70
+ ),
71
+ }),
72
+ );
73
+ }
74
+
75
+ function signedInPanel(profile, sso) {
76
+ const name = input("text", t("account.name"), profile.name || "");
77
+ const nick = input("text", t("account.nickname"), profile.nickname || "");
78
+ const current = input("password", t("account.currentPassword"));
79
+ const next = input("password", t("account.newPassword"));
80
+
81
+ async function saveProfile() {
82
+ const res = await api.updateProfile({ name: name.value.trim(), nickname: nick.value.trim() });
83
+ toast(resultText(res, t("account.profileOk")), res.ok ? "ok" : "err");
84
+ if (res.ok) load();
85
+ }
86
+ async function savePassword() {
87
+ const res = await api.changePassword(current.value, next.value);
88
+ toast(resultText(res, t("account.passwordOk")), res.ok ? "ok" : "err");
89
+ if (res.ok) { current.value = ""; next.value = ""; }
90
+ }
91
+ async function doLogout() {
92
+ const res = await api.logout();
93
+ toast(resultText(res, t("account.logoutOk")), res.ok ? "ok" : "err");
94
+ if (res.ok) load();
95
+ }
96
+
97
+ return h("div.lt3-grid-2",
98
+ c.panel({
99
+ title: t("account.profile"),
100
+ actions: [c.statePill(t("account.signedIn"))],
101
+ children: h("div.lt3-stack-4",
102
+ h("dl.lt3-keyval",
103
+ h("dt", t("account.email")), h("dd", h("span.lt3-mono", profile.email || "—")),
104
+ h("dt", t("common.role")), h("dd", c.pill(profile.role || "user", "info")),
105
+ h("dt", t("account.sso")), h("dd", c.statePill(sso && sso.enabled ? "ready" : "idle")),
106
+ ),
107
+ field(ctx, t("account.name"), name),
108
+ field(ctx, t("account.nickname"), nick),
109
+ h("div.lt3-row-2",
110
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: saveProfile } }, icon("device-floppy"), t("common.save")),
111
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: doLogout } }, icon("logout"), t("account.logout")),
112
+ ),
113
+ ),
114
+ }),
115
+ c.panel({
116
+ title: t("account.changePassword"),
117
+ sub: t("account.passwordRule"),
118
+ children: h("div.lt3-stack-4",
119
+ field(ctx, t("account.currentPassword"), current),
120
+ field(ctx, t("account.newPassword"), next),
121
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: savePassword } }, icon("key"), t("account.changePassword")),
122
+ ),
123
+ }),
124
+ );
125
+ }
126
+
127
+ await load();
128
+ return host;
129
+
130
+ function input(type, label, value = "") {
131
+ return h("input.lt3-input", { type, value, autocomplete: type === "password" ? "current-password" : "on", "aria-label": label });
132
+ }
133
+ }
134
+
135
+ function field({ h }, label, control) {
136
+ return h("div.lt3-field", h("label.lt3-label", label), control);
137
+ }
138
+
139
+ function resultText(res, okText) {
140
+ if (res && res.ok) return okText;
141
+ const data = (res && res.data) || {};
142
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
143
+ }
@@ -0,0 +1,143 @@
1
+ import { t } from "../core/i18n.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, store, c, toast } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 4 }));
6
+
7
+ async function load() {
8
+ const [profile, sso] = await Promise.all([api.profile(), api.ssoConfig()]);
9
+ host.replaceChildren(
10
+ c.viewHeader({
11
+ eyebrow: t("account.eyebrow"),
12
+ title: t("account.title"),
13
+ sub: t("account.sub"),
14
+ actions: [c.sourceBadge(profile.ok ? "live" : "unavailable")],
15
+ }),
16
+ profile.ok && profile.data ? signedInPanel(profile.data, sso.data || {}) : authPanel(sso.data || {}),
17
+ );
18
+ if (profile.ok && profile.data) {
19
+ store.setUser({
20
+ email: profile.data.email || "",
21
+ nickname: profile.data.nickname || profile.data.name || profile.data.email || t("shell.you"),
22
+ role: profile.data.role || "user",
23
+ });
24
+ }
25
+ }
26
+
27
+ function authPanel(sso) {
28
+ const email = input("email", t("account.email"));
29
+ const password = input("password", t("account.password"));
30
+ const regEmail = input("email", t("account.email"));
31
+ const regName = input("text", t("account.name"));
32
+ const regNick = input("text", t("account.nickname"));
33
+ const regPassword = input("password", t("account.password"));
34
+
35
+ async function doLogin() {
36
+ const res = await api.login(email.value.trim(), password.value);
37
+ toast(resultText(res, t("account.loginOk")), res.ok ? "ok" : "err");
38
+ if (res.ok) load();
39
+ }
40
+ async function doRegister() {
41
+ const res = await api.register({
42
+ email: regEmail.value.trim(),
43
+ password: regPassword.value,
44
+ name: regName.value.trim(),
45
+ nickname: regNick.value.trim(),
46
+ });
47
+ toast(resultText(res, t("account.registerOk")), res.ok ? "ok" : "err");
48
+ if (res.ok) load();
49
+ }
50
+
51
+ return h("div.lt3-grid-2",
52
+ c.panel({
53
+ title: t("account.login"),
54
+ children: h("div.lt3-stack-4",
55
+ field(ctx, t("account.email"), email),
56
+ field(ctx, t("account.password"), password),
57
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: doLogin } }, icon("login"), t("account.login")),
58
+ ),
59
+ }),
60
+ c.panel({
61
+ title: t("account.register"),
62
+ sub: t("account.passwordRule"),
63
+ children: h("div.lt3-stack-4",
64
+ field(ctx, t("account.email"), regEmail),
65
+ field(ctx, t("account.name"), regName),
66
+ field(ctx, t("account.nickname"), regNick),
67
+ field(ctx, t("account.password"), regPassword),
68
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: doRegister } }, icon("user-plus"), t("account.register")),
69
+ sso && sso.enabled ? c.banner(t("account.sso"), "info") : null,
70
+ ),
71
+ }),
72
+ );
73
+ }
74
+
75
+ function signedInPanel(profile, sso) {
76
+ const name = input("text", t("account.name"), profile.name || "");
77
+ const nick = input("text", t("account.nickname"), profile.nickname || "");
78
+ const current = input("password", t("account.currentPassword"));
79
+ const next = input("password", t("account.newPassword"));
80
+
81
+ async function saveProfile() {
82
+ const res = await api.updateProfile({ name: name.value.trim(), nickname: nick.value.trim() });
83
+ toast(resultText(res, t("account.profileOk")), res.ok ? "ok" : "err");
84
+ if (res.ok) load();
85
+ }
86
+ async function savePassword() {
87
+ const res = await api.changePassword(current.value, next.value);
88
+ toast(resultText(res, t("account.passwordOk")), res.ok ? "ok" : "err");
89
+ if (res.ok) { current.value = ""; next.value = ""; }
90
+ }
91
+ async function doLogout() {
92
+ const res = await api.logout();
93
+ toast(resultText(res, t("account.logoutOk")), res.ok ? "ok" : "err");
94
+ if (res.ok) load();
95
+ }
96
+
97
+ return h("div.lt3-grid-2",
98
+ c.panel({
99
+ title: t("account.profile"),
100
+ actions: [c.statePill(t("account.signedIn"))],
101
+ children: h("div.lt3-stack-4",
102
+ h("dl.lt3-keyval",
103
+ h("dt", t("account.email")), h("dd", h("span.lt3-mono", profile.email || "—")),
104
+ h("dt", t("common.role")), h("dd", c.pill(profile.role || "user", "info")),
105
+ h("dt", t("account.sso")), h("dd", c.statePill(sso && sso.enabled ? "ready" : "idle")),
106
+ ),
107
+ field(ctx, t("account.name"), name),
108
+ field(ctx, t("account.nickname"), nick),
109
+ h("div.lt3-row-2",
110
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: saveProfile } }, icon("device-floppy"), t("common.save")),
111
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: doLogout } }, icon("logout"), t("account.logout")),
112
+ ),
113
+ ),
114
+ }),
115
+ c.panel({
116
+ title: t("account.changePassword"),
117
+ sub: t("account.passwordRule"),
118
+ children: h("div.lt3-stack-4",
119
+ field(ctx, t("account.currentPassword"), current),
120
+ field(ctx, t("account.newPassword"), next),
121
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: savePassword } }, icon("key"), t("account.changePassword")),
122
+ ),
123
+ }),
124
+ );
125
+ }
126
+
127
+ await load();
128
+ return host;
129
+
130
+ function input(type, label, value = "") {
131
+ return h("input.lt3-input", { type, value, autocomplete: type === "password" ? "current-password" : "on", "aria-label": label });
132
+ }
133
+ }
134
+
135
+ function field({ h }, label, control) {
136
+ return h("div.lt3-field", h("label.lt3-label", label), control);
137
+ }
138
+
139
+ function resultText(res, okText) {
140
+ if (res && res.ok) return okText;
141
+ const data = (res && res.data) || {};
142
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
143
+ }