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
@@ -8,6 +8,8 @@
8
8
  * ========================================================================== */
9
9
 
10
10
  import { escapeHtml } from "../core/dom.a2773eb0.js";
11
+ import { createGraphCanvas } from "./graph-canvas.17c15d65.js";
12
+ import { t } from "../core/i18n.880e1fec.js";
11
13
 
12
14
  const TYPE_COLOR = {
13
15
  Topic: "var(--lt3-pillar-graph)",
@@ -144,47 +146,42 @@ function buildExplore(ctx) {
144
146
  );
145
147
  }
146
148
 
149
+ // Live force-directed canvas (zoom / pan / drag / physics) — replaces the
150
+ // static SVG spiral. The renderer only draws the data it is given.
151
+ let graphCanvas = null;
152
+
153
+ function ensureGraphCanvas() {
154
+ if (graphCanvas) return graphCanvas;
155
+ graphCanvas = createGraphCanvas({
156
+ colorFor,
157
+ onSelect: (id) => {
158
+ state.selected = id;
159
+ renderInspector();
160
+ },
161
+ });
162
+ return graphCanvas;
163
+ }
164
+
147
165
  function renderCanvas() {
148
166
  const { nodes, edges } = state.data;
149
- if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
150
- const laidOut = layout(nodes);
151
- const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
152
- const W = 1000, H = 600;
153
- const edgeSvg = edges.map((e) => {
154
- const a = pos[e.from], b = pos[e.to];
155
- if (!a || !b) return "";
156
- return `<line class="lt3-gedge" x1="${a.px}" y1="${a.py}" x2="${b.px}" y2="${b.py}" stroke-width="${1 + (e.weight || 1) * 0.6}"></line>`;
157
- }).join("");
158
- const nodeSvg = laidOut.map((n) => {
159
- const r = 10 + (n.weight || 0.5) * 16;
160
- const sel = state.selected === n.id;
161
- return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
162
- <circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
163
- <text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
164
- </g>`;
165
- }).join("");
166
- canvasHost.replaceChildren(
167
- h("div.lt3-graph-canvas", {
168
- html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
169
- on: { click: onCanvasClick },
170
- }),
171
- );
167
+ if (!nodes.length) {
168
+ if (graphCanvas) { graphCanvas.destroy(); graphCanvas = null; }
169
+ canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." }));
170
+ return;
171
+ }
172
+ const gc = ensureGraphCanvas();
173
+ gc.setData({ nodes, edges });
174
+ gc.setSelected(state.selected);
175
+ if (!gc.el.isConnected || gc.el.parentElement !== canvasHost.querySelector(".lt3-graph-canvas")) {
176
+ canvasHost.replaceChildren(h("div.lt3-graph-canvas", gc.el));
177
+ }
172
178
  }
173
179
 
174
- function onCanvasClick(e) {
175
- const g = e.target.closest(".lt3-gnode");
176
- if (!g) return;
177
- state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
178
- renderCanvas();
180
+ function syncSelection() {
181
+ if (graphCanvas) graphCanvas.setSelected(state.selected);
179
182
  renderInspector();
180
183
  }
181
184
 
182
- function isNeighbor(id) {
183
- if (!state.selected) return false;
184
- return state.data.edges.some((e) =>
185
- (e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
186
- }
187
-
188
185
  function renderInspector() {
189
186
  if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
190
187
  const q = state.query;
@@ -199,7 +196,7 @@ function buildExplore(ctx) {
199
196
  }
200
197
 
201
198
  function entityRow(n) {
202
- return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
199
+ return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; syncSelection(); } } },
203
200
  h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
204
201
  h("div.lt3-entity__body",
205
202
  h("div.lt3-entity__name", n.label),
@@ -220,7 +217,7 @@ function buildExplore(ctx) {
220
217
  })
221
218
  .filter((r) => r.other);
222
219
  return h("div.lt3-stack-4",
223
- h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
220
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; syncSelection(); } } }, icon("arrow-left"), "All entities"),
224
221
  h("div.lt3-card.lt3-card--flat",
225
222
  h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
226
223
  h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
@@ -231,7 +228,7 @@ function buildExplore(ctx) {
231
228
  h("div",
232
229
  h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
233
230
  rels.length
234
- ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
231
+ ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; syncSelection(); } } },
235
232
  h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
236
233
  h("div.lt3-entity__body",
237
234
  h("div.lt3-entity__name", r.other.label),
@@ -251,21 +248,25 @@ function buildExplore(ctx) {
251
248
  async function renderStatus(ctx, host) {
252
249
  const { h, icon, api, c } = ctx;
253
250
  host.replaceChildren(c.loading({ lines: 3 }));
254
- const [port, gs, idx] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus()]);
251
+ const [port, gs, idx, coverage] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus(), api.kgProvenanceCoverage()]);
255
252
  const p = port.data || {};
256
253
  const prov = p.provenance || {};
254
+ const cov = coverage.data || {};
257
255
  const nodes = sumCounts((gs.data && gs.data.nodes) || {});
258
256
  const edges = sumCounts((gs.data && gs.data.edges) || {});
259
257
  const pipelines = (idx.data && idx.data.pipelines) || {};
258
+ const ratio = Number(cov.coverage_ratio ?? cov.ratio ?? 0);
259
+ const covered = Number(cov.nodes_with_provenance ?? cov.covered_nodes ?? 0);
260
260
 
261
261
  host.replaceChildren(
262
- h("div.lt3-row-2", c.sourceBadge(port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
262
+ h("div.lt3-row-2", c.sourceBadge(port.source === "live" || coverage.source === "live" ? "live" : port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
263
263
  p.graph_schema_version != null ? `Schema v${p.graph_schema_version} · embed dim ${p.embed_dim ?? "—"}` : "Knowledge Graph status")),
264
264
  h("div.lt3-statrow",
265
265
  c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
266
266
  c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
267
267
  c.stat({ label: "Ingested items", value: c.fmtNum(prov.total || 0), icon: "package-import" }),
268
268
  c.stat({ label: "Embedded (RAG-ready)", value: c.fmtNum(prov.embedded || 0), icon: "vector" }),
269
+ c.stat({ label: t("kg.provenanceCoverage"), value: `${Math.round(ratio * 100)}%`, icon: "shield-check", delta: `${covered}/${cov.total_nodes ?? nodes}` }),
269
270
  ),
270
271
  c.card(
271
272
  h("div.lt3-stack-3",
@@ -275,6 +276,14 @@ async function renderStatus(ctx, host) {
275
276
  pipelineRow(ctx, "Hybrid retrieval", pipelines.hybrid),
276
277
  ),
277
278
  ),
279
+ c.card(h("div.lt3-stack-3",
280
+ h("div.lt3-eyebrow", t("kg.provenanceCoverage")),
281
+ h("dl.lt3-keyval",
282
+ h("dt", t("kg.coveredNodes")), h("dd", `${covered}/${cov.total_nodes ?? nodes}`),
283
+ h("dt", t("kg.sourceTypes")), h("dd", compactCounts(cov.provenance_by_source_type || cov.by_source_type || {})),
284
+ h("dt", t("kg.uncoveredTypes")), h("dd", compactCounts(cov.uncovered_by_type || {})),
285
+ ),
286
+ )),
278
287
  prov.last_ingested_at
279
288
  ? h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, `Last ingestion: ${fmtWhen(prov.last_ingested_at)} · ${prov.duplicates || 0} duplicate(s) linked, not re-stored.`)
280
289
  : c.emptyState({ icon: "package-import", title: "Nothing ingested yet", body: "Add files or capture a page to populate the graph." }),
@@ -331,18 +340,18 @@ function buildCapture(ctx) {
331
340
 
332
341
  async function run() {
333
342
  const url = (input.value || "").trim();
334
- if (!url) { result.replaceChildren(c.banner({ tone: "warn", text: "Enter a URL first." })); return; }
343
+ if (!url) { result.replaceChildren(c.banner("Enter a URL first.", "warn", "alert-triangle")); return; }
335
344
  result.replaceChildren(c.loading({ lines: 1 }));
336
345
  const res = await api.browserReadUrl(url);
337
346
  const d = res.data || {};
338
347
  if (res.ok && d.status === "ok") {
339
- result.replaceChildren(c.banner({ tone: "ok", text: `Added to your Knowledge Graph${d.duplicate ? " (already present — linked)" : ""}. ${d.chunk_count || 0} chunk(s) indexed.` }));
348
+ result.replaceChildren(c.banner(`Added to your Knowledge Graph${d.duplicate ? " (already present — linked)" : ""}. ${d.chunk_count || 0} chunk(s) indexed.`, "ok", "circle-check"));
340
349
  ctx.toast && ctx.toast("Page added to Knowledge Graph");
341
350
  } else if (d.status === "empty") {
342
- result.replaceChildren(c.banner({ tone: "warn", text: "No readable text was found on that page." }));
351
+ result.replaceChildren(c.banner("No readable text was found on that page.", "warn", "alert-triangle"));
343
352
  } else {
344
353
  const detail = d.detail || (res.status === 422 ? "The page is blocked or login-required." : "Could not read that URL.");
345
- result.replaceChildren(c.banner({ tone: "err", text: detail }));
354
+ result.replaceChildren(c.banner(detail, "err", "alert-triangle"));
346
355
  }
347
356
  }
348
357
 
@@ -370,7 +379,9 @@ async function renderPortability(ctx, host) {
370
379
  const port = await api.kgPortability();
371
380
  const status = h("div");
372
381
 
373
- function note(tone, text) { status.replaceChildren(c.banner({ tone, text })); }
382
+ function note(tone, text) {
383
+ status.replaceChildren(c.banner(text, tone, tone === "err" ? "alert-triangle" : tone === "ok" ? "circle-check" : "info-circle"));
384
+ }
374
385
 
375
386
  async function doExport() {
376
387
  note("info", "Exporting…");
@@ -443,6 +454,11 @@ function sumCounts(obj) {
443
454
  return Object.values(obj || {}).reduce((a, b) => a + (Number(b) || 0), 0);
444
455
  }
445
456
 
457
+ function compactCounts(obj) {
458
+ const entries = Object.entries(obj || {});
459
+ return entries.length ? entries.map(([k, v]) => `${k}: ${v}`).join(" · ") : "—";
460
+ }
461
+
446
462
  function prettySource(k) {
447
463
  return ({ web_url: "Web URL", browser_tab: "Browser tab", file: "Files", local_file: "Local files",
448
464
  note: "Notes", text: "Text", markdown: "Markdown", code: "Code", upload: "Uploads" })[k] || k;
@@ -8,6 +8,8 @@
8
8
  * ========================================================================== */
9
9
 
10
10
  import { escapeHtml } from "../core/dom.js";
11
+ import { createGraphCanvas } from "./graph-canvas.js";
12
+ import { t } from "../core/i18n.js";
11
13
 
12
14
  const TYPE_COLOR = {
13
15
  Topic: "var(--lt3-pillar-graph)",
@@ -144,47 +146,42 @@ function buildExplore(ctx) {
144
146
  );
145
147
  }
146
148
 
149
+ // Live force-directed canvas (zoom / pan / drag / physics) — replaces the
150
+ // static SVG spiral. The renderer only draws the data it is given.
151
+ let graphCanvas = null;
152
+
153
+ function ensureGraphCanvas() {
154
+ if (graphCanvas) return graphCanvas;
155
+ graphCanvas = createGraphCanvas({
156
+ colorFor,
157
+ onSelect: (id) => {
158
+ state.selected = id;
159
+ renderInspector();
160
+ },
161
+ });
162
+ return graphCanvas;
163
+ }
164
+
147
165
  function renderCanvas() {
148
166
  const { nodes, edges } = state.data;
149
- if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
150
- const laidOut = layout(nodes);
151
- const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
152
- const W = 1000, H = 600;
153
- const edgeSvg = edges.map((e) => {
154
- const a = pos[e.from], b = pos[e.to];
155
- if (!a || !b) return "";
156
- return `<line class="lt3-gedge" x1="${a.px}" y1="${a.py}" x2="${b.px}" y2="${b.py}" stroke-width="${1 + (e.weight || 1) * 0.6}"></line>`;
157
- }).join("");
158
- const nodeSvg = laidOut.map((n) => {
159
- const r = 10 + (n.weight || 0.5) * 16;
160
- const sel = state.selected === n.id;
161
- return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
162
- <circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
163
- <text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
164
- </g>`;
165
- }).join("");
166
- canvasHost.replaceChildren(
167
- h("div.lt3-graph-canvas", {
168
- html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
169
- on: { click: onCanvasClick },
170
- }),
171
- );
167
+ if (!nodes.length) {
168
+ if (graphCanvas) { graphCanvas.destroy(); graphCanvas = null; }
169
+ canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." }));
170
+ return;
171
+ }
172
+ const gc = ensureGraphCanvas();
173
+ gc.setData({ nodes, edges });
174
+ gc.setSelected(state.selected);
175
+ if (!gc.el.isConnected || gc.el.parentElement !== canvasHost.querySelector(".lt3-graph-canvas")) {
176
+ canvasHost.replaceChildren(h("div.lt3-graph-canvas", gc.el));
177
+ }
172
178
  }
