ltcai 2.2.2 → 3.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 (78) hide show
  1. package/README.md +66 -27
  2. package/codex_telegram_bot.py +6 -2
  3. package/docs/CHANGELOG.md +154 -0
  4. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  5. package/docs/V3_FRONTEND.md +136 -0
  6. package/knowledge_graph.py +649 -21
  7. package/latticeai/__init__.py +1 -1
  8. package/latticeai/api/admin.py +47 -0
  9. package/latticeai/api/agents.py +54 -31
  10. package/latticeai/api/auth.py +1 -1
  11. package/latticeai/api/chat.py +10 -2
  12. package/latticeai/api/search.py +236 -0
  13. package/latticeai/api/static_routes.py +21 -2
  14. package/latticeai/core/config.py +16 -0
  15. package/latticeai/core/embedding_providers.py +502 -0
  16. package/latticeai/core/local_embeddings.py +86 -0
  17. package/latticeai/core/logging_safety.py +62 -0
  18. package/latticeai/core/workspace_os.py +1 -1
  19. package/latticeai/server_app.py +49 -1
  20. package/latticeai/services/agent_runtime.py +245 -0
  21. package/latticeai/services/search_service.py +346 -0
  22. package/package.json +8 -4
  23. package/static/account.html +9 -4
  24. package/static/activity.html +4 -4
  25. package/static/admin.html +8 -3
  26. package/static/agents.html +4 -4
  27. package/static/chat.html +16 -11
  28. package/static/css/reference/account.css +439 -0
  29. package/static/css/reference/admin.css +610 -0
  30. package/static/css/reference/base.css +1658 -0
  31. package/static/{lattice-reference.css → css/reference/chat.css} +271 -3633
  32. package/static/css/reference/graph.css +1016 -0
  33. package/static/css/responsive.css +248 -1
  34. package/static/css/tokens.css +132 -126
  35. package/static/favicon.ico +0 -0
  36. package/static/graph.html +9 -4
  37. package/static/manifest.json +3 -3
  38. package/static/platform.css +1 -1
  39. package/static/plugins.html +4 -4
  40. package/static/scripts/account.js +4 -4
  41. package/static/scripts/chat.js +227 -77
  42. package/static/scripts/workspace.js +78 -0
  43. package/static/sw.js +5 -3
  44. package/static/v3/css/lattice.base.css +128 -0
  45. package/static/v3/css/lattice.components.css +447 -0
  46. package/static/v3/css/lattice.shell.css +407 -0
  47. package/static/v3/css/lattice.tokens.css +132 -0
  48. package/static/v3/css/lattice.views.css +277 -0
  49. package/static/v3/index.html +40 -0
  50. package/static/v3/js/app.js +26 -0
  51. package/static/v3/js/core/api.js +327 -0
  52. package/static/v3/js/core/components.js +215 -0
  53. package/static/v3/js/core/dom.js +148 -0
  54. package/static/v3/js/core/fixtures.js +171 -0
  55. package/static/v3/js/core/router.js +37 -0
  56. package/static/v3/js/core/routes.js +73 -0
  57. package/static/v3/js/core/shell.js +363 -0
  58. package/static/v3/js/core/store.js +113 -0
  59. package/static/v3/js/views/admin-audit.js +185 -0
  60. package/static/v3/js/views/admin-permissions.js +178 -0
  61. package/static/v3/js/views/admin-policies.js +103 -0
  62. package/static/v3/js/views/admin-private-vpc.js +138 -0
  63. package/static/v3/js/views/admin-security.js +181 -0
  64. package/static/v3/js/views/admin-users.js +168 -0
  65. package/static/v3/js/views/agents.js +194 -0
  66. package/static/v3/js/views/chat.js +450 -0
  67. package/static/v3/js/views/files.js +180 -0
  68. package/static/v3/js/views/home.js +119 -0
  69. package/static/v3/js/views/hybrid-search.js +195 -0
  70. package/static/v3/js/views/knowledge-graph.js +238 -0
  71. package/static/v3/js/views/models.js +247 -0
  72. package/static/v3/js/views/my-computer.js +237 -0
  73. package/static/v3/js/views/pipeline.js +161 -0
  74. package/static/v3/js/views/settings.js +258 -0
  75. package/static/workflows.html +4 -4
  76. package/static/workspace.css +408 -14
  77. package/static/workspace.html +43 -24
  78. package/telegram_bot.py +18 -14
