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,119 @@
1
+ /* ============================================================================
2
+ * View: Home — the workspace command center.
3
+ * Leads with the product identity (the retrieval lattice: Knowledge Graph +
4
+ * Vector Index + Hybrid Search) and routes into every primary area.
5
+ *
6
+ * View contract (shared by all views):
7
+ * export async function render(ctx) -> single DOM node
8
+ * ctx = { h, icon, api, store, c, route, params, navigate, toast }
9
+ * ========================================================================== */
10
+
11
+ export async function render(ctx) {
12
+ const { h, icon, api, store, c, navigate } = ctx;
13
+ const ws = store.activeWorkspace();
14
+
15
+ const root = h("div.lt3-stack-6",
16
+ c.viewHeader({
17
+ eyebrow: "Local-first AI workspace",
18
+ title: `Welcome to ${ws.name}`,
19
+ sub: "Everything you index stays on this machine. Ask questions, explore the graph, and fuse structure with semantics — no data leaves your computer.",
20
+ actions: [
21
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("hybrid-search") } }, icon("arrows-join"), "Hybrid search"),
22
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "New chat"),
23
+ ],
24
+ }),
25
+ buildHero(ctx),
26
+ h("section",
27
+ c.sectionHead("Retrieval lattice", h("span", { id: "home-idx-src" }, c.sourceBadge("pending"))),
28
+ h("div", { id: "home-pillars" }, c.loading({ lines: 2, block: true })),
29
+ ),
30
+ h("section",
31
+ c.sectionHead("Jump back in"),
32
+ buildQuickGrid(ctx),
33
+ ),
34
+ h("div.lt3-grid-2",
35
+ c.panel({ eyebrow: "Index", title: "Connected sources", children: h("div", { id: "home-sources" }, c.loading({ lines: 3 })) }),
36
+ c.panel({ eyebrow: "Workspace", title: "At a glance", children: h("div", { id: "home-stats" }, c.loading({ lines: 3 })) }),
37
+ ),
38
+ );
39
+
40
+ hydrate(ctx, root);
41
+ return root;
42
+ }
43
+
44
+ function buildHero({ h, icon, navigate }) {
45
+ return h("div.lt3-hero",
46
+ h("div.lt3-eyebrow.lt3-hero__eyebrow", icon("sparkles"), "Knowledge Graph · Vector Index · Hybrid Search"),
47
+ h("h2.lt3-hero__title", "One workspace. Three ways to recall everything."),
48
+ h("p.lt3-hero__sub", "Lattice builds a knowledge graph and a vector field from your files, then fuses them so every answer is grounded in both structure and meaning."),
49
+ h("div.lt3-hero__actions",
50
+ h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "Explore the graph"),
51
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("files") } }, icon("folder-plus"), "Connect files"),
52
+ ),
53
+ );
54
+ }
55
+
56
+ const QUICK = [
57
+ { key: "knowledge-graph", icon: "chart-dots-3", title: "Knowledge Graph", desc: "Browse entities and relations." },
58
+ { key: "hybrid-search", icon: "arrows-join", title: "Hybrid Search", desc: "Fuse graph + vector recall." },
59
+ { key: "chat", icon: "message-2", title: "Chat", desc: "Grounded conversation." },
60
+ { key: "files", icon: "folders", title: "Files", desc: "Sources and indexing." },
61
+ { key: "pipeline", icon: "git-branch", title: "Pipeline", desc: "Ingest and embed flows." },
62
+ { key: "models", icon: "cpu", title: "Models", desc: "Local MLX runtime." },
63
+ ];
64
+
65
+ function buildQuickGrid({ h, icon, navigate }) {
66
+ return h("div.lt3-quickgrid",
67
+ QUICK.map((q) => h("button.lt3-quick", { style: { "text-align": "left" }, on: { click: () => navigate(q.key) } },
68
+ h("div.lt3-quick__icon", icon(q.icon)),
69
+ h("div.lt3-quick__title", q.title),
70
+ h("div.lt3-quick__desc", q.desc),
71
+ )),
72
+ );
73
+ }
74
+
75
+ async function hydrate(ctx, root) {
76
+ const { h, icon, api, store, c } = ctx;
77
+ const numFmt = c.fmtNum;
78
+
79
+ // Index status → pillars + sources + topbar chip.
80
+ const idx = store.get().indexStatus
81
+ ? { data: store.get().indexStatus, source: "live" }
82
+ : await api.indexStatus().then((r) => { store.setIndexStatus(r.data); return r; });
83
+
84
+ root.querySelector("#home-idx-src")?.replaceChildren(c.sourceBadge(idx.source));
85
+ root.querySelector("#home-pillars")?.replaceChildren(c.pillars(idx.data));
86
+
87
+ const sources = (idx.data && idx.data.sources) || [];
88
+ const srcHost = root.querySelector("#home-sources");
89
+ if (srcHost) {
90
+ srcHost.replaceChildren(
91
+ sources.length
92
+ ? h("div.lt3-stack-3", sources.map((s) => h("div.lt3-stack-2",
93
+ h("div.lt3-row", { style: { "justify-content": "space-between" } },
94
+ h("div.lt3-row-2", icon("database"), h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, s.label)),
95
+ c.statePill(s.state),
96
+ ),
97
+ c.meter(s.progress ?? (s.state === "indexed" ? 1 : 0.5), s.state === "indexing" ? "warn" : "vector"),
98
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${numFmt(s.files)} files`),
99
+ )))
100
+ : c.emptyState({ icon: "database-off", title: "No sources connected", body: "Connect a folder to start indexing." }),
101
+ );
102
+ }
103
+
104
+ // Workspace counts.
105
+ const os = await api.workspaceOs();
106
+ const counts = (os.data && os.data.counts) || {};
107
+ const statHost = root.querySelector("#home-stats");
108
+ if (statHost) {
109
+ statHost.replaceChildren(
110
+ h("div.lt3-statrow",
111
+ c.stat({ label: "Memories", value: numFmt(counts.memories), icon: "brain" }),
112
+ c.stat({ label: "Traces", value: numFmt(counts.traces), icon: "route" }),
113
+ c.stat({ label: "Workflows", value: numFmt(counts.workflows), icon: "git-branch" }),
114
+ c.stat({ label: "Skills", value: numFmt(counts.skills), icon: "puzzle" }),
115
+ ),
116
+ h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.sourceBadge(os.source)),
117
+ );
118
+ }
119
+ }
@@ -0,0 +1,195 @@
1
+ /* ============================================================================
2
+ * View: Hybrid Search — the fused-retrieval surface (headline capability).
3
+ * Runs api.hybridSearch(query, {weights}) and shows, per result, how keyword,
4
+ * local vector and graph signals combine into the fused score. Missing
5
+ * endpoints degrade to clearly-badged sample data.
6
+ * ========================================================================== */
7
+
8
+ const MODES = [
9
+ { key: "hybrid", label: "Hybrid" },
10
+ { key: "vector", label: "Vector" },
11
+ { key: "graph", label: "Graph" },
12
+ { key: "keyword", label: "Keyword" },
13
+ ];
14
+
15
+ const MODE_WEIGHTS = {
16
+ hybrid: { keyword: 0.35, vector: 0.40, graph: 0.25 },
17
+ vector: { keyword: 0, vector: 1, graph: 0 },
18
+ graph: { keyword: 0, vector: 0, graph: 1 },
19
+ keyword: { keyword: 1, vector: 0, graph: 0 },
20
+ };
21
+
22
+ const EXAMPLES = ["retrieval design", "vector index config", "rank fusion", "graph adjacency"];
23
+
24
+ const SIGNALS = [
25
+ { key: "vector", label: "Vector", variant: "vector", icon: "grid-dots", desc: "Local vector similarity from the configured embedding index." },
26
+ { key: "keyword", label: "Keyword", variant: "", icon: "abc", desc: "Lexical overlap — exact terms and phrases." },
27
+ { key: "graph", label: "Graph", variant: "graph", icon: "chart-dots-3", desc: "Structural proximity in the knowledge graph." },
28
+ ];
29
+
30
+ export async function render(ctx) {
31
+ const { h, icon, api, store, c } = ctx;
32
+
33
+ const state = { query: "", mode: "hybrid", source: "pending" };
34
+
35
+ let activeWeights = MODE_WEIGHTS.hybrid;
36
+ api.indexStatus().then((r) => { if (r.data) store.setIndexStatus(r.data); });
37
+
38
+ const input = h("input", {
39
+ type: "text", placeholder: "Search your workspace…", "aria-label": "Search query",
40
+ on: { keydown: (e) => { if (e.key === "Enter") run(input.value); } },
41
+ });
42
+ const weightPill = h("span", c.pill(weightLabel(activeWeights), "info"));
43
+ const srcSlot = h("span", c.sourceBadge("pending"));
44
+ const resultsHost = h("div.lt3-stack-6", introBlock());
45
+
46
+ const seg = h("div.lt3-fusion", { role: "tablist", "aria-label": "Fusion mode" },
47
+ MODES.map((m) => h("button", {
48
+ type: "button", role: "tab",
49
+ dataset: { active: String(m.key === state.mode) },
50
+ "aria-selected": String(m.key === state.mode),
51
+ on: { click: () => { state.mode = m.key; syncSeg(); if (state.query) run(state.query); } },
52
+ }, m.label)),
53
+ );
54
+ function syncSeg() {
55
+ seg.querySelectorAll("button").forEach((b, i) => {
56
+ const on = MODES[i].key === state.mode;
57
+ b.dataset.active = String(on); b.setAttribute("aria-selected", String(on));
58
+ });
59
+ }
60
+
61
+ const root = h("div.lt3-stack-6",
62
+ c.viewHeader({
63
+ eyebrow: "Retrieval · fusion",
64
+ title: "Hybrid Search",
65
+ sub: "Fuse keyword recall, local vector similarity, and knowledge-graph structure. Each result shows the contributing signals behind its rank.",
66
+ actions: [srcSlot],
67
+ }),
68
+ h("section.lt3-search-hero",
69
+ h("div.lt3-row-2", { style: { "align-items": "stretch" } },
70
+ h("div.lt3-search", { style: { flex: "1", height: "46px" } }, icon("search"), input),
71
+ h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => run(input.value) } }, icon("arrows-join"), "Search"),
72
+ ),
73
+ h("div.lt3-row", { style: { "justify-content": "space-between", "flex-wrap": "wrap", gap: "var(--lt3-space-3)" } },
74
+ seg,
75
+ h("div.lt3-row-2", h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Weights"), weightPill),
76
+ ),
77
+ h("div.lt3-cluster",
78
+ h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Try"),
79
+ EXAMPLES.map((q) => h("button.lt3-chip", { type: "button", on: { click: () => run(q) } }, icon("search"), q)),
80
+ ),
81
+ ),
82
+ resultsHost,
83
+ );
84
+
85
+ /* ── search flow ───────────────────────────────────────────────────────── */
86
+ async function run(rawQuery) {
87
+ const q = String(rawQuery || "").trim();
88
+ if (!q) { input.focus(); return; }
89
+ state.query = q;
90
+ if (input.value !== q) input.value = q;
91
+ resultsHost.replaceChildren(
92
+ c.sectionHead(`Results for “${q}”`, srcSlot.cloneNode(true)),
93
+ c.loading({ lines: 4 }),
94
+ );
95
+ activeWeights = MODE_WEIGHTS[state.mode] || MODE_WEIGHTS.hybrid;
96
+ weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
97
+ const res = await api.hybridSearch(q, { weights: activeWeights });
98
+ if (res.weights) {
99
+ activeWeights = res.weights;
100
+ weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
101
+ }
102
+ state.source = res.source;
103
+ srcSlot.replaceChildren(c.sourceBadge(res.source));
104
+ renderResults(res);
105
+ }
106
+
107
+ function renderResults(res) {
108
+ if (!res.ok) {
109
+ resultsHost.replaceChildren(c.errorState(res.error || "Search failed", () => run(state.query)));
110
+ return;
111
+ }
112
+ const rows = Array.isArray(res.data) ? res.data : [];
113
+ if (!rows.length) {
114
+ resultsHost.replaceChildren(
115
+ c.sectionHead(`No results for “${state.query}”`, c.sourceBadge(res.source)),
116
+ c.emptyState({ icon: "search-off", title: "Nothing matched", body: "Try broader terms, or switch the fusion mode above." }),
117
+ );
118
+ return;
119
+ }
120
+ resultsHost.replaceChildren(
121
+ c.sectionHead(
122
+ `${rows.length} ${rows.length === 1 ? "result" : "results"}`,
123
+ c.pill(MODES.find((m) => m.key === state.mode).label, "info"),
124
+ c.sourceBadge(res.source),
125
+ ),
126
+ h("div.lt3-stack-3", rows.map((r) => resultCard(r))),
127
+ );
128
+ }
129
+
130
+ function resultCard(r) {
131
+ const score = typeof r.score === "number" ? r.score : (0.5 * (r.vector || 0) + 0.2 * (r.lexical || 0) + 0.3 * (r.graph || 0));
132
+ return h("article.lt3-result",
133
+ h("div.lt3-result__top",
134
+ h("div.lt3-result__title", { style: { flex: "1", "min-width": "0" } }, String(r.title || "Untitled")),
135
+ c.pill(`${(score).toFixed(2)} score`, "info", { dot: true }),
136
+ ),
137
+ h("div.lt3-faint.lt3-mono", { style: { "font-size": "var(--lt3-text-2xs)" } }, String(r.path || "")),
138
+ r.snippet && h("p.lt3-result__snippet", String(r.snippet)),
139
+ h("div.lt3-result__scores",
140
+ scoreBlock("Vector", r.vector, "vector"),
141
+ scoreBlock("Keyword", r.lexical, ""),
142
+ scoreBlock("Graph", r.graph, "graph"),
143
+ ),
144
+ );
145
+ }
146
+
147
+ function scoreBlock(label, value, variant) {
148
+ const v = Number(value) || 0;
149
+ return h("div.lt3-score",
150
+ h("div.lt3-score__row", h("span", label), h("b", v.toFixed(2))),
151
+ c.meter(v, variant),
152
+ );
153
+ }
154
+
155
+ /* ── pre-search intro ──────────────────────────────────────────────────── */
156
+ function introBlock() {
157
+ return h("div.lt3-stack-6",
158
+ c.emptyState({
159
+ icon: "arrows-join",
160
+ title: "Search across structure and vector signals",
161
+ body: "Enter a query above. Results show keyword, local vector, and graph scores before fusion.",
162
+ }),
163
+ h("section",
164
+ c.sectionHead("How fusion scores a match"),
165
+ h("div.lt3-grid-3",
166
+ SIGNALS.map((s) => signalCard(s)),
167
+ ),
168
+ ),
169
+ );
170
+ }
171
+
172
+ function signalCard(s) {
173
+ const tint = s.variant === "vector" ? "var(--lt3-pillar-vector)"
174
+ : s.variant === "graph" ? "var(--lt3-pillar-graph)"
175
+ : "var(--accent-3)";
176
+ return c.card(
177
+ h("div.lt3-stack-3",
178
+ h("div.lt3-row-2",
179
+ h("span.lt3-result__title", { style: { display: "grid", "place-items": "center", width: "32px", height: "32px", "border-radius": "var(--lt3-radius-sm)", background: `color-mix(in srgb, ${tint} 16%, transparent)`, color: tint } }, icon(s.icon)),
180
+ h("b", s.label),
181
+ ),
182
+ h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, s.desc),
183
+ c.meter(s.key === "graph" ? 0.85 : s.key === "vector" ? 0.7 : 0.55, s.variant),
184
+ ),
185
+ { flat: true },
186
+ );
187
+ }
188
+
189
+ return root;
190
+ }
191
+
192
+ function weightLabel(weights) {
193
+ const w = { ...MODE_WEIGHTS.hybrid, ...(weights || {}) };
194
+ return `K ${Number(w.keyword || 0).toFixed(2)} · V ${Number(w.vector || 0).toFixed(2)} · G ${Number(w.graph || 0).toFixed(2)}`;
195
+ }
@@ -0,0 +1,238 @@
1
+ /* ============================================================================
2
+ * View: Knowledge Graph — entity/relation explorer.
3
+ * Renders the graph as an SVG mesh (integration-ready against /api/graph) with
4
+ * a live inspector. Falls back to clearly-badged sample data when the backend
5
+ * graph endpoint isn't available yet.
6
+ * ========================================================================== */
7
+
8
+ import { escapeHtml } from "../core/dom.js";
9
+
10
+ const TYPE_COLOR = {
11
+ Topic: "var(--lt3-pillar-graph)",
12
+ Concept: "var(--lt3-pillar-vector)",
13
+ Method: "var(--lt3-pillar-hybrid)",
14
+ Model: "var(--accent-3)",
15
+ File: "var(--faint)",
16
+ Decision: "var(--accent-3)",
17
+ Task: "var(--accent-2)",
18
+ Person: "var(--accent-pink)",
19
+ default: "var(--accent)",
20
+ };
21
+ const colorFor = (t) => TYPE_COLOR[t] || TYPE_COLOR.default;
22
+
23
+ export async function render(ctx) {
24
+ const { h, icon, api, store, c } = ctx;
25
+
26
+ const state = { selected: null, query: "", data: { nodes: [], edges: [] }, source: "pending" };
27
+
28
+ const canvasHost = h("div", c.loading({ lines: 0, block: true }));
29
+ const inspectorHost = h("div", c.loading({ lines: 4 }));
30
+ const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
31
+ const srcSlot = h("span", c.sourceBadge("pending"));
32
+
33
+ const root = h("div.lt3-stack-6",
34
+ c.viewHeader({
35
+ eyebrow: "Retrieval · structure",
36
+ title: "Knowledge Graph",
37
+ sub: "Entities and the relations the workspace extracted between them. Click a node to trace its neighborhood.",
38
+ actions: [
39
+ srcSlot,
40
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => load() } }, icon("refresh"), "Rebuild view"),
41
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => ctx.navigate("hybrid-search") } }, icon("arrows-join"), "Search graph"),
42
+ ],
43
+ }),
44
+ statHost,
45
+ h("div.lt3-split",
46
+ h("div.lt3-stack-3",
47
+ c.card(canvasHost, { attrs: { style: "padding:0;overflow:hidden" } }),
48
+ buildLegend(ctx),
49
+ ),
50
+ h("aside.lt3-panel",
51
+ h("div.lt3-panel__head", h("div", h("div.lt3-eyebrow", "Inspector"), h("h3.lt3-panel__title", "Entities"))),
52
+ h("div.lt3-search", { style: { "margin-bottom": "var(--lt3-space-4)" } },
53
+ icon("search"),
54
+ h("input", { type: "text", placeholder: "Filter entities…", "aria-label": "Filter entities",
55
+ on: { input: (e) => { state.query = e.target.value.toLowerCase(); renderInspector(); } } }),
56
+ ),
57
+ inspectorHost,
58
+ ),
59
+ ),
60
+ );
61
+
62
+ async function load() {
63
+ canvasHost.replaceChildren(c.loading({ lines: 0, block: true }));
64
+ const [g, stats] = await Promise.all([api.graph(), api.graphStats()]);
65
+ state.data = normalize(g.data);
66
+ state.source = g.source;
67
+ srcSlot.replaceChildren(c.sourceBadge(g.source));
68
+ renderStats(stats.data, g.data);
69
+ renderCanvas();
70
+ renderInspector();
71
+ }
72
+
73
+ function renderStats(stats, graphData) {
74
+ const nodes = state.data.nodes.length;
75
+ const edges = state.data.edges.length;
76
+ const types = stats && stats.nodes ? Object.keys(stats.nodes).length : new Set(state.data.nodes.map((n) => n.type)).size;
77
+ const density = nodes > 1 ? (edges / (nodes * (nodes - 1) / 2)) : 0;
78
+ statHost.replaceChildren(
79
+ c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
80
+ c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
81
+ c.stat({ label: "Entity types", value: types, icon: "category" }),
82
+ c.stat({ label: "Density", value: density.toFixed(2), icon: "chart-dots" }),
83
+ );
84
+ }
85
+
86
+ function renderCanvas() {
87
+ const { nodes, edges } = state.data;
88
+ if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
89
+ const laidOut = layout(nodes);
90
+ const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
91
+ const W = 1000, H = 600;
92
+ const edgeSvg = edges.map((e) => {
93
+ const a = pos[e.from], b = pos[e.to];
94
+ if (!a || !b) return "";
95
+ 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>`;
96
+ }).join("");
97
+ const nodeSvg = laidOut.map((n) => {
98
+ const r = 10 + (n.weight || 0.5) * 16;
99
+ const sel = state.selected === n.id;
100
+ return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
101
+ <circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
102
+ <text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
103
+ </g>`;
104
+ }).join("");
105
+ canvasHost.replaceChildren(
106
+ h("div.lt3-graph-canvas", {
107
+ html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
108
+ on: { click: onCanvasClick },
109
+ }),
110
+ );
111
+ }
112
+
113
+ function onCanvasClick(e) {
114
+ const g = e.target.closest(".lt3-gnode");
115
+ if (!g) return;
116
+ state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
117
+ renderCanvas();
118
+ renderInspector();
119
+ }
120
+
121
+ function isNeighbor(id) {
122
+ if (!state.selected) return false;
123
+ return state.data.edges.some((e) =>
124
+ (e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
125
+ }
126
+
127
+ function renderInspector() {
128
+ if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
129
+ const q = state.query;
130
+ const list = state.data.nodes
131
+ .filter((n) => !q || (n.label || "").toLowerCase().includes(q) || (n.type || "").toLowerCase().includes(q))
132
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0));
133
+ inspectorHost.replaceChildren(
134
+ list.length
135
+ ? h("div.lt3-stack-2", list.slice(0, 60).map((n) => entityRow(n)))
136
+ : c.emptyState({ icon: "search-off", title: "No matches", body: "Try a different entity name." }),
137
+ );
138
+ }
139
+
140
+ function entityRow(n) {
141
+ return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
142
+ h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
143
+ h("div.lt3-entity__body",
144
+ h("div.lt3-entity__name", n.label),
145
+ h("div.lt3-entity__meta", `${n.type || "Entity"} · weight ${(n.weight || 0).toFixed(2)}`),
146
+ ),
147
+ );
148
+ }
149
+
150
+ function detailView() {
151
+ const n = state.data.nodes.find((x) => x.id === state.selected);
152
+ if (!n) { state.selected = null; return c.emptyState({ title: "Not found" }); }
153
+ const rels = state.data.edges
154
+ .filter((e) => e.from === n.id || e.to === n.id)
155
+ .map((e) => {
156
+ const otherId = e.from === n.id ? e.to : e.from;
157
+ const other = state.data.nodes.find((x) => x.id === otherId);
158
+ return { type: e.type, dir: e.from === n.id ? "→" : "←", other };
159
+ })
160
+ .filter((r) => r.other);
161
+ return h("div.lt3-stack-4",
162
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
163
+ h("div.lt3-card.lt3-card--flat",
164
+ h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
165
+ h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
166
+ ),
167
+ h("div", { style: { "font-size": "var(--lt3-text-lg)", "font-weight": 700 } }, n.label),
168
+ n.summary && h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-2)" } }, n.summary),
169
+ ),
170
+ h("div",
171
+ h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
172
+ rels.length
173
+ ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
174
+ h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
175
+ h("div.lt3-entity__body",
176
+ h("div.lt3-entity__name", r.other.label),
177
+ h("div.lt3-entity__meta", r.type),
178
+ ),
179
+ )))
180
+ : c.emptyState({ icon: "unlink", title: "No relations", body: "This entity is currently isolated." }),
181
+ ),
182
+ );
183
+ }
184
+
185
+ load();
186
+ return root;
187
+ }
188
+
189
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
190
+ function normalize(data) {
191
+ const nodes = (data.nodes || []).map((n) => ({
192
+ id: n.id,
193
+ label: n.label || n.title || n.id,
194
+ type: n.type || "Entity",
195
+ weight: n.weight ?? n.importance_norm ?? (n.metadata && n.metadata.graph_metrics && n.metadata.graph_metrics.importance_norm) ?? 0.5,
196
+ summary: n.summary || "",
197
+ x: n.x, y: n.y,
198
+ }));
199
+ const ids = new Set(nodes.map((n) => n.id));
200
+ const edges = (data.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to))
201
+ .map((e) => ({ from: e.from, to: e.to, type: e.type || "related", weight: e.weight || 1 }));
202
+ return { nodes, edges };
203
+ }
204
+
205
+ function layout(nodes) {
206
+ const W = 1000, H = 600, cx = W / 2, cy = H / 2;
207
+ const golden = Math.PI * (3 - Math.sqrt(5));
208
+ const hasCoords = nodes.length && nodes.every((n) => typeof n.x === "number" && typeof n.y === "number");
209
+ if (hasCoords) {
210
+ return nodes.map((n) => ({ ...n, px: Math.round(60 + n.x * (W - 120)), py: Math.round(50 + n.y * (H - 100)) }));
211
+ }
212
+ // Sunflower (Vogel) spread — even spacing, highest-weight entity centered.
213
+ const order = nodes.map((n, i) => ({ n, i })).sort((a, b) => (b.n.weight || 0) - (a.n.weight || 0));
214
+ const maxR = Math.min(W, H) * 0.42;
215
+ const placed = {};
216
+ order.forEach((o, rank) => {
217
+ const radius = rank === 0 ? 0 : maxR * Math.sqrt(rank / Math.max(1, nodes.length - 1));
218
+ const angle = rank * golden;
219
+ placed[o.i] = {
220
+ px: Math.round(cx + Math.cos(angle) * radius),
221
+ py: Math.round(cy + Math.sin(angle) * radius * 0.66),
222
+ };
223
+ });
224
+ return nodes.map((n, i) => ({ ...n, ...placed[i] }));
225
+ }
226
+
227
+ function truncate(s, n) { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
228
+
229
+ function iconForType(t) {
230
+ return ({ Topic: "bulb", Concept: "atom", Method: "function", Model: "cpu", File: "file", Decision: "gavel", Task: "checkbox", Person: "user" })[t] || "point";
231
+ }
232
+
233
+ function buildLegend({ h }) {
234
+ const types = ["Topic", "Concept", "Method", "Model", "File"];
235
+ return h("div.lt3-graph-legend",
236
+ types.map((t) => h("span", h("i", { style: { background: colorFor(t) } }), t)),
237
+ );
238
+ }