173
179
 
174
- function onCanvasClick(e) {
175
- const g = e.target.closest(".lt3-gnode");
176
- if (!g) return;
177
- state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
178
- renderCanvas();
180
+ function syncSelection() {
181
+ if (graphCanvas) graphCanvas.setSelected(state.selected);
179
182
  renderInspector();
180
183
  }
181
184
 
182
- function isNeighbor(id) {
183
- if (!state.selected) return false;
184
- return state.data.edges.some((e) =>
185
- (e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
186
- }
187
-
188
185
  function renderInspector() {
189
186
  if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
190
187
  const q = state.query;
@@ -199,7 +196,7 @@ function buildExplore(ctx) {
199
196
  }
200
197
 
201
198
  function entityRow(n) {
202
- return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
199
+ return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; syncSelection(); } } },
203
200
  h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
204
201
  h("div.lt3-entity__body",
205
202
  h("div.lt3-entity__name", n.label),
@@ -220,7 +217,7 @@ function buildExplore(ctx) {
220
217
  })
221
218
  .filter((r) => r.other);
222
219
  return h("div.lt3-stack-4",
223
- h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
220
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; syncSelection(); } } }, icon("arrow-left"), "All entities"),
224
221
  h("div.lt3-card.lt3-card--flat",
225
222
  h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
226
223
  h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
@@ -231,7 +228,7 @@ function buildExplore(ctx) {
231
228
  h("div",
232
229
  h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
233
230
  rels.length
234
- ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
231
+ ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; syncSelection(); } } },
235
232
  h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
236
233
  h("div.lt3-entity__body",
237
234
  h("div.lt3-entity__name", r.other.label),
@@ -251,21 +248,25 @@ function buildExplore(ctx) {
251
248
  async function renderStatus(ctx, host) {
252
249
  const { h, icon, api, c } = ctx;
253
250
  host.replaceChildren(c.loading({ lines: 3 }));
254
- const [port, gs, idx] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus()]);
251
+ const [port, gs, idx, coverage] = await Promise.all([api.kgPortability(), api.graphStats(), api.indexStatus(), api.kgProvenanceCoverage()]);
255
252
  const p = port.data || {};
256
253
  const prov = p.provenance || {};
254
+ const cov = coverage.data || {};
257
255
  const nodes = sumCounts((gs.data && gs.data.nodes) || {});
258
256
  const edges = sumCounts((gs.data && gs.data.edges) || {});
259
257
  const pipelines = (idx.data && idx.data.pipelines) || {};
258
+ const ratio = Number(cov.coverage_ratio ?? cov.ratio ?? 0);
259
+ const covered = Number(cov.nodes_with_provenance ?? cov.covered_nodes ?? 0);
260
260
 
261
261
  host.replaceChildren(
262
- h("div.lt3-row-2", c.sourceBadge(port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
262
+ h("div.lt3-row-2", c.sourceBadge(port.source === "live" || coverage.source === "live" ? "live" : port.source), h("span.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } },
263
263
  p.graph_schema_version != null ? `Schema v${p.graph_schema_version} · embed dim ${p.embed_dim ?? "—"}` : "Knowledge Graph status")),
264
264
  h("div.lt3-statrow",
265
265
  c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
266
266
  c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
267
267
  c.stat({ label: "Ingested items", value: c.fmtNum(prov.total || 0), icon: "package-import" }),
268
268
  c.stat({ label: "Embedded (RAG-ready)", value: c.fmtNum(prov.embedded || 0), icon: "vector" }),
269
+ c.stat({ label: t("kg.provenanceCoverage"), value: `${Math.round(ratio * 100)}%`, icon: "shield-check", delta: `${covered}/${cov.total_nodes ?? nodes}` }),
269
270
  ),
270
271
  c.card(
271
272
  h("div.lt3-stack-3",
@@ -275,6 +276,14 @@ async function renderStatus(ctx, host) {
275
276
  pipelineRow(ctx, "Hybrid retrieval", pipelines.hybrid),
276
277
  ),
277
278
  ),
279
+ c.card(h("div.lt3-stack-3",
280
+ h("div.lt3-eyebrow", t("kg.provenanceCoverage")),
281
+ h("dl.lt3-keyval",
282
+ h("dt", t("kg.coveredNodes")), h("dd", `${covered}/${cov.total_nodes ?? nodes}`),
283
+ h("dt", t("kg.sourceTypes")), h("dd", compactCounts(cov.provenance_by_source_type || cov.by_source_type || {})),
284
+ h("dt", t("kg.uncoveredTypes")), h("dd", compactCounts(cov.uncovered_by_type || {})),
285
+ ),
286
+ )),
278
287
  prov.last_ingested_at
279
288
  ? h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, `Last ingestion: ${fmtWhen(prov.last_ingested_at)} · ${prov.duplicates || 0} duplicate(s) linked, not re-stored.`)
280
289
  : c.emptyState({ icon: "package-import", title: "Nothing ingested yet", body: "Add files or capture a page to populate the graph." }),
@@ -331,18 +340,18 @@ function buildCapture(ctx) {
331
340
 
332
341
  async function run() {
333
342
  const url = (input.value || "").trim();
334
- if (!url) { result.replaceChildren(c.banner({ tone: "warn", text: "Enter a URL first." })); return; }
343
+ if (!url) { result.replaceChildren(c.banner("Enter a URL first.", "warn", "alert-triangle")); return; }
335
344
  result.replaceChildren(c.loading({ lines: 1 }));
336
345
  const res = await api.browserReadUrl(url);
337
346
  const d = res.data || {};
338
347
  if (res.ok && d.status === "ok") {
339
- result.replaceChildren(c.banner({ tone: "ok", text: `Added to your Knowledge Graph${d.duplicate ? " (already present — linked)" : ""}. ${d.chunk_count || 0} chunk(s) indexed.` }));
348
+ result.replaceChildren(c.banner(`Added to your Knowledge Graph${d.duplicate ? " (already present — linked)" : ""}. ${d.chunk_count || 0} chunk(s) indexed.`, "ok", "circle-check"));
340
349
  ctx.toast && ctx.toast("Page added to Knowledge Graph");
341
350
  } else if (d.status === "empty") {
342
- result.replaceChildren(c.banner({ tone: "warn", text: "No readable text was found on that page." }));
351
+ result.replaceChildren(c.banner("No readable text was found on that page.", "warn", "alert-triangle"));
343
352
  } else {
344
353
  const detail = d.detail || (res.status === 422 ? "The page is blocked or login-required." : "Could not read that URL.");
345
- result.replaceChildren(c.banner({ tone: "err", text: detail }));
354
+ result.replaceChildren(c.banner(detail, "err", "alert-triangle"));
346
355
  }
347
356
  }
348
357
 
@@ -370,7 +379,9 @@ async function renderPortability(ctx, host) {
370
379
  const port = await api.kgPortability();
371
380
  const status = h("div");
372
381
 
373
- function note(tone, text) { status.replaceChildren(c.banner({ tone, text })); }
382
+ function note(tone, text) {
383
+ status.replaceChildren(c.banner(text, tone, tone === "err" ? "alert-triangle" : tone === "ok" ? "circle-check" : "info-circle"));
384
+ }
374
385
 
375
386
  async function doExport() {
376
387
  note("info", "Exporting…");
@@ -443,6 +454,11 @@ function sumCounts(obj) {
443
454
  return Object.values(obj || {}).reduce((a, b) => a + (Number(b) || 0), 0);
444
455
  }
445
456
 
457
+ function compactCounts(obj) {
458
+ const entries = Object.entries(obj || {});
459
+ return entries.length ? entries.map(([k, v]) => `${k}: ${v}`).join(" · ") : "—";
460
+ }
461
+
446
462
  function prettySource(k) {
447
463
  return ({ web_url: "Web URL", browser_tab: "Browser tab", file: "Files", local_file: "Local files",
448
464
  note: "Notes", text: "Text", markdown: "Markdown", code: "Code", upload: "Uploads" })[k] || k;
@@ -0,0 +1,97 @@
1
+ import { t } from "../core/i18n.880e1fec.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, c, toast, store } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 5, block: true }));
6
+
7
+ async function load() {
8
+ const [identity, peers] = await Promise.all([api.networkIdentity(), api.networkPeers()]);
9
+ host.replaceChildren(
10
+ c.viewHeader({
11
+ eyebrow: t("network.eyebrow"),
12
+ title: t("network.title"),
13
+ sub: t("network.sub"),
14
+ actions: [c.sourceBadge(identity.source === "live" || peers.source === "live" ? "live" : "unavailable")],
15
+ }),
16
+ identityPanel(identity),
17
+ pairPanel(),
18
+ peersPanel(peers),
19
+ );
20
+ }
21
+
22
+ function identityPanel(res) {
23
+ const d = res.data || {};
24
+ return c.panel({
25
+ title: t("network.identity"),
26
+ actions: [c.sourceBadge(res.source)],
27
+ children: h("dl.lt3-keyval",
28
+ h("dt", "device_id"), h("dd", h("span.lt3-mono", d.device_id || d.id || "—")),
29
+ h("dt", "fingerprint"), h("dd", h("span.lt3-mono", d.fingerprint || d.public_key_fingerprint || "—")),
30
+ h("dt", t("network.publicKey")), h("dd", h("pre.lt3-code", truncate(d.public_key || "—", 420))),
31
+ ),
32
+ });
33
+ }
34
+
35
+ function pairPanel() {
36
+ const name = h("input.lt3-input", { type: "text", placeholder: t("network.peerName"), "aria-label": t("network.peerName") });
37
+ const base = h("input.lt3-input", { type: "url", placeholder: t("network.baseUrl"), "aria-label": t("network.baseUrl") });
38
+ const key = h("textarea.lt3-textarea", { rows: 3, placeholder: t("network.publicKey"), "aria-label": t("network.publicKey") });
39
+ return c.panel({
40
+ title: t("network.pair"),
41
+ children: h("div.lt3-stack-4",
42
+ h("div.lt3-grid-2", field(ctx, t("network.peerName"), name), field(ctx, t("network.baseUrl"), base)),
43
+ field(ctx, t("network.publicKey"), key),
44
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: async () => {
45
+ const res = await api.pairPeer({ name: name.value.trim(), base_url: base.value.trim(), public_key: key.value.trim() });
46
+ toast(resultText(res, t("network.paired")), res.ok ? "ok" : "err");
47
+ if (res.ok) load();
48
+ } } }, icon("link"), t("network.pair")),
49
+ ),
50
+ });
51
+ }
52
+
53
+ function peersPanel(res) {
54
+ const rows = Array.isArray(res.data?.peers) ? res.data.peers : [];
55
+ return c.panel({
56
+ title: t("network.peers"),
57
+ actions: [c.sourceBadge(res.source)],
58
+ children: rows.length ? c.table([
59
+ { key: "name", label: t("common.name"), render: (p) => h("div", h("b", p.name || p.peer_id || p.id), h("div.lt3-faint", { style: { "font-family": "var(--lt3-font-mono)", "font-size": "var(--lt3-text-2xs)" } }, p.peer_id || p.id || "")) },
60
+ { key: "base", label: t("network.baseUrl"), render: (p) => p.base_url || "—" },
61
+ { key: "fp", label: "fingerprint", render: (p) => h("span.lt3-mono", p.fingerprint || p.public_key_fingerprint || "—") },
62
+ { key: "act", label: "", width: "1%", render: (p) => h("div.lt3-row-2",
63
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => pushPeer(p.peer_id || p.id) } }, icon("send"), t("network.push")),
64
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => unpairPeer(p.peer_id || p.id) } }, icon("unlink"), t("network.unpair")),
65
+ ) },
66
+ ], rows) : c.emptyState({ icon: "network-off", title: t("network.peers"), body: t("common.none") }),
67
+ });
68
+ }
69
+
70
+ async function pushPeer(peerId) {
71
+ const res = await api.pushPeer(peerId, store.get().workspaceId);
72
+ toast(resultText(res, t("network.pushed")), res.ok ? "ok" : "err");
73
+ }
74
+ async function unpairPeer(peerId) {
75
+ const res = await api.unpairPeer(peerId);
76
+ toast(resultText(res, t("network.unpaired")), res.ok ? "ok" : "err");
77
+ if (res.ok) load();
78
+ }
79
+
80
+ await load();
81
+ return host;
82
+ }
83
+
84
+ function field({ h }, label, control) {
85
+ return h("div.lt3-field", h("label.lt3-label", label), control);
86
+ }
87
+
88
+ function truncate(value, n) {
89
+ const s = String(value || "");
90
+ return s.length > n ? `${s.slice(0, n)}…` : s;
91
+ }
92
+
93
+ function resultText(res, okText) {
94
+ if (res && res.ok) return okText;
95
+ const data = (res && res.data) || {};
96
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
97
+ }
@@ -0,0 +1,97 @@
1
+ import { t } from "../core/i18n.js";
2
+
3
+ export async function render(ctx) {
4
+ const { h, icon, api, c, toast, store } = ctx;
5
+ const host = h("div.lt3-stack-6", c.loading({ lines: 5, block: true }));
6
+
7
+ async function load() {
8
+ const [identity, peers] = await Promise.all([api.networkIdentity(), api.networkPeers()]);
9
+ host.replaceChildren(
10
+ c.viewHeader({
11
+ eyebrow: t("network.eyebrow"),
12
+ title: t("network.title"),
13
+ sub: t("network.sub"),
14
+ actions: [c.sourceBadge(identity.source === "live" || peers.source === "live" ? "live" : "unavailable")],
15
+ }),
16
+ identityPanel(identity),
17
+ pairPanel(),
18
+ peersPanel(peers),
19
+ );
20
+ }
21
+
22
+ function identityPanel(res) {
23
+ const d = res.data || {};
24
+ return c.panel({
25
+ title: t("network.identity"),
26
+ actions: [c.sourceBadge(res.source)],
27
+ children: h("dl.lt3-keyval",
28
+ h("dt", "device_id"), h("dd", h("span.lt3-mono", d.device_id || d.id || "—")),
29
+ h("dt", "fingerprint"), h("dd", h("span.lt3-mono", d.fingerprint || d.public_key_fingerprint || "—")),
30
+ h("dt", t("network.publicKey")), h("dd", h("pre.lt3-code", truncate(d.public_key || "—", 420))),
31
+ ),
32
+ });
33
+ }
34
+
35
+ function pairPanel() {
36
+ const name = h("input.lt3-input", { type: "text", placeholder: t("network.peerName"), "aria-label": t("network.peerName") });
37
+ const base = h("input.lt3-input", { type: "url", placeholder: t("network.baseUrl"), "aria-label": t("network.baseUrl") });
38
+ const key = h("textarea.lt3-textarea", { rows: 3, placeholder: t("network.publicKey"), "aria-label": t("network.publicKey") });
39
+ return c.panel({
40
+ title: t("network.pair"),
41
+ children: h("div.lt3-stack-4",
42
+ h("div.lt3-grid-2", field(ctx, t("network.peerName"), name), field(ctx, t("network.baseUrl"), base)),
43
+ field(ctx, t("network.publicKey"), key),
44
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: async () => {
45
+ const res = await api.pairPeer({ name: name.value.trim(), base_url: base.value.trim(), public_key: key.value.trim() });
46
+ toast(resultText(res, t("network.paired")), res.ok ? "ok" : "err");
47
+ if (res.ok) load();
48
+ } } }, icon("link"), t("network.pair")),
49
+ ),
50
+ });
51
+ }
52
+
53
+ function peersPanel(res) {
54
+ const rows = Array.isArray(res.data?.peers) ? res.data.peers : [];
55
+ return c.panel({
56
+ title: t("network.peers"),
57
+ actions: [c.sourceBadge(res.source)],
58
+ children: rows.length ? c.table([
59
+ { key: "name", label: t("common.name"), render: (p) => h("div", h("b", p.name || p.peer_id || p.id), h("div.lt3-faint", { style: { "font-family": "var(--lt3-font-mono)", "font-size": "var(--lt3-text-2xs)" } }, p.peer_id || p.id || "")) },
60
+ { key: "base", label: t("network.baseUrl"), render: (p) => p.base_url || "—" },
61
+ { key: "fp", label: "fingerprint", render: (p) => h("span.lt3-mono", p.fingerprint || p.public_key_fingerprint || "—") },
62
+ { key: "act", label: "", width: "1%", render: (p) => h("div.lt3-row-2",
63
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => pushPeer(p.peer_id || p.id) } }, icon("send"), t("network.push")),
64
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => unpairPeer(p.peer_id || p.id) } }, icon("unlink"), t("network.unpair")),
65
+ ) },
66
+ ], rows) : c.emptyState({ icon: "network-off", title: t("network.peers"), body: t("common.none") }),
67
+ });
68
+ }
69
+
70
+ async function pushPeer(peerId) {
71
+ const res = await api.pushPeer(peerId, store.get().workspaceId);
72
+ toast(resultText(res, t("network.pushed")), res.ok ? "ok" : "err");
73
+ }
74
+ async function unpairPeer(peerId) {
75
+ const res = await api.unpairPeer(peerId);
76
+ toast(resultText(res, t("network.unpaired")), res.ok ? "ok" : "err");
77
+ if (res.ok) load();
78
+ }
79
+
80
+ await load();
81
+ return host;
82
+ }
83
+
84
+ function field({ h }, label, control) {
85
+ return h("div.lt3-field", h("label.lt3-label", label), control);
86
+ }
87
+
88
+ function truncate(value, n) {
89
+ const s = String(value || "");
90
+ return s.length > n ? `${s.slice(0, n)}…` : s;
91
+ }
92
+
93
+ function resultText(res, okText) {
94
+ if (res && res.ok) return okText;
95
+ const data = (res && res.data) || {};
96
+ return String(data.detail || data.error || res?.error || t("common.unavailable"));
97
+ }