@@ -0,0 +1,327 @@
1
+ /* ============================================================================
2
+ * Lattice AI v3 — Integration adapter
3
+ *
4
+ * Every adapter call hits the real endpoint first (including /api/index/status,
5
+ * /api/graph, /api/search/hybrid, and /chat). If that
6
+ * endpoint is missing/unavailable, it degrades to a clearly-labeled SAMPLE
7
+ * payload from fixtures.js and reports `source: "placeholder"` so the UI can
8
+ * badge it. No backend logic is implemented here — only transport + graceful
9
+ * fallback, which keeps the v3 frontend resilient during local setup.
10
+ *
11
+ * Return shape (never throws): { ok, status, data, source, error }
12
+ * source: "live" → returned by a real backend endpoint
13
+ * "placeholder" → fixture fallback (backend not yet available)
14
+ * ========================================================================== */
15
+
16
+ import { store } from "./store.js";
17
+ import * as fx from "./fixtures.js";
18
+
19
+ const TIMEOUT_MS = 8000;
20
+
21
+ async function raw(path, { method = "GET", body, headers } = {}) {
22
+ const ctrl = new AbortController();
23
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
24
+ try {
25
+ const ws = store.get().workspaceId;
26
+ const res = await fetch(path, {
27
+ method,
28
+ credentials: "same-origin",
29
+ signal: ctrl.signal,
30
+ headers: {
31
+ "Accept": "application/json",
32
+ ...(body ? { "Content-Type": "application/json" } : {}),
33
+ ...(ws ? { "X-Workspace-Id": ws } : {}),
34
+ ...headers,
35
+ },
36
+ body: body ? JSON.stringify(body) : undefined,
37
+ });
38
+ let data = null;
39
+ const text = await res.text();
40
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
41
+ return { ok: res.ok, status: res.status, data };
42
+ } catch (err) {
43
+ return { ok: false, status: 0, data: null, error: err && err.name === "AbortError" ? "timeout" : String(err) };
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ /** Try the live endpoint; on any non-2xx/transport failure, use the fixture. */
50
+ async function withFallback(path, opts, fixture) {
51
+ const res = await raw(path, opts);
52
+ if (res.ok && res.data && !res.data.raw) {
53
+ return { ...res, source: "live" };
54
+ }
55
+ return { ok: true, status: res.status, data: typeof fixture === "function" ? fixture() : fixture, source: "placeholder", error: res.error };
56
+ }
57
+
58
+ export const api = {
59
+ raw,
60
+
61
+ /** Generic GET with fixture fallback. */
62
+ async get(path, fixture = null) {
63
+ return withFallback(path, {}, fixture);
64
+ },
65
+
66
+ /* ── Documented future surfaces ─────────────────────────────────────── */
67
+
68
+ /** GET /api/index/status — KG + Vector + Hybrid pipeline state. */
69
+ indexStatus() {
70
+ return withFallback("/api/index/status", {}, fx.INDEX_STATUS);
71
+ },
72
+
73
+ /** POST /api/index/rebuild — rebuild the derived vector index (real run). */
74
+ rebuildIndex(opts = {}) {
75
+ return raw("/api/index/rebuild", { method: "POST", body: { full: false, include_nodes: true, include_chunks: true, ...opts } });
76
+ },
77
+
78
+ /** GET /api/graph — knowledge graph (nodes + edges). Falls back through the
79
+ * current /knowledge-graph/graph route before the fixture. */
80
+ async graph(params = {}) {
81
+ const qs = new URLSearchParams(params).toString();
82
+ const primary = await raw(`/api/graph${qs ? "?" + qs : ""}`);
83
+ if (primary.ok && primary.data && Array.isArray(primary.data.nodes)) {
84
+ return { ...primary, source: "live" };
85
+ }
86
+ const legacy = await raw("/knowledge-graph/graph");
87
+ if (legacy.ok && legacy.data && Array.isArray(legacy.data.nodes)) {
88
+ return { ...legacy, source: "live" };
89
+ }
90
+ return { ok: true, status: 200, data: fx.GRAPH, source: "placeholder" };
91
+ },
92
+
93
+ graphStats() {
94
+ return withFallback("/knowledge-graph/stats", {}, fx.GRAPH_STATS);
95
+ },
96
+
97
+ /** POST /api/search/hybrid — fused KG + vector retrieval.
98
+ * The backend returns `{ matches: [...] }` where each match carries
99
+ * `source_scores: { keyword, vector, graph }`. Normalize that into the flat
100
+ * result shape the view renders (title/path/snippet/score + per-signal). A
101
+ * legacy `results` array is also accepted defensively. */
102
+ async hybridSearch(query, opts = {}) {
103
+ const res = await raw("/api/search/hybrid", { method: "POST", body: { query, ...opts } });
104
+ const live = res.ok && res.data
105
+ ? (Array.isArray(res.data.matches) ? res.data.matches
106
+ : Array.isArray(res.data.results) ? res.data.results
107
+ : null)
108
+ : null;
109
+ if (live) {
110
+ const items = live.map((m) => {
111
+ const ss = m.source_scores || {};
112
+ const meta = m.metadata || {};
113
+ return {
114
+ id: m.id || m.node_id,
115
+ title: m.title || m.id || "Untitled",
116
+ path: meta.path || meta.source || m.path || m.type || "",
117
+ snippet: m.snippet || m.summary || "",
118
+ score: typeof m.score === "number" ? m.score : 0,
119
+ vector: Number(ss.vector ?? m.vector) || 0,
120
+ lexical: Number(ss.keyword ?? m.lexical) || 0,
121
+ graph: Number(ss.graph ?? m.graph) || 0,
122
+ };
123
+ });
124
+ return { ok: true, status: res.status, data: items, source: "live", weights: res.data.weights || null };
125
+ }
126
+ return { ok: true, status: res.status, data: fx.hybridResults(query), source: "placeholder", error: res.error };
127
+ },
128
+
129
+ /* ── Existing surfaces (used where helpful, all fallback-safe) ──────── */
130
+ workspaceOs() { return withFallback("/workspace/os", {}, fx.WORKSPACE_OS); },
131
+ async models() {
132
+ const res = await raw("/models");
133
+ if (res.ok && res.data && !res.data.raw) {
134
+ const data = res.data;
135
+ const loadedIds = Array.isArray(data.loaded) ? data.loaded : [];
136
+ const recommended = Array.isArray(data.recommended) ? data.recommended.map((m) => ({
137
+ ...m,
138
+ name: m.name || m.display_name || m.id,
139
+ family: m.family || m.modality || "local",
140
+ state: loadedIds.includes(m.id) || data.current === m.id ? "loaded" : "available",
141
+ })) : [];
142
+ const loadedOnly = loadedIds
143
+ .filter((id) => !recommended.some((m) => m.id === id))
144
+ .map((id) => ({ id, name: id, family: "local", state: data.current === id ? "loaded" : "available" }));
145
+ return {
146
+ ok: true,
147
+ status: res.status,
148
+ source: "live",
149
+ data: { ...data, catalog: Array.isArray(data.catalog) ? data.catalog : [...recommended, ...loadedOnly] },
150
+ };
151
+ }
152
+ return { ok: true, status: res.status, data: fx.MODELS, source: "placeholder", error: res.error };
153
+ },
154
+ sysinfo() { return withFallback("/local/sysinfo", {}, fx.SYSINFO); },
155
+
156
+ adminSummary() { return withFallback("/admin/summary", {}, fx.ADMIN.summary); },
157
+ adminUsers() { return withFallback("/admin/users", {}, fx.ADMIN.users); },
158
+ adminAudit() { return withFallback("/admin/audit", {}, { recent_events: fx.ADMIN.audit }); },
159
+ adminSecurity() { return withFallback("/admin/security/overview", {}, fx.ADMIN.security); },
160
+ adminRoles() { return withFallback("/admin/roles", {}, { roles: fx.ADMIN.roles }); },
161
+ adminPolicies() { return withFallback("/admin/policies", {}, { policies: fx.ADMIN.policies }); },
162
+ vpcStatus() { return withFallback("/vpc/status", {}, fx.ADMIN.vpc); },
163
+
164
+ /* ── Embeddings (real backend: /api/embeddings/*) ───────────────────── */
165
+ /** GET /api/embeddings/status — active provider, grade, dimensions, last index. */
166
+ async embeddingsStatus() {
167
+ const res = await raw("/api/embeddings/status");
168
+ if (res.ok && res.data && res.data.provider) {
169
+ return { ok: true, status: res.status, data: res.data, source: "live" };
170
+ }
171
+ // No backend → report unavailable honestly (never fabricate a provider).
172
+ return {
173
+ ok: true, status: res.status, source: "placeholder",
174
+ data: { provider: "hash", active_provider: "hash", model: "lattice-local-hash-v1",
175
+ model_id: "lattice-local-hash-v1:384", dimensions: 384, grade: "fallback",
176
+ state: "fallback", fell_back: false, health: { status: "unknown", detail: "backend unavailable" },
177
+ last_indexed_at: null },
178
+ };
179
+ },
180
+ embeddingsProviders() { return withFallback("/api/embeddings/providers", {}, { active: "hash", providers: [] }); },
181
+
182
+ /* ── Agents (real backend: AgentRuntime /agents/api/runtime/*) ───────── */
183
+ /** GET /agents/api/runtime/status — roles, roster, runs, health from the runtime. */
184
+ async agentRuntime() {
185
+ const res = await raw("/agents/api/runtime/status");
186
+ if (res.ok && res.data && res.data.runtime && Array.isArray(res.data.agents)) {
187
+ return { ok: true, status: res.status, data: res.data, source: "live" };
188
+ }
189
+ // Fallback: clearly-badged sample roster, no fabricated run ledger.
190
+ return {
191
+ ok: true, status: res.status, source: "placeholder",
192
+ data: { runtime: { ready: false, total_runs: 0, active_runs: 0 },
193
+ health: { status: "unknown", checks: {} }, roles: [],
194
+ agents: fx.AGENTS.map((a) => ({ ...a, last_status: null, last_at: null })), runs: [] },
195
+ };
196
+ },
197
+ /** POST /agents/api/run — execute the multi-agent pipeline for a goal. */
198
+ runAgent(goal, roles) { return raw("/agents/api/run", { method: "POST", body: { goal, roles: roles || [] } }); },
199
+
200
+ /* ── Local computer memory (real backend: /workspace/computer-memory) ── */
201
+ computerMemory() { return raw("/workspace/computer-memory"); },
202
+ setComputerMemory(enabled) {
203
+ return raw("/workspace/computer-memory", { method: "POST", body: { enabled, consent: { approved: !!enabled } } });
204
+ },
205
+
206
+ /* ── Organization workspaces (real backend: /workspace/orgs) ────────── */
207
+ createOrg(name) { return raw("/workspace/orgs", { method: "POST", body: { name } }); },
208
+
209
+ /* ── Chat (real backend: SSE /chat + /history/*) ────────────────────── */
210
+
211
+ /** GET /history/conversations — conversation list. */
212
+ async chatHistory() {
213
+ const res = await raw("/history/conversations");
214
+ const list = res.ok && Array.isArray(res.data) ? res.data
215
+ : res.ok && res.data && Array.isArray(res.data.conversations) ? res.data.conversations
216
+ : null;
217
+ if (list) return { ok: true, status: res.status, data: list, source: "live" };
218
+ return { ok: true, status: res.status, data: fx.CHAT.conversations, source: "placeholder" };
219
+ },
220
+
221
+ /** GET /history/conversations/{id} — messages for one conversation. */
222
+ async conversation(id) {
223
+ const res = await raw(`/history/conversations/${encodeURIComponent(id)}`);
224
+ if (res.ok && res.data && Array.isArray(res.data.messages)) {
225
+ return { ok: true, status: res.status, data: res.data.messages, source: "live" };
226
+ }
227
+ const sample = (fx.CHAT.conversations.find((c) => c.id === id) || {}).messages || [];
228
+ return { ok: true, status: res.status, data: sample, source: "placeholder" };
229
+ },
230
+
231
+ deleteConversation(id) {
232
+ return raw(`/history/conversations/${encodeURIComponent(id)}`, { method: "DELETE" });
233
+ },
234
+
235
+ /**
236
+ * POST /chat — streams the assistant reply over SSE.
237
+ * Parses `data: {chunk, model, trace}` events (terminator `[DONE]`), calling
238
+ * onChunk(delta, fullText) and onTrace(trace). If the endpoint is missing or
239
+ * not an event-stream, degrades to a clearly-labeled SAMPLE stream (no real
240
+ * generation is invented). Resolves { source, text, trace, model, aborted }.
241
+ */
242
+ async streamChat(body, { onChunk, onTrace, signal } = {}) {
243
+ const ws = store.get().workspaceId;
244
+ let res;
245
+ try {
246
+ res = await fetch("/chat", {
247
+ method: "POST",
248
+ credentials: "same-origin",
249
+ signal,
250
+ headers: {
251
+ "Content-Type": "application/json",
252
+ "Accept": "text/event-stream",
253
+ ...(ws ? { "X-Workspace-Id": ws } : {}),
254
+ },
255
+ body: JSON.stringify({ stream: true, max_tokens: 2048, temperature: 0.2, ...body }),
256
+ });
257
+ } catch (err) {
258
+ if (err && err.name === "AbortError") return { source: "live", text: "", aborted: true };
259
+ return simulateChat(body, { onChunk, onTrace, signal });
260
+ }
261
+ const ctype = res.headers.get("content-type") || "";
262
+ if (!res.ok) {
263
+ let data = null;
264
+ try { data = await res.clone().json(); } catch {}
265
+ const detail = data && (data.detail || data.message || data.error);
266
+ const noModel = data && (data.error === "no_model_loaded" || /no .*model .*loaded/i.test(String(detail || "")));
267
+ if (noModel) {
268
+ return { source: "live", text: "", error: "no_model_loaded", errorMessage: String(detail || "No local model is loaded.") };
269
+ }
270
+ return simulateChat(body, { onChunk, onTrace, signal });
271
+ }
272
+ if (!res.body || !ctype.includes("text/event-stream")) {
273
+ return simulateChat(body, { onChunk, onTrace, signal });
274
+ }
275
+ const reader = res.body.getReader();
276
+ const decoder = new TextDecoder();
277
+ let buf = "", text = "", trace = null, model = null;
278
+ try {
279
+ for (;;) {
280
+ const { value, done } = await reader.read();
281
+ if (done) break;
282
+ buf += decoder.decode(value, { stream: true });
283
+ const parts = buf.split("\n\n");
284
+ buf = parts.pop();
285
+ for (const part of parts) {
286
+ const line = part.split("\n").find((l) => l.startsWith("data:"));
287
+ if (!line) continue;
288
+ const rawData = line.slice(5).trim();
289
+ if (rawData === "[DONE]") return { source: "live", text, trace, model };
290
+ let data; try { data = JSON.parse(rawData); } catch { continue; }
291
+ if (data.chunk) { text += data.chunk; onChunk && onChunk(data.chunk, text); }
292
+ if (data.model) model = data.model;
293
+ if (data.trace) { trace = data.trace; onTrace && onTrace(trace); }
294
+ }
295
+ }
296
+ } catch (err) {
297
+ if (err && err.name === "AbortError") return { source: "live", text, trace, model, aborted: true };
298
+ if (!text) return simulateChat(body, { onChunk, onTrace, signal });
299
+ }
300
+ return { source: "live", text, trace, model };
301
+ },
302
+ };
303
+
304
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
305
+
306
+ /** Transparent SAMPLE stream — used only when no chat backend is available. */
307
+ async function simulateChat(body, { onChunk, onTrace, signal } = {}) {
308
+ const q = (body && body.message) || "your question";
309
+ const reply =
310
+ `This is a sample response. Connect a local model and the v3 retrieval backend ` +
311
+ `(/api/search/hybrid, /api/graph) to ground answers to “${q}” in your workspace. ` +
312
+ `The context panel shows the knowledge-graph entities, vector matches, and indexed ` +
313
+ `files that would be used to answer — this preview does not run a model.`;
314
+ let text = "";
315
+ for (const word of reply.split(" ")) {
316
+ if (signal && signal.aborted) return { source: "placeholder", text, aborted: true };
317
+ const delta = (text ? " " : "") + word;
318
+ text += delta;
319
+ onChunk && onChunk(delta, text);
320
+ await sleep(16);
321
+ }
322
+ const trace = fx.sampleTrace(q);
323
+ onTrace && onTrace(trace);
324
+ return { source: "placeholder", text, trace };
325
+ }
326
+
327
+ export { fx };
@@ -0,0 +1,215 @@
1
+ /* ============================================================================
2
+ * Lattice AI v3 — Component factories
3
+ * Build the shared vocabulary (cards, panels, stats, tables, states, the
4
+ * retrieval-lattice pillars …) on top of dom.js + the component CSS. Views
5
+ * compose these to stay visually consistent.
6
+ * ========================================================================== */
7
+
8
+ import { h, icon, fmtNum } from "./dom.js";
9
+
10
+ /* ── View + section headers ─────────────────────────────────────────────── */
11
+ export function viewHeader({ eyebrow, title, sub, actions } = {}) {
12
+ return h("header.lt3-vhead",
13
+ h("div",
14
+ eyebrow && h("div.lt3-eyebrow", eyebrow),
15
+ h("h1.lt3-vhead__title", title),
16
+ sub && h("p.lt3-vhead__sub", sub),
17
+ ),
18
+ actions && actions.length && h("div.lt3-vhead__actions", actions),
19
+ );
20
+ }
21
+
22
+ export function sectionHead(title, ...actions) {
23
+ return h("div.lt3-section__head",
24
+ h("h2.lt3-section__title", title),
25
+ actions.length && h("div.lt3-row-2", actions),
26
+ );
27
+ }
28
+
29
+ /* ── Panel / card ───────────────────────────────────────────────────────── */
30
+ export function panel({ title, sub, actions, head, children, eyebrow, className } = {}) {
31
+ return h(`section.lt3-panel${className ? "." + className : ""}`,
32
+ (title || head || actions) && h("div.lt3-panel__head",
33
+ head || h("div",
34
+ eyebrow && h("div.lt3-eyebrow", eyebrow),
35
+ title && h("h3.lt3-panel__title", title),
36
+ sub && h("p.lt3-panel__sub", sub),
37
+ ),
38
+ actions && h("div.lt3-row-2", actions),
39
+ ),
40
+ children,
41
+ );
42
+ }
43
+
44
+ export function card(children, opts = {}) {
45
+ const cls = ["lt3-card"];
46
+ if (opts.interactive) cls.push("lt3-card--interactive");
47
+ if (opts.flat) cls.push("lt3-card--flat");
48
+ if (opts.ghost) cls.push("lt3-card--ghost");
49
+ return h(`div.${cls.join(".")}`, opts.attrs || {}, children);
50
+ }
51
+
52
+ /* ── Stat tile ──────────────────────────────────────────────────────────── */
53
+ export function stat({ label, value, icon: ic, delta, deltaDir }) {
54
+ return h("div.lt3-stat",
55
+ h("div.lt3-stat__label", ic && icon(ic), label),
56
+ h("div.lt3-stat__value", value == null ? "—" : value),
57
+ delta && h(`div.lt3-stat__delta${deltaDir ? ".lt3-stat__delta--" + deltaDir : ""}`, delta),
58
+ );
59
+ }
60
+
61
+ /* ── Pills / badges ─────────────────────────────────────────────────────── */
62
+ export function pill(text, variant = "", { dot } = {}) {
63
+ const cls = ["lt3-pill"];
64
+ if (variant) cls.push("lt3-pill--" + variant);
65
+ if (dot) cls.push("lt3-pill--dot");
66
+ return h(`span.${cls.join(".")}`, text);
67
+ }
68
+
69
+ const STATE_VARIANT = {
70
+ ready: "ok", active: "ok", indexed: "ok", loaded: "ok", ok: "ok", available: "info",
71
+ idle: "", standby: "", pending: "warn", indexing: "warn", building: "warn",
72
+ failed: "err", error: "err", disabled: "err", not_configured: "",
73
+ };
74
+ export function statePill(state) {
75
+ return pill(String(state || "unknown"), STATE_VARIANT[String(state).toLowerCase()] ?? "", { dot: true });
76
+ }
77
+
78
+ /** Provenance badge — makes placeholder vs live data explicit. */
79
+ export function sourceBadge(source) {
80
+ if (source === "live") return h("span.lt3-source.lt3-source--live", icon("circle-filled"), "Live");
81
+ if (source === "placeholder") return h("span.lt3-source.lt3-source--placeholder", icon("flask"), "Sample data");
82
+ return h("span.lt3-source.lt3-source--pending", "—");
83
+ }
84
+
85
+ /* ── States ─────────────────────────────────────────────────────────────── */
86
+ export function emptyState({ icon: ic = "inbox", title, body, action } = {}) {
87
+ return h("div.lt3-empty",
88
+ h("div.lt3-empty__icon", icon(ic)),
89
+ title && h("div.lt3-empty__title", title),
90
+ body && h("div.lt3-empty__body", body),
91
+ action,
92
+ );
93
+ }
94
+
95
+ export function loading({ lines = 3, block = false } = {}) {
96
+ const kids = [];
97
+ if (block) kids.push(h("div.lt3-skel.lt3-skel--block"));
98
+ for (let i = 0; i < lines; i++) {
99
+ kids.push(h("div.lt3-skel.lt3-skel--line", { style: { width: 100 - i * 14 + "%" } }));
100
+ }
101
+ return h("div", { "aria-busy": "true", "aria-label": "Loading" }, kids);
102
+ }
103
+
104
+ export function errorState(message, onRetry) {
105
+ return h("div.lt3-banner.lt3-banner--err",
106
+ icon("alert-triangle"),
107
+ h("div", h("div", { style: { fontWeight: 600 } }, "Couldn't load"), h("div.lt3-faint", message || "Request failed")),
108
+ onRetry && h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { style: { "margin-left": "auto" }, on: { click: onRetry } }, icon("refresh"), "Retry"),
109
+ );
110
+ }
111
+
112
+ export function banner(text, variant = "info", ic = "info-circle") {
113
+ return h(`div.lt3-banner.lt3-banner--${variant}`, icon(ic), h("div", text));
114
+ }
115
+
116
+ /* ── Table ──────────────────────────────────────────────────────────────── */
117
+ export function table(columns, rows, { empty } = {}) {
118
+ if (!rows || !rows.length) {
119
+ return empty || emptyState({ title: "Nothing here yet", body: "Data will appear once connected." });
120
+ }
121
+ return h("div.lt3-table--clip", { style: { overflow: "auto" } },
122
+ h("table.lt3-table",
123
+ h("thead", h("tr", columns.map((c) => h("th", { style: c.width ? { width: c.width } : {} }, c.label)))),
124
+ h("tbody", rows.map((row) => h("tr", columns.map((c) => h("td", c.render ? c.render(row) : row[c.key]))))),
125
+ ),
126
+ );
127
+ }
128
+
129
+ /* ── Tabs / segmented ───────────────────────────────────────────────────── */
130
+ export function tabs(items, active, onChange) {
131
+ return h("div.lt3-tabs", { role: "tablist" },
132
+ items.map((it) => h("button.lt3-tab", {
133
+ role: "tab", type: "button",
134
+ dataset: { active: String(it.key === active) },
135
+ "aria-selected": String(it.key === active),
136
+ on: { click: () => onChange(it.key) },
137
+ }, it.label)),
138
+ );
139
+ }
140
+
141
+ export function segmented(items, active, onChange) {
142
+ return h("div.lt3-seg", { role: "tablist" },
143
+ items.map((it) => h("button", {
144
+ type: "button", role: "tab",
145
+ dataset: { active: String(it.key === active) },
146
+ "aria-selected": String(it.key === active),
147
+ on: { click: () => onChange(it.key) },
148
+ }, it.label)),
149
+ );
150
+ }
151
+
152
+ /* ── Meter ──────────────────────────────────────────────────────────────── */
153
+ export function meter(value, variant = "") {
154
+ const pct = Math.max(0, Math.min(1, Number(value) || 0)) * 100;
155
+ return h("div.lt3-meter",
156
+ h(`div.lt3-meter__fill${variant ? ".lt3-meter__fill--" + variant : ""}`, { style: { width: pct + "%" } }),
157
+ );
158
+ }
159
+
160
+ /* ── Retrieval lattice (signature) ──────────────────────────────────────── */
161
+ const PILLAR_DEFS = [
162
+ { key: "knowledge_graph", kind: "graph", name: "Knowledge Graph", desc: "Entities & relations", icon: "chart-dots-3", unit: "entities", read: (p) => p?.entities },
163
+ { key: "vector_index", kind: "vector", name: "Vector Index", desc: "Local embedding vectors", icon: "grid-dots", unit: "vectors", read: (p) => p?.vectors },
164
+ { key: "hybrid", kind: "hybrid", name: "Hybrid Search", desc: "Fused graph + vector", icon: "arrows-join", unit: "fusion", read: (p) => p?.strategy },
165
+ ];
166
+
167
+ export function pillars(indexStatus) {
168
+ const pipes = indexStatus?.pipelines || {};
169
+ return h("div.lt3-pillars",
170
+ PILLAR_DEFS.map((def) => {
171
+ const p = pipes[def.key] || {};
172
+ const raw = def.read(p);
173
+ const num = typeof raw === "number" ? fmtNum(raw) : (raw || "ready");
174
+ return h(`article.lt3-pillar.lt3-pillar--${def.kind}`,
175
+ h("div.lt3-pillar__icon", icon(def.icon)),
176
+ h("div.lt3-row", { style: { "justify-content": "space-between" } },
177
+ h("div",
178
+ h("div.lt3-pillar__name", def.name),
179
+ h("div.lt3-pillar__desc", def.desc),
180
+ ),
181
+ statePill(p.state || "ready"),
182
+ ),
183
+ h("div.lt3-pillar__stat",
184
+ h("span.lt3-pillar__num", num),
185
+ h("span.lt3-pillar__unit", def.unit),
186
+ ),
187
+ );
188
+ }),
189
+ );
190
+ }
191
+
192
+ /** Compact 3-dot index chip for the topbar. */
193
+ export function indexChip(indexStatus) {
194
+ const pipes = indexStatus?.pipelines || {};
195
+ const dot = (kind, key) => h("span.lt3-idxchip__dot", {
196
+ dataset: { kind, on: String((pipes[key]?.state || "ready") === "ready") },
197
+ title: `${kind}: ${pipes[key]?.state || "—"}`,
198
+ });
199
+ return h("div.lt3-idxchip", { title: "Retrieval index status" },
200
+ h("span.lt3-idxchip__dots", dot("graph", "knowledge_graph"), dot("vector", "vector_index"), dot("hybrid", "hybrid")),
201
+ h("span", "Index"),
202
+ );
203
+ }
204
+
205
+ /* ── Toast ──────────────────────────────────────────────────────────────── */
206
+ export function toast(message, variant = "info") {
207
+ let host = document.querySelector(".lt3-toasts");
208
+ if (!host) { host = h("div.lt3-toasts"); document.body.append(host); }
209
+ const ic = variant === "ok" ? "circle-check" : variant === "err" ? "alert-circle" : "info-circle";
210
+ const node = h(`div.lt3-toast.lt3-toast--${variant}`, icon(ic), h("div", message));
211
+ host.append(node);
212
+ setTimeout(() => { node.style.opacity = "0"; node.style.transition = "opacity .3s"; setTimeout(() => node.remove(), 300); }, 3200);
213
+ }
214
+
215
+ export { icon, fmtNum };