ltcai 2.2.7 → 3.1.0

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 (122) hide show
  1. package/README.md +72 -34
  2. package/docs/CHANGELOG.md +119 -0
  3. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  4. package/docs/V3_FRONTEND.md +139 -0
  5. package/knowledge_graph.py +649 -21
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/admin.py +47 -0
  8. package/latticeai/api/agents.py +54 -31
  9. package/latticeai/api/auth.py +5 -2
  10. package/latticeai/api/chat.py +10 -2
  11. package/latticeai/api/search.py +240 -0
  12. package/latticeai/api/static_routes.py +11 -2
  13. package/latticeai/core/config.py +18 -0
  14. package/latticeai/core/embedding_providers.py +625 -0
  15. package/latticeai/core/local_embeddings.py +86 -0
  16. package/latticeai/core/workspace_os.py +1 -1
  17. package/latticeai/server_app.py +65 -1
  18. package/latticeai/services/agent_runtime.py +245 -0
  19. package/latticeai/services/search_service.py +346 -0
  20. package/package.json +13 -6
  21. package/scripts/build_v3_assets.mjs +164 -0
  22. package/scripts/capture/README.md +28 -0
  23. package/scripts/capture/capture_enterprise.js +8 -0
  24. package/scripts/capture/capture_graph.js +8 -0
  25. package/scripts/capture/capture_onboarding.js +8 -0
  26. package/scripts/capture/capture_page.js +43 -0
  27. package/scripts/capture/capture_release_media.js +125 -0
  28. package/scripts/capture/capture_skills.js +8 -0
  29. package/scripts/capture/capture_workspace.js +8 -0
  30. package/scripts/generate_diagrams.py +513 -0
  31. package/scripts/lint_v3.mjs +33 -0
  32. package/scripts/release-0.3.1.sh +105 -0
  33. package/scripts/take_screenshots.js +69 -0
  34. package/scripts/validate_release_artifacts.py +167 -0
  35. package/static/account.html +9 -9
  36. package/static/activity.html +4 -4
  37. package/static/admin.html +8 -8
  38. package/static/agents.html +4 -4
  39. package/static/chat.html +10 -10
  40. package/static/css/reference/account.css +137 -1
  41. package/static/css/reference/chat.css +31 -37
  42. package/static/css/responsive.css +42 -0
  43. package/static/css/tokens.5a595671.css +260 -0
  44. package/static/css/tokens.css +125 -130
  45. package/static/graph.html +9 -9
  46. package/static/manifest.json +3 -3
  47. package/static/plugins.html +4 -4
  48. package/static/scripts/account.js +4 -4
  49. package/static/scripts/chat.js +40 -8
  50. package/static/scripts/workspace.js +78 -0
  51. package/static/sw.js +3 -1
  52. package/static/v3/asset-manifest.json +47 -0
  53. package/static/v3/css/lattice.base.css +128 -0
  54. package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
  55. package/static/v3/css/lattice.components.011e988b.css +447 -0
  56. package/static/v3/css/lattice.components.css +447 -0
  57. package/static/v3/css/lattice.shell.4920f42d.css +407 -0
  58. package/static/v3/css/lattice.shell.css +407 -0
  59. package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
  60. package/static/v3/css/lattice.tokens.css +132 -0
  61. package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
  62. package/static/v3/css/lattice.views.css +277 -0
  63. package/static/v3/index.html +69 -0
  64. package/static/v3/js/app.46fb61d9.js +26 -0
  65. package/static/v3/js/app.js +26 -0
  66. package/static/v3/js/core/api.22a41d42.js +344 -0
  67. package/static/v3/js/core/api.js +344 -0
  68. package/static/v3/js/core/components.4c83e0a9.js +222 -0
  69. package/static/v3/js/core/components.js +222 -0
  70. package/static/v3/js/core/dom.a2773eb0.js +148 -0
  71. package/static/v3/js/core/dom.js +148 -0
  72. package/static/v3/js/core/router.584570f2.js +37 -0
  73. package/static/v3/js/core/router.js +37 -0
  74. package/static/v3/js/core/routes.f935dd50.js +78 -0
  75. package/static/v3/js/core/routes.js +78 -0
  76. package/static/v3/js/core/shell.1b6199d6.js +363 -0
  77. package/static/v3/js/core/shell.js +363 -0
  78. package/static/v3/js/core/store.34ebd5e6.js +113 -0
  79. package/static/v3/js/core/store.js +113 -0
  80. package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
  81. package/static/v3/js/views/admin-audit.js +185 -0
  82. package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
  83. package/static/v3/js/views/admin-permissions.js +177 -0
  84. package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
  85. package/static/v3/js/views/admin-policies.js +102 -0
  86. package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
  87. package/static/v3/js/views/admin-private-vpc.js +135 -0
  88. package/static/v3/js/views/admin-security.07c66b72.js +180 -0
  89. package/static/v3/js/views/admin-security.js +180 -0
  90. package/static/v3/js/views/admin-users.03bac88c.js +168 -0
  91. package/static/v3/js/views/admin-users.js +168 -0
  92. package/static/v3/js/views/agents.14e48bdd.js +193 -0
  93. package/static/v3/js/views/agents.js +193 -0
  94. package/static/v3/js/views/chat.718144ce.js +449 -0
  95. package/static/v3/js/views/chat.js +449 -0
  96. package/static/v3/js/views/files.4935197e.js +186 -0
  97. package/static/v3/js/views/files.js +186 -0
  98. package/static/v3/js/views/home.cdde3b32.js +119 -0
  99. package/static/v3/js/views/home.js +119 -0
  100. package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
  101. package/static/v3/js/views/hybrid-search.js +195 -0
  102. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
  103. package/static/v3/js/views/knowledge-graph.js +237 -0
  104. package/static/v3/js/views/models.a1ffa147.js +256 -0
  105. package/static/v3/js/views/models.js +256 -0
  106. package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
  107. package/static/v3/js/views/my-computer.js +237 -0
  108. package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
  109. package/static/v3/js/views/pipeline.js +157 -0
  110. package/static/v3/js/views/settings.4f777210.js +250 -0
  111. package/static/v3/js/views/settings.js +250 -0
  112. package/static/workflows.html +4 -4
  113. package/static/workspace.css +340 -2
  114. package/static/workspace.html +43 -24
  115. package/docs/images/tmp_frames/frame_00.png +0 -0
  116. package/docs/images/tmp_frames/frame_01.png +0 -0
  117. package/docs/images/tmp_frames/frame_02.png +0 -0
  118. package/docs/images/tmp_frames/frame_03.png +0 -0
  119. package/docs/images/tmp_frames/hero_00.png +0 -0
  120. package/docs/images/tmp_frames/hero_01.png +0 -0
  121. package/docs/images/tmp_frames/hero_02.png +0 -0
  122. package/docs/images/tmp_frames/hero_03.png +0 -0
@@ -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 render an unavailable state.
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,237 @@
1
+ /* ============================================================================
2
+ * View: Knowledge Graph — entity/relation explorer.
3
+ * Renders the graph as an SVG mesh against /api/graph with a live inspector.
4
+ * Missing graph data renders an empty unavailable state.
5
+ * ========================================================================== */
6
+
7
+ import { escapeHtml } from "../core/dom.a2773eb0.js";
8
+
9
+ const TYPE_COLOR = {
10
+ Topic: "var(--lt3-pillar-graph)",
11
+ Concept: "var(--lt3-pillar-vector)",
12
+ Method: "var(--lt3-pillar-hybrid)",
13
+ Model: "var(--accent-3)",
14
+ File: "var(--faint)",
15
+ Decision: "var(--accent-3)",
16
+ Task: "var(--accent-2)",
17
+ Person: "var(--accent-pink)",
18
+ default: "var(--accent)",
19
+ };
20
+ const colorFor = (t) => TYPE_COLOR[t] || TYPE_COLOR.default;
21
+
22
+ export async function render(ctx) {
23
+ const { h, icon, api, store, c } = ctx;
24
+
25
+ const state = { selected: null, query: "", data: { nodes: [], edges: [] }, source: "pending" };
26
+
27
+ const canvasHost = h("div", c.loading({ lines: 0, block: true }));
28
+ const inspectorHost = h("div", c.loading({ lines: 4 }));
29
+ const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
30
+ const srcSlot = h("span", c.sourceBadge("pending"));
31
+
32
+ const root = h("div.lt3-stack-6",
33
+ c.viewHeader({
34
+ eyebrow: "Retrieval · structure",
35
+ title: "Knowledge Graph",
36
+ sub: "Entities and the relations the workspace extracted between them. Click a node to trace its neighborhood.",
37
+ actions: [
38
+ srcSlot,
39
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => load() } }, icon("refresh"), "Rebuild view"),
40
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => ctx.navigate("hybrid-search") } }, icon("arrows-join"), "Search graph"),
41
+ ],
42
+ }),
43
+ statHost,
44
+ h("div.lt3-split",
45
+ h("div.lt3-stack-3",
46
+ c.card(canvasHost, { attrs: { style: "padding:0;overflow:hidden" } }),
47
+ buildLegend(ctx),
48
+ ),
49
+ h("aside.lt3-panel",
50
+ h("div.lt3-panel__head", h("div", h("div.lt3-eyebrow", "Inspector"), h("h3.lt3-panel__title", "Entities"))),
51
+ h("div.lt3-search", { style: { "margin-bottom": "var(--lt3-space-4)" } },
52
+ icon("search"),
53
+ h("input", { type: "text", placeholder: "Filter entities…", "aria-label": "Filter entities",
54
+ on: { input: (e) => { state.query = e.target.value.toLowerCase(); renderInspector(); } } }),
55
+ ),
56
+ inspectorHost,
57
+ ),
58
+ ),
59
+ );
60
+
61
+ async function load() {
62
+ canvasHost.replaceChildren(c.loading({ lines: 0, block: true }));
63
+ const [g, stats] = await Promise.all([api.graph(), api.graphStats()]);
64
+ state.data = normalize(g.data);
65
+ state.source = g.source;
66
+ srcSlot.replaceChildren(c.sourceBadge(g.source));
67
+ renderStats(stats.data, g.data);
68
+ renderCanvas();
69
+ renderInspector();
70
+ }
71
+
72
+ function renderStats(stats, graphData) {
73
+ const nodes = state.data.nodes.length;
74
+ const edges = state.data.edges.length;
75
+ const types = stats && stats.nodes ? Object.keys(stats.nodes).length : new Set(state.data.nodes.map((n) => n.type)).size;
76
+ const density = nodes > 1 ? (edges / (nodes * (nodes - 1) / 2)) : 0;
77
+ statHost.replaceChildren(
78
+ c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
79
+ c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
80
+ c.stat({ label: "Entity types", value: types, icon: "category" }),
81
+ c.stat({ label: "Density", value: density.toFixed(2), icon: "chart-dots" }),
82
+ );
83
+ }
84
+
85
+ function renderCanvas() {
86
+ const { nodes, edges } = state.data;
87
+ if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
88
+ const laidOut = layout(nodes);
89
+ const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
90
+ const W = 1000, H = 600;
91
+ const edgeSvg = edges.map((e) => {
92
+ const a = pos[e.from], b = pos[e.to];
93
+ if (!a || !b) return "";
94
+ 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>`;
95
+ }).join("");
96
+ const nodeSvg = laidOut.map((n) => {
97
+ const r = 10 + (n.weight || 0.5) * 16;
98
+ const sel = state.selected === n.id;
99
+ return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
100
+ <circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
101
+ <text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
102
+ </g>`;
103
+ }).join("");
104
+ canvasHost.replaceChildren(
105
+ h("div.lt3-graph-canvas", {
106
+ html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
107
+ on: { click: onCanvasClick },
108
+ }),
109
+ );
110
+ }
111
+
112
+ function onCanvasClick(e) {
113
+ const g = e.target.closest(".lt3-gnode");
114
+ if (!g) return;
115
+ state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
116
+ renderCanvas();
117
+ renderInspector();
118
+ }
119
+
120
+ function isNeighbor(id) {
121
+ if (!state.selected) return false;
122
+ return state.data.edges.some((e) =>
123
+ (e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
124
+ }
125
+
126
+ function renderInspector() {
127
+ if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
128
+ const q = state.query;
129
+ const list = state.data.nodes
130
+ .filter((n) => !q || (n.label || "").toLowerCase().includes(q) || (n.type || "").toLowerCase().includes(q))
131
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0));
132
+ inspectorHost.replaceChildren(
133
+ list.length
134
+ ? h("div.lt3-stack-2", list.slice(0, 60).map((n) => entityRow(n)))
135
+ : c.emptyState({ icon: "search-off", title: "No matches", body: "Try a different entity name." }),
136
+ );
137
+ }
138
+
139
+ function entityRow(n) {
140
+ return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
141
+ h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
142
+ h("div.lt3-entity__body",
143
+ h("div.lt3-entity__name", n.label),
144
+ h("div.lt3-entity__meta", `${n.type || "Entity"} · weight ${(n.weight || 0).toFixed(2)}`),
145
+ ),
146
+ );
147
+ }
148
+
149
+ function detailView() {
150
+ const n = state.data.nodes.find((x) => x.id === state.selected);
151
+ if (!n) { state.selected = null; return c.emptyState({ title: "Not found" }); }
152
+ const rels = state.data.edges
153
+ .filter((e) => e.from === n.id || e.to === n.id)
154
+ .map((e) => {
155
+ const otherId = e.from === n.id ? e.to : e.from;
156
+ const other = state.data.nodes.find((x) => x.id === otherId);
157
+ return { type: e.type, dir: e.from === n.id ? "→" : "←", other };
158
+ })
159
+ .filter((r) => r.other);
160
+ return h("div.lt3-stack-4",
161
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
162
+ h("div.lt3-card.lt3-card--flat",
163
+ h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
164
+ h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
165
+ ),
166
+ h("div", { style: { "font-size": "var(--lt3-text-lg)", "font-weight": 700 } }, n.label),
167
+ n.summary && h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-2)" } }, n.summary),
168
+ ),
169
+ h("div",
170
+ h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
171
+ rels.length
172
+ ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
173
+ h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
174
+ h("div.lt3-entity__body",
175
+ h("div.lt3-entity__name", r.other.label),
176
+ h("div.lt3-entity__meta", r.type),
177
+ ),
178
+ )))
179
+ : c.emptyState({ icon: "unlink", title: "No relations", body: "This entity is currently isolated." }),
180
+ ),
181
+ );
182
+ }
183
+
184
+ load();
185
+ return root;
186
+ }
187
+
188
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
189
+ function normalize(data) {
190
+ const nodes = (data.nodes || []).map((n) => ({
191
+ id: n.id,
192
+ label: n.label || n.title || n.id,
193
+ type: n.type || "Entity",
194
+ weight: n.weight ?? n.importance_norm ?? (n.metadata && n.metadata.graph_metrics && n.metadata.graph_metrics.importance_norm) ?? 0.5,
195
+ summary: n.summary || "",
196
+ x: n.x, y: n.y,
197
+ }));
198
+ const ids = new Set(nodes.map((n) => n.id));
199
+ const edges = (data.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to))
200
+ .map((e) => ({ from: e.from, to: e.to, type: e.type || "related", weight: e.weight || 1 }));
201
+ return { nodes, edges };
202
+ }
203
+
204
+ function layout(nodes) {
205
+ const W = 1000, H = 600, cx = W / 2, cy = H / 2;
206
+ const golden = Math.PI * (3 - Math.sqrt(5));
207
+ const hasCoords = nodes.length && nodes.every((n) => typeof n.x === "number" && typeof n.y === "number");
208
+ if (hasCoords) {
209
+ return nodes.map((n) => ({ ...n, px: Math.round(60 + n.x * (W - 120)), py: Math.round(50 + n.y * (H - 100)) }));
210
+ }
211
+ // Sunflower (Vogel) spread — even spacing, highest-weight entity centered.
212
+ const order = nodes.map((n, i) => ({ n, i })).sort((a, b) => (b.n.weight || 0) - (a.n.weight || 0));
213
+ const maxR = Math.min(W, H) * 0.42;
214
+ const placed = {};
215
+ order.forEach((o, rank) => {
216
+ const radius = rank === 0 ? 0 : maxR * Math.sqrt(rank / Math.max(1, nodes.length - 1));
217
+ const angle = rank * golden;
218
+ placed[o.i] = {
219
+ px: Math.round(cx + Math.cos(angle) * radius),
220
+ py: Math.round(cy + Math.sin(angle) * radius * 0.66),
221
+ };
222
+ });
223
+ return nodes.map((n, i) => ({ ...n, ...placed[i] }));
224
+ }
225
+
226
+ function truncate(s, n) { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
227
+
228
+ function iconForType(t) {
229
+ return ({ Topic: "bulb", Concept: "atom", Method: "function", Model: "cpu", File: "file", Decision: "gavel", Task: "checkbox", Person: "user" })[t] || "point";
230
+ }
231
+
232
+ function buildLegend({ h }) {
233
+ const types = ["Topic", "Concept", "Method", "Model", "File"];
234
+ return h("div.lt3-graph-legend",
235
+ types.map((t) => h("span", h("i", { style: { background: colorFor(t) } }), t)),
236
+ );
237
+ }
@@ -0,0 +1,237 @@
1
+ /* ============================================================================
2
+ * View: Knowledge Graph — entity/relation explorer.
3
+ * Renders the graph as an SVG mesh against /api/graph with a live inspector.
4
+ * Missing graph data renders an empty unavailable state.
5
+ * ========================================================================== */
6
+
7
+ import { escapeHtml } from "../core/dom.js";
8
+
9
+ const TYPE_COLOR = {
10
+ Topic: "var(--lt3-pillar-graph)",
11
+ Concept: "var(--lt3-pillar-vector)",
12
+ Method: "var(--lt3-pillar-hybrid)",
13
+ Model: "var(--accent-3)",
14
+ File: "var(--faint)",
15
+ Decision: "var(--accent-3)",
16
+ Task: "var(--accent-2)",
17
+ Person: "var(--accent-pink)",
18
+ default: "var(--accent)",
19
+ };
20
+ const colorFor = (t) => TYPE_COLOR[t] || TYPE_COLOR.default;
21
+
22
+ export async function render(ctx) {
23
+ const { h, icon, api, store, c } = ctx;
24
+
25
+ const state = { selected: null, query: "", data: { nodes: [], edges: [] }, source: "pending" };
26
+
27
+ const canvasHost = h("div", c.loading({ lines: 0, block: true }));
28
+ const inspectorHost = h("div", c.loading({ lines: 4 }));
29
+ const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
30
+ const srcSlot = h("span", c.sourceBadge("pending"));
31
+
32
+ const root = h("div.lt3-stack-6",
33
+ c.viewHeader({
34
+ eyebrow: "Retrieval · structure",
35
+ title: "Knowledge Graph",
36
+ sub: "Entities and the relations the workspace extracted between them. Click a node to trace its neighborhood.",
37
+ actions: [
38
+ srcSlot,
39
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => load() } }, icon("refresh"), "Rebuild view"),
40
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => ctx.navigate("hybrid-search") } }, icon("arrows-join"), "Search graph"),
41
+ ],
42
+ }),
43
+ statHost,
44
+ h("div.lt3-split",
45
+ h("div.lt3-stack-3",
46
+ c.card(canvasHost, { attrs: { style: "padding:0;overflow:hidden" } }),
47
+ buildLegend(ctx),
48
+ ),
49
+ h("aside.lt3-panel",
50
+ h("div.lt3-panel__head", h("div", h("div.lt3-eyebrow", "Inspector"), h("h3.lt3-panel__title", "Entities"))),
51
+ h("div.lt3-search", { style: { "margin-bottom": "var(--lt3-space-4)" } },
52
+ icon("search"),
53
+ h("input", { type: "text", placeholder: "Filter entities…", "aria-label": "Filter entities",
54
+ on: { input: (e) => { state.query = e.target.value.toLowerCase(); renderInspector(); } } }),
55
+ ),
56
+ inspectorHost,
57
+ ),
58
+ ),
59
+ );
60
+
61
+ async function load() {
62
+ canvasHost.replaceChildren(c.loading({ lines: 0, block: true }));
63
+ const [g, stats] = await Promise.all([api.graph(), api.graphStats()]);
64
+ state.data = normalize(g.data);
65
+ state.source = g.source;
66
+ srcSlot.replaceChildren(c.sourceBadge(g.source));
67
+ renderStats(stats.data, g.data);
68
+ renderCanvas();
69
+ renderInspector();
70
+ }
71
+
72
+ function renderStats(stats, graphData) {
73
+ const nodes = state.data.nodes.length;
74
+ const edges = state.data.edges.length;
75
+ const types = stats && stats.nodes ? Object.keys(stats.nodes).length : new Set(state.data.nodes.map((n) => n.type)).size;
76
+ const density = nodes > 1 ? (edges / (nodes * (nodes - 1) / 2)) : 0;
77
+ statHost.replaceChildren(
78
+ c.stat({ label: "Entities", value: c.fmtNum(nodes), icon: "circles" }),
79
+ c.stat({ label: "Relations", value: c.fmtNum(edges), icon: "vector-triangle" }),
80
+ c.stat({ label: "Entity types", value: types, icon: "category" }),
81
+ c.stat({ label: "Density", value: density.toFixed(2), icon: "chart-dots" }),
82
+ );
83
+ }
84
+
85
+ function renderCanvas() {
86
+ const { nodes, edges } = state.data;
87
+ if (!nodes.length) { canvasHost.replaceChildren(c.emptyState({ icon: "chart-dots-3", title: "No entities yet", body: "Index a source to populate the graph." })); return; }
88
+ const laidOut = layout(nodes);
89
+ const pos = Object.fromEntries(laidOut.map((n) => [n.id, n]));
90
+ const W = 1000, H = 600;
91
+ const edgeSvg = edges.map((e) => {
92
+ const a = pos[e.from], b = pos[e.to];
93
+ if (!a || !b) return "";
94
+ 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>`;
95
+ }).join("");
96
+ const nodeSvg = laidOut.map((n) => {
97
+ const r = 10 + (n.weight || 0.5) * 16;
98
+ const sel = state.selected === n.id;
99
+ return `<g class="lt3-gnode" data-id="${escapeHtml(n.id)}" opacity="${state.selected && !sel && !isNeighbor(n.id) ? 0.35 : 1}">
100
+ <circle cx="${n.px}" cy="${n.py}" r="${sel ? r + 3 : r}" fill="${colorFor(n.type)}" stroke-width="${sel ? 3 : 2}"></circle>
101
+ <text x="${n.px}" y="${n.py + r + 13}" text-anchor="middle">${escapeHtml(truncate(n.label, 18))}</text>
102
+ </g>`;
103
+ }).join("");
104
+ canvasHost.replaceChildren(
105
+ h("div.lt3-graph-canvas", {
106
+ html: `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Knowledge graph">${edgeSvg}${nodeSvg}</svg>`,
107
+ on: { click: onCanvasClick },
108
+ }),
109
+ );
110
+ }
111
+
112
+ function onCanvasClick(e) {
113
+ const g = e.target.closest(".lt3-gnode");
114
+ if (!g) return;
115
+ state.selected = g.dataset.id === state.selected ? null : g.dataset.id;
116
+ renderCanvas();
117
+ renderInspector();
118
+ }
119
+
120
+ function isNeighbor(id) {
121
+ if (!state.selected) return false;
122
+ return state.data.edges.some((e) =>
123
+ (e.from === state.selected && e.to === id) || (e.to === state.selected && e.from === id));
124
+ }
125
+
126
+ function renderInspector() {
127
+ if (state.selected) { inspectorHost.replaceChildren(detailView()); return; }
128
+ const q = state.query;
129
+ const list = state.data.nodes
130
+ .filter((n) => !q || (n.label || "").toLowerCase().includes(q) || (n.type || "").toLowerCase().includes(q))
131
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0));
132
+ inspectorHost.replaceChildren(
133
+ list.length
134
+ ? h("div.lt3-stack-2", list.slice(0, 60).map((n) => entityRow(n)))
135
+ : c.emptyState({ icon: "search-off", title: "No matches", body: "Try a different entity name." }),
136
+ );
137
+ }
138
+
139
+ function entityRow(n) {
140
+ return h("button.lt3-entity", { on: { click: () => { state.selected = n.id; renderCanvas(); renderInspector(); } } },
141
+ h("div.lt3-entity__type", { style: { background: `color-mix(in srgb, ${colorFor(n.type)} 18%, transparent)`, color: colorFor(n.type) } }, icon(iconForType(n.type))),
142
+ h("div.lt3-entity__body",
143
+ h("div.lt3-entity__name", n.label),
144
+ h("div.lt3-entity__meta", `${n.type || "Entity"} · weight ${(n.weight || 0).toFixed(2)}`),
145
+ ),
146
+ );
147
+ }
148
+
149
+ function detailView() {
150
+ const n = state.data.nodes.find((x) => x.id === state.selected);
151
+ if (!n) { state.selected = null; return c.emptyState({ title: "Not found" }); }
152
+ const rels = state.data.edges
153
+ .filter((e) => e.from === n.id || e.to === n.id)
154
+ .map((e) => {
155
+ const otherId = e.from === n.id ? e.to : e.from;
156
+ const other = state.data.nodes.find((x) => x.id === otherId);
157
+ return { type: e.type, dir: e.from === n.id ? "→" : "←", other };
158
+ })
159
+ .filter((r) => r.other);
160
+ return h("div.lt3-stack-4",
161
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { state.selected = null; renderCanvas(); renderInspector(); } } }, icon("arrow-left"), "All entities"),
162
+ h("div.lt3-card.lt3-card--flat",
163
+ h("div.lt3-row-2", { style: { "margin-bottom": "var(--lt3-space-2)" } },
164
+ h("span.lt3-pill", { style: { color: colorFor(n.type) } }, n.type || "Entity"),
165
+ ),
166
+ h("div", { style: { "font-size": "var(--lt3-text-lg)", "font-weight": 700 } }, n.label),
167
+ n.summary && h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-2)" } }, n.summary),
168
+ ),
169
+ h("div",
170
+ h("div.lt3-eyebrow", { style: { "margin-bottom": "var(--lt3-space-2)" } }, `Relations (${rels.length})`),
171
+ rels.length
172
+ ? h("div.lt3-stack-2", rels.map((r) => h("button.lt3-entity", { on: { click: () => { state.selected = r.other.id; renderCanvas(); renderInspector(); } } },
173
+ h("div.lt3-entity__type", { style: { background: "var(--surface-3)" } }, h("span.lt3-mono", { style: { "font-size": "var(--lt3-text-sm)" } }, r.dir)),
174
+ h("div.lt3-entity__body",
175
+ h("div.lt3-entity__name", r.other.label),
176
+ h("div.lt3-entity__meta", r.type),
177
+ ),
178
+ )))
179
+ : c.emptyState({ icon: "unlink", title: "No relations", body: "This entity is currently isolated." }),
180
+ ),
181
+ );
182
+ }
183
+
184
+ load();
185
+ return root;
186
+ }
187
+
188
+ /* ── helpers ─────────────────────────────────────────────────────────────── */
189
+ function normalize(data) {
190
+ const nodes = (data.nodes || []).map((n) => ({
191
+ id: n.id,
192
+ label: n.label || n.title || n.id,
193
+ type: n.type || "Entity",
194
+ weight: n.weight ?? n.importance_norm ?? (n.metadata && n.metadata.graph_metrics && n.metadata.graph_metrics.importance_norm) ?? 0.5,
195
+ summary: n.summary || "",
196
+ x: n.x, y: n.y,
197
+ }));
198
+ const ids = new Set(nodes.map((n) => n.id));
199
+ const edges = (data.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to))
200
+ .map((e) => ({ from: e.from, to: e.to, type: e.type || "related", weight: e.weight || 1 }));
201
+ return { nodes, edges };
202
+ }
203
+
204
+ function layout(nodes) {
205
+ const W = 1000, H = 600, cx = W / 2, cy = H / 2;
206
+ const golden = Math.PI * (3 - Math.sqrt(5));
207
+ const hasCoords = nodes.length && nodes.every((n) => typeof n.x === "number" && typeof n.y === "number");
208
+ if (hasCoords) {
209
+ return nodes.map((n) => ({ ...n, px: Math.round(60 + n.x * (W - 120)), py: Math.round(50 + n.y * (H - 100)) }));
210
+ }
211
+ // Sunflower (Vogel) spread — even spacing, highest-weight entity centered.
212
+ const order = nodes.map((n, i) => ({ n, i })).sort((a, b) => (b.n.weight || 0) - (a.n.weight || 0));
213
+ const maxR = Math.min(W, H) * 0.42;
214
+ const placed = {};
215
+ order.forEach((o, rank) => {
216
+ const radius = rank === 0 ? 0 : maxR * Math.sqrt(rank / Math.max(1, nodes.length - 1));
217
+ const angle = rank * golden;
218
+ placed[o.i] = {
219
+ px: Math.round(cx + Math.cos(angle) * radius),
220
+ py: Math.round(cy + Math.sin(angle) * radius * 0.66),
221
+ };
222
+ });
223
+ return nodes.map((n, i) => ({ ...n, ...placed[i] }));
224
+ }
225
+
226
+ function truncate(s, n) { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
227
+
228
+ function iconForType(t) {
229
+ return ({ Topic: "bulb", Concept: "atom", Method: "function", Model: "cpu", File: "file", Decision: "gavel", Task: "checkbox", Person: "user" })[t] || "point";
230
+ }
231
+
232
+ function buildLegend({ h }) {
233
+ const types = ["Topic", "Concept", "Method", "Model", "File"];
234
+ return h("div.lt3-graph-legend",
235
+ types.map((t) => h("span", h("i", { style: { background: colorFor(t) } }), t)),
236
+ );
237
+ }