ltcai 4.3.0 → 4.3.3
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.
- package/README.md +186 -276
- package/bin/ltcai.js +6 -2
- package/docs/CHANGELOG.md +124 -3
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +174 -0
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +81 -0
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +75 -0
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +48 -0
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +209 -0
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +57 -0
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +63 -0
- package/docs/V4_3_2_VALIDATION_REPORT.md +97 -0
- package/docs/V4_3_3_VALIDATION_REPORT.md +46 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -25
- package/frontend/openapi.json +11 -1
- package/frontend/src/App.tsx +15 -1
- package/frontend/src/api/client.ts +19 -1
- package/frontend/src/api/openapi.ts +10 -0
- package/frontend/src/components/primitives.tsx +92 -10
- package/frontend/src/pages/Act.tsx +72 -9
- package/frontend/src/pages/Ask.tsx +2 -2
- package/frontend/src/pages/Brain.tsx +607 -65
- package/frontend/src/pages/Capture.tsx +11 -7
- package/frontend/src/pages/Library.tsx +12 -6
- package/frontend/src/pages/System.tsx +186 -23
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +3 -3
- package/lattice_brain/storage/sqlite.py +15 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +3 -1
- package/latticeai/api/models.py +66 -18
- package/latticeai/brain/projection.py +12 -2
- package/latticeai/brain/retrieval.py +10 -0
- package/latticeai/brain/store.py +6 -1
- package/latticeai/core/config.py +3 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/product_hardening.py +2 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +52 -12
- package/latticeai/services/model_runtime.py +83 -2
- package/ltcai_cli.py +14 -3
- package/package.json +5 -7
- package/requirements.txt +17 -0
- package/scripts/build_vercel_static.mjs +77 -0
- package/scripts/check_markdown_links.mjs +75 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +269 -27
- package/src-tauri/tauri.conf.json +20 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-CHHal8Zl.css +2 -0
- package/static/app/assets/index-pdzil9ac.js +333 -0
- package/static/app/assets/index-pdzil9ac.js.map +1 -0
- package/static/app/index.html +2 -2
- package/latticeai/api/deps.py +0 -15
- package/scripts/capture/README.md +0 -28
- package/scripts/capture/capture_enterprise.js +0 -8
- package/scripts/capture/capture_graph.js +0 -8
- package/scripts/capture/capture_onboarding.js +0 -8
- package/scripts/capture/capture_page.js +0 -43
- package/scripts/capture/capture_release_media.js +0 -125
- package/scripts/capture/capture_skills.js +0 -8
- package/scripts/capture/capture_v340.js +0 -88
- package/scripts/capture/capture_workspace.js +0 -8
- package/scripts/generate_diagrams.py +0 -512
- package/scripts/release-0.3.1.sh +0 -105
- package/scripts/take_screenshots.js +0 -69
- package/static/app/assets/index-RiJTJliG.js +0 -333
- package/static/app/assets/index-RiJTJliG.js.map +0 -1
- package/static/app/assets/index-yZswHE3d.css +0 -2
- package/static/css/tokens.3ba22e37.css +0 -260
|
@@ -1,17 +1,63 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
-
import cytoscape, { Core } from "cytoscape";
|
|
4
|
-
import { BrainCircuit, DatabaseBackup,
|
|
3
|
+
import cytoscape, { Core, ElementDefinition } from "cytoscape";
|
|
4
|
+
import { BrainCircuit, DatabaseBackup, Filter, Focus, Layers3, LocateFixed, Search, Sparkles } from "lucide-react";
|
|
5
5
|
import { latticeApi } from "@/api/client";
|
|
6
|
-
import { ActionButton, DataPanel,
|
|
6
|
+
import { ActionButton, DataPanel, EmptyState, EntityList, LoadingPanel, OperationResult, StatGrid, StructuredView, Tabs } from "@/components/primitives";
|
|
7
7
|
import { Badge } from "@/components/ui/badge";
|
|
8
8
|
import { Button } from "@/components/ui/button";
|
|
9
9
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
10
|
import { Input } from "@/components/ui/input";
|
|
11
11
|
import { Textarea } from "@/components/ui/textarea";
|
|
12
|
-
import { asArray, pct } from "@/lib/utils";
|
|
12
|
+
import { asArray, fmtNumber, pct, shortId, titleize } from "@/lib/utils";
|
|
13
13
|
|
|
14
14
|
type BrainTab = "overview" | "graph" | "search" | "memory" | "provenance" | "portability";
|
|
15
|
+
type LabelMode = "important" | "all" | "off";
|
|
16
|
+
|
|
17
|
+
type GraphNode = {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
type: string;
|
|
21
|
+
group: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
source: string;
|
|
24
|
+
importance: number;
|
|
25
|
+
degree: number;
|
|
26
|
+
searchText: string;
|
|
27
|
+
raw: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type GraphEdge = {
|
|
31
|
+
id: string;
|
|
32
|
+
source: string;
|
|
33
|
+
target: string;
|
|
34
|
+
label: string;
|
|
35
|
+
weight: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type GraphGroup = {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
color: string;
|
|
42
|
+
count: number;
|
|
43
|
+
visibleCount: number;
|
|
44
|
+
collapsed: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ParsedGraph = {
|
|
48
|
+
nodes: GraphNode[];
|
|
49
|
+
edges: GraphEdge[];
|
|
50
|
+
groups: GraphGroup[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type ExplorerModel = ParsedGraph & {
|
|
54
|
+
elements: ElementDefinition[];
|
|
55
|
+
visibleNodes: GraphNode[];
|
|
56
|
+
visibleEdges: GraphEdge[];
|
|
57
|
+
totalNodes: number;
|
|
58
|
+
totalEdges: number;
|
|
59
|
+
hiddenByFilters: number;
|
|
60
|
+
};
|
|
15
61
|
|
|
16
62
|
const tabs: Array<{ id: BrainTab; label: string }> = [
|
|
17
63
|
{ id: "overview", label: "Overview" },
|
|
@@ -22,75 +68,346 @@ const tabs: Array<{ id: BrainTab; label: string }> = [
|
|
|
22
68
|
{ id: "portability", label: "Portability" },
|
|
23
69
|
];
|
|
24
70
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
const groupDefinitions = [
|
|
72
|
+
{ id: "knowledge", label: "Knowledge", color: "#20c997", types: ["topic", "concept", "entity", "decision", "insight", "claim", "fact"] },
|
|
73
|
+
{ id: "source", label: "Sources", color: "#60a5fa", types: ["file", "document", "source", "chunk", "note", "url", "page", "image", "transcript"] },
|
|
74
|
+
{ id: "activity", label: "Activity", color: "#f59e0b", types: ["task", "workflow", "agent", "run", "approval", "hook"] },
|
|
75
|
+
{ id: "memory", label: "Memory", color: "#a78bfa", types: ["memory", "conversation", "message", "chat", "context"] },
|
|
76
|
+
{ id: "people", label: "People", color: "#f472b6", types: ["person", "user", "team", "organization", "org"] },
|
|
77
|
+
{ id: "system", label: "System", color: "#94a3b8", types: ["model", "skill", "plugin", "setting", "policy", "device", "storage"] },
|
|
78
|
+
{ id: "other", label: "Other", color: "#f8fafc", types: [] },
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
const groupLookup: Map<string, string> = new Map(groupDefinitions.flatMap((group) => group.types.map((type) => [type, group.id])));
|
|
82
|
+
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function field(record: Record<string, unknown>, keys: string[], fallback = "") {
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const value = record[key];
|
|
90
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
91
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
92
|
+
}
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function numberField(record: Record<string, unknown>, keys: string[]) {
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
const value = Number(record[key]);
|
|
99
|
+
if (Number.isFinite(value)) return value;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function nestedRecord(record: Record<string, unknown>, key: string) {
|
|
105
|
+
return isRecord(record[key]) ? record[key] as Record<string, unknown> : {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function groupForType(type: string) {
|
|
109
|
+
return groupLookup.get(type.toLowerCase()) || "other";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function groupDefinition(id: string) {
|
|
113
|
+
return groupDefinitions.find((group) => group.id === id) || groupDefinitions[groupDefinitions.length - 1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseGraph(data: unknown): ParsedGraph {
|
|
117
|
+
const graph = isRecord(data) ? data : {};
|
|
118
|
+
const rawNodes = asArray<Record<string, unknown>>(graph.nodes);
|
|
119
|
+
const rawEdges = asArray<Record<string, unknown>>(graph.edges);
|
|
120
|
+
const ids = new Set(rawNodes.map((node) => field(node, ["id", "node_id", "title", "label"])).filter(Boolean));
|
|
121
|
+
const edges = rawEdges.flatMap((edge, index): GraphEdge[] => {
|
|
122
|
+
const source = field(edge, ["from", "source", "source_id"]);
|
|
123
|
+
const target = field(edge, ["to", "target", "target_id"]);
|
|
124
|
+
if (!source || !target || !ids.has(source) || !ids.has(target)) return [];
|
|
125
|
+
return [{
|
|
126
|
+
id: field(edge, ["id"], `edge-${index}`),
|
|
127
|
+
source,
|
|
128
|
+
target,
|
|
129
|
+
label: field(edge, ["type", "label", "relationship"], "related"),
|
|
130
|
+
weight: Math.max(0.2, numberField(edge, ["weight", "score", "confidence"]) || 1),
|
|
131
|
+
}];
|
|
132
|
+
});
|
|
133
|
+
const degree = new Map<string, number>();
|
|
134
|
+
edges.forEach((edge) => {
|
|
135
|
+
degree.set(edge.source, (degree.get(edge.source) || 0) + 1);
|
|
136
|
+
degree.set(edge.target, (degree.get(edge.target) || 0) + 1);
|
|
137
|
+
});
|
|
138
|
+
const maxDegree = Math.max(1, ...Array.from(degree.values()));
|
|
139
|
+
const nodes = rawNodes.flatMap((node): GraphNode[] => {
|
|
140
|
+
const id = field(node, ["id", "node_id", "title", "label"]);
|
|
141
|
+
if (!id) return [];
|
|
142
|
+
const metadata = nestedRecord(node, "metadata");
|
|
143
|
+
const metrics = nestedRecord(metadata, "graph_metrics");
|
|
144
|
+
const type = field(node, ["type", "kind", "category"], "Node");
|
|
145
|
+
const label = field(node, ["title", "label", "name"], shortId(id, 38));
|
|
146
|
+
const explicitImportance = numberField(node, ["importance_norm", "importance", "score"]) || numberField(metrics, ["importance_norm", "importance", "centrality"]);
|
|
147
|
+
const nodeDegree = degree.get(id) || 0;
|
|
148
|
+
const importance = Math.max(0.08, Math.min(1, explicitImportance || (nodeDegree / maxDegree) * 0.8 + 0.12));
|
|
149
|
+
const summary = field(node, ["summary", "description", "snippet"]) || field(metadata, ["summary", "description", "relative_path", "filename"]);
|
|
150
|
+
const source = field(node, ["source", "path"]) || field(metadata, ["source", "relative_path", "filename"]);
|
|
151
|
+
const searchText = [id, label, type, summary, source, Object.keys(metadata).join(" ")].join(" ").toLowerCase();
|
|
152
|
+
return [{ id, label, type, group: groupForType(type), summary, source, importance, degree: nodeDegree, searchText, raw: node }];
|
|
153
|
+
});
|
|
154
|
+
const groupCounts = new Map<string, number>();
|
|
155
|
+
nodes.forEach((node) => groupCounts.set(node.group, (groupCounts.get(node.group) || 0) + 1));
|
|
156
|
+
const groups = groupDefinitions.map((group) => ({
|
|
157
|
+
id: group.id,
|
|
158
|
+
label: group.label,
|
|
159
|
+
color: group.color,
|
|
160
|
+
count: groupCounts.get(group.id) || 0,
|
|
161
|
+
visibleCount: 0,
|
|
162
|
+
collapsed: false,
|
|
163
|
+
})).filter((group) => group.count > 0);
|
|
164
|
+
return { nodes, edges, groups };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildExplorerModel({
|
|
168
|
+
graph,
|
|
169
|
+
search,
|
|
170
|
+
groupFilter,
|
|
171
|
+
minImportance,
|
|
172
|
+
collapsedGroups,
|
|
173
|
+
selectedId,
|
|
174
|
+
labelMode,
|
|
175
|
+
maxNodes,
|
|
176
|
+
}: {
|
|
177
|
+
graph: ParsedGraph;
|
|
178
|
+
search: string;
|
|
179
|
+
groupFilter: string;
|
|
180
|
+
minImportance: number;
|
|
181
|
+
collapsedGroups: Set<string>;
|
|
182
|
+
selectedId: string | null;
|
|
183
|
+
labelMode: LabelMode;
|
|
184
|
+
maxNodes: number;
|
|
185
|
+
}): ExplorerModel {
|
|
186
|
+
const query = search.trim().toLowerCase();
|
|
187
|
+
const neighborIds = new Set<string>();
|
|
188
|
+
if (selectedId && !selectedId.startsWith("group:")) {
|
|
189
|
+
neighborIds.add(selectedId);
|
|
190
|
+
graph.edges.forEach((edge) => {
|
|
191
|
+
if (edge.source === selectedId) neighborIds.add(edge.target);
|
|
192
|
+
if (edge.target === selectedId) neighborIds.add(edge.source);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const filtered = graph.nodes
|
|
196
|
+
.filter((node) => groupFilter === "all" || node.group === groupFilter || node.type === groupFilter)
|
|
197
|
+
.filter((node) => query ? node.searchText.includes(query) : node.importance >= minImportance)
|
|
198
|
+
.filter((node) => !neighborIds.size || neighborIds.has(node.id))
|
|
199
|
+
.sort((a, b) => (b.importance + b.degree / 25) - (a.importance + a.degree / 25));
|
|
200
|
+
const capped = filtered.slice(0, maxNodes);
|
|
201
|
+
const visibleCandidateIds = new Set(capped.map((node) => node.id));
|
|
202
|
+
const aggregateNodes = new Map<string, { id: string; group: GraphGroup; count: number; maxImportance: number }>();
|
|
203
|
+
const visibleNodes = capped.filter((node) => {
|
|
204
|
+
if (!collapsedGroups.has(node.group)) return true;
|
|
205
|
+
const definition = groupDefinition(node.group);
|
|
206
|
+
const aggregateId = `group:${node.group}`;
|
|
207
|
+
const current = aggregateNodes.get(aggregateId);
|
|
208
|
+
aggregateNodes.set(aggregateId, {
|
|
209
|
+
id: aggregateId,
|
|
210
|
+
group: { id: definition.id, label: definition.label, color: definition.color, count: 0, visibleCount: 0, collapsed: true },
|
|
211
|
+
count: (current?.count || 0) + 1,
|
|
212
|
+
maxImportance: Math.max(current?.maxImportance || 0, node.importance),
|
|
213
|
+
});
|
|
214
|
+
return false;
|
|
215
|
+
});
|
|
216
|
+
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
|
|
217
|
+
const aggregateIds = new Set(aggregateNodes.keys());
|
|
218
|
+
const visibleEdges = graph.edges.filter((edge) => visibleCandidateIds.has(edge.source) && visibleCandidateIds.has(edge.target));
|
|
219
|
+
const mappedEdges = visibleEdges.flatMap((edge, index): ElementDefinition[] => {
|
|
220
|
+
const sourceNode = graph.nodes.find((node) => node.id === edge.source);
|
|
221
|
+
const targetNode = graph.nodes.find((node) => node.id === edge.target);
|
|
222
|
+
const source = visibleNodeIds.has(edge.source) ? edge.source : sourceNode && aggregateIds.has(`group:${sourceNode.group}`) ? `group:${sourceNode.group}` : "";
|
|
223
|
+
const target = visibleNodeIds.has(edge.target) ? edge.target : targetNode && aggregateIds.has(`group:${targetNode.group}`) ? `group:${targetNode.group}` : "";
|
|
224
|
+
if (!source || !target || source === target) return [];
|
|
39
225
|
return [{
|
|
40
226
|
data: {
|
|
41
|
-
id:
|
|
227
|
+
id: `${edge.id}-${index}-${source}-${target}`,
|
|
42
228
|
source,
|
|
43
229
|
target,
|
|
44
|
-
label:
|
|
230
|
+
label: edge.label,
|
|
231
|
+
width: Math.max(1, Math.min(4, edge.weight)),
|
|
45
232
|
},
|
|
233
|
+
classes: selectedId && (edge.source === selectedId || edge.target === selectedId) ? "connected" : "",
|
|
46
234
|
}];
|
|
47
235
|
});
|
|
48
|
-
|
|
236
|
+
const nodeElements: ElementDefinition[] = visibleNodes.map((node) => {
|
|
237
|
+
const definition = groupDefinition(node.group);
|
|
238
|
+
const matched = query && node.searchText.includes(query);
|
|
239
|
+
const label = labelMode === "off" ? "" : labelMode === "all" || node.importance > 0.55 || node.degree > 1 || matched || selectedId === node.id ? node.label : "";
|
|
240
|
+
return {
|
|
241
|
+
data: {
|
|
242
|
+
id: node.id,
|
|
243
|
+
label: node.label,
|
|
244
|
+
displayLabel: label,
|
|
245
|
+
type: node.type,
|
|
246
|
+
group: definition.label,
|
|
247
|
+
color: definition.color,
|
|
248
|
+
borderColor: selectedId === node.id ? "#ffffff" : definition.color,
|
|
249
|
+
size: Math.round(20 + node.importance * 34 + Math.min(node.degree, 10) * 2),
|
|
250
|
+
},
|
|
251
|
+
classes: [
|
|
252
|
+
selectedId === node.id ? "selected" : "",
|
|
253
|
+
matched ? "match" : "",
|
|
254
|
+
neighborIds.size && !neighborIds.has(node.id) ? "faded" : "",
|
|
255
|
+
].filter(Boolean).join(" "),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
const aggregateElements: ElementDefinition[] = Array.from(aggregateNodes.values()).map((aggregate) => ({
|
|
259
|
+
data: {
|
|
260
|
+
id: aggregate.id,
|
|
261
|
+
label: aggregate.group.label,
|
|
262
|
+
displayLabel: `${aggregate.group.label} (${aggregate.count})`,
|
|
263
|
+
type: "Cluster",
|
|
264
|
+
group: aggregate.group.label,
|
|
265
|
+
color: aggregate.group.color,
|
|
266
|
+
borderColor: "#ffffff",
|
|
267
|
+
size: Math.round(34 + Math.min(aggregate.count, 28) * 2 + aggregate.maxImportance * 16),
|
|
268
|
+
},
|
|
269
|
+
classes: "cluster",
|
|
270
|
+
}));
|
|
271
|
+
const visibleCounts = new Map<string, number>();
|
|
272
|
+
visibleNodes.forEach((node) => visibleCounts.set(node.group, (visibleCounts.get(node.group) || 0) + 1));
|
|
273
|
+
const groups = graph.groups.map((group) => ({
|
|
274
|
+
...group,
|
|
275
|
+
visibleCount: (visibleCounts.get(group.id) || 0) + (aggregateNodes.get(`group:${group.id}`)?.count || 0),
|
|
276
|
+
collapsed: collapsedGroups.has(group.id),
|
|
277
|
+
}));
|
|
278
|
+
return {
|
|
279
|
+
...graph,
|
|
280
|
+
groups,
|
|
281
|
+
elements: [...aggregateElements, ...nodeElements, ...mappedEdges],
|
|
282
|
+
visibleNodes,
|
|
283
|
+
visibleEdges,
|
|
284
|
+
totalNodes: graph.nodes.length,
|
|
285
|
+
totalEdges: graph.edges.length,
|
|
286
|
+
hiddenByFilters: Math.max(0, graph.nodes.length - capped.length),
|
|
287
|
+
};
|
|
49
288
|
}
|
|
50
289
|
|
|
51
|
-
function CytoscapeGraph({
|
|
290
|
+
function CytoscapeGraph({
|
|
291
|
+
model,
|
|
292
|
+
selectedId,
|
|
293
|
+
onSelect,
|
|
294
|
+
fitSignal,
|
|
295
|
+
}: {
|
|
296
|
+
model: ExplorerModel;
|
|
297
|
+
selectedId: string | null;
|
|
298
|
+
onSelect: (id: string | null) => void;
|
|
299
|
+
fitSignal: number;
|
|
300
|
+
}) {
|
|
52
301
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
53
302
|
const cyRef = React.useRef<Core | null>(null);
|
|
54
303
|
React.useEffect(() => {
|
|
55
304
|
if (!hostRef.current) return;
|
|
56
|
-
const elements = graphElements(data);
|
|
57
305
|
cyRef.current?.destroy();
|
|
58
306
|
cyRef.current = cytoscape({
|
|
59
307
|
container: hostRef.current,
|
|
60
|
-
elements,
|
|
308
|
+
elements: model.elements,
|
|
61
309
|
style: [
|
|
62
310
|
{
|
|
63
311
|
selector: "node",
|
|
64
312
|
style: {
|
|
65
|
-
"background-color": "
|
|
66
|
-
"border-color": "
|
|
67
|
-
"border-width": 1,
|
|
68
|
-
color: "#
|
|
69
|
-
label: "data(
|
|
70
|
-
"font-size":
|
|
313
|
+
"background-color": "data(color)",
|
|
314
|
+
"border-color": "data(borderColor)",
|
|
315
|
+
"border-width": 1.5,
|
|
316
|
+
color: "#f8fafc",
|
|
317
|
+
label: "data(displayLabel)",
|
|
318
|
+
"font-size": 10,
|
|
319
|
+
"font-weight": 600,
|
|
71
320
|
"text-outline-color": "#071012",
|
|
72
|
-
"text-outline-width": 2,
|
|
73
|
-
width:
|
|
74
|
-
height:
|
|
321
|
+
"text-outline-width": 2.5,
|
|
322
|
+
width: "data(size)",
|
|
323
|
+
height: "data(size)",
|
|
324
|
+
"overlay-opacity": 0,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
selector: "node.cluster",
|
|
329
|
+
style: {
|
|
330
|
+
shape: "round-rectangle",
|
|
331
|
+
"background-opacity": 0.46,
|
|
332
|
+
"border-width": 2,
|
|
333
|
+
"font-size": 12,
|
|
334
|
+
"text-valign": "center",
|
|
335
|
+
"text-halign": "center",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
selector: "node.selected",
|
|
340
|
+
style: {
|
|
341
|
+
"border-width": 4,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
selector: "node.match",
|
|
346
|
+
style: {
|
|
347
|
+
"border-width": 4,
|
|
348
|
+
"border-color": "#fef08a",
|
|
75
349
|
},
|
|
76
350
|
},
|
|
77
351
|
{
|
|
78
352
|
selector: "edge",
|
|
79
353
|
style: {
|
|
80
|
-
width:
|
|
81
|
-
"line-color": "#
|
|
354
|
+
width: "data(width)",
|
|
355
|
+
"line-color": "#64748b",
|
|
82
356
|
"target-arrow-shape": "triangle",
|
|
83
|
-
"target-arrow-color": "#
|
|
357
|
+
"target-arrow-color": "#64748b",
|
|
84
358
|
"curve-style": "bezier",
|
|
359
|
+
"arrow-scale": 0.7,
|
|
360
|
+
opacity: 0.72,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
selector: "edge.connected",
|
|
365
|
+
style: {
|
|
366
|
+
width: 2.6,
|
|
367
|
+
"line-color": "#fef08a",
|
|
368
|
+
"target-arrow-color": "#fef08a",
|
|
369
|
+
opacity: 1,
|
|
85
370
|
},
|
|
86
371
|
},
|
|
87
372
|
],
|
|
88
|
-
layout: {
|
|
89
|
-
|
|
373
|
+
layout: {
|
|
374
|
+
name: "cose",
|
|
375
|
+
animate: false,
|
|
376
|
+
idealEdgeLength: 120,
|
|
377
|
+
nodeRepulsion: 5800,
|
|
378
|
+
gravity: 0.28,
|
|
379
|
+
numIter: 1200,
|
|
380
|
+
fit: true,
|
|
381
|
+
padding: 32,
|
|
382
|
+
},
|
|
383
|
+
wheelSensitivity: 0.18,
|
|
384
|
+
});
|
|
385
|
+
cyRef.current.on("tap", "node", (event) => onSelect(String(event.target.id())));
|
|
386
|
+
cyRef.current.on("tap", (event) => {
|
|
387
|
+
if (event.target === cyRef.current) onSelect(null);
|
|
90
388
|
});
|
|
91
389
|
return () => cyRef.current?.destroy();
|
|
92
|
-
}, [
|
|
93
|
-
|
|
390
|
+
}, [model.elements, onSelect]);
|
|
391
|
+
|
|
392
|
+
React.useEffect(() => {
|
|
393
|
+
cyRef.current?.fit(undefined, 32);
|
|
394
|
+
}, [fitSignal]);
|
|
395
|
+
|
|
396
|
+
React.useEffect(() => {
|
|
397
|
+
if (!cyRef.current || !selectedId) return;
|
|
398
|
+
const node = cyRef.current.getElementById(selectedId);
|
|
399
|
+
if (node.length) {
|
|
400
|
+
cyRef.current.animate({ center: { eles: node }, zoom: Math.max(cyRef.current.zoom(), 1.15) }, { duration: 180 });
|
|
401
|
+
}
|
|
402
|
+
}, [selectedId]);
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<div
|
|
406
|
+
ref={hostRef}
|
|
407
|
+
data-testid="brain-cytoscape"
|
|
408
|
+
className="h-[620px] min-h-[32rem] w-full overflow-hidden rounded-md border border-border bg-background brain-grid"
|
|
409
|
+
/>
|
|
410
|
+
);
|
|
94
411
|
}
|
|
95
412
|
|
|
96
413
|
export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
@@ -112,10 +429,10 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
|
112
429
|
<div className="flex items-center gap-2 text-sm text-primary"><BrainCircuit className="h-4 w-4" /> Graph-first Digital Brain</div>
|
|
113
430
|
<h1 className="mt-2 text-3xl font-semibold tracking-normal">Brain</h1>
|
|
114
431
|
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
|
115
|
-
|
|
432
|
+
Explore the knowledge graph, memory, provenance, retrieval, and portable brain state. Empty states reflect live API availability.
|
|
116
433
|
</p>
|
|
117
434
|
</div>
|
|
118
|
-
<div className="rounded-
|
|
435
|
+
<div className="rounded-md border border-border bg-card p-4">
|
|
119
436
|
<div className="text-xs uppercase text-muted-foreground">Provenance coverage</div>
|
|
120
437
|
<div className="mt-2 text-3xl font-semibold">{pct((coverage.data?.data as Record<string, unknown>)?.coverage_ratio)}</div>
|
|
121
438
|
<div className="mt-2 text-sm text-muted-foreground">Source: {coverage.data?.source || "loading"}</div>
|
|
@@ -126,18 +443,13 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
|
126
443
|
{tab === "overview" ? (
|
|
127
444
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
128
445
|
<DataPanel title="Brain status" result={stats.data}>
|
|
129
|
-
{(data) => <
|
|
130
|
-
{ label: "Nodes", value: (data as Record<string, unknown>).total_nodes ?? 0 },
|
|
131
|
-
{ label: "Edges", value: (data as Record<string, unknown>).total_edges ?? 0 },
|
|
132
|
-
{ label: "Node types", value: Object.keys(((data as Record<string, unknown>).nodes as Record<string, unknown>) || {}).length },
|
|
133
|
-
{ label: "Edge types", value: Object.keys(((data as Record<string, unknown>).edges as Record<string, unknown>) || {}).length },
|
|
134
|
-
]} />}
|
|
446
|
+
{(data) => <GraphStatus data={data as Record<string, unknown>} />}
|
|
135
447
|
</DataPanel>
|
|
136
448
|
<DataPanel title="Retrieval index" result={index.data}>
|
|
137
|
-
{(data) => <
|
|
449
|
+
{(data) => <RetrievalStatus data={data as Record<string, unknown>} />}
|
|
138
450
|
</DataPanel>
|
|
139
451
|
<DataPanel title="Memory tiers" result={memory.data}>
|
|
140
|
-
{(data) => <
|
|
452
|
+
{(data) => <MemoryStatus data={data as Record<string, unknown>} />}
|
|
141
453
|
</DataPanel>
|
|
142
454
|
<DataPanel title="Recent provenance" result={provenance.data}>
|
|
143
455
|
{(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" />}
|
|
@@ -147,8 +459,8 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
|
147
459
|
|
|
148
460
|
{tab === "graph" ? (
|
|
149
461
|
graph.isLoading ? <LoadingPanel title="Knowledge graph" /> : (
|
|
150
|
-
<DataPanel title="
|
|
151
|
-
{(data) => <
|
|
462
|
+
<DataPanel title="Digital Brain explorer" description="Interactive Cytoscape.js explorer backed by /knowledge-graph/graph." result={graph.data}>
|
|
463
|
+
{(data) => <DigitalBrainExplorer data={data} />}
|
|
152
464
|
</DataPanel>
|
|
153
465
|
)
|
|
154
466
|
) : null}
|
|
@@ -161,18 +473,226 @@ export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
|
161
473
|
);
|
|
162
474
|
}
|
|
163
475
|
|
|
476
|
+
function GraphStatus({ data }: { data: Record<string, unknown> }) {
|
|
477
|
+
const nodeTypes = Object.keys((data.nodes as Record<string, unknown>) || {});
|
|
478
|
+
const edgeTypes = Object.keys((data.edges as Record<string, unknown>) || {});
|
|
479
|
+
return (
|
|
480
|
+
<div className="space-y-3">
|
|
481
|
+
<StatGrid stats={[
|
|
482
|
+
{ label: "Nodes", value: data.total_nodes ?? nodeTypes.reduce((sum, key) => sum + Number(((data.nodes as Record<string, unknown>) || {})[key] || 0), 0) },
|
|
483
|
+
{ label: "Edges", value: data.total_edges ?? edgeTypes.reduce((sum, key) => sum + Number(((data.edges as Record<string, unknown>) || {})[key] || 0), 0) },
|
|
484
|
+
{ label: "Node types", value: nodeTypes.length },
|
|
485
|
+
{ label: "Edge types", value: edgeTypes.length },
|
|
486
|
+
]} />
|
|
487
|
+
<StructuredView value={{ node_types: nodeTypes, edge_types: edgeTypes }} />
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function RetrievalStatus({ data }: { data: Record<string, unknown> }) {
|
|
493
|
+
const pipelines = isRecord(data.pipelines) ? data.pipelines : {};
|
|
494
|
+
const rows = Object.entries(pipelines).map(([name, value]) => ({
|
|
495
|
+
name: titleize(name),
|
|
496
|
+
status: isRecord(value) ? String(value.state || value.status || "reported") : "reported",
|
|
497
|
+
description: isRecord(value) ? Object.entries(value).filter(([key]) => key !== "state" && key !== "status").slice(0, 3).map(([key, item]) => `${titleize(key)}: ${String(item)}`).join(" · ") : String(value),
|
|
498
|
+
}));
|
|
499
|
+
return rows.length ? <EntityList items={rows} titleKey="name" metaKey="status" /> : <StructuredView value={data} />;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function MemoryStatus({ data }: { data: Record<string, unknown> }) {
|
|
503
|
+
const usage = isRecord(data.usage) ? data.usage : {};
|
|
504
|
+
return (
|
|
505
|
+
<div className="space-y-3">
|
|
506
|
+
<StatGrid stats={[
|
|
507
|
+
{ label: "Sources", value: usage.sources ?? asArray(data.sources).length },
|
|
508
|
+
{ label: "Items", value: usage.total_items ?? asArray(data.sources).reduce((sum, item) => sum + Number(isRecord(item) ? item.count || 0 : 0), 0) },
|
|
509
|
+
{ label: "Bytes", value: usage.total_bytes ?? 0 },
|
|
510
|
+
{ label: "Health", value: data.health || "reported" },
|
|
511
|
+
]} />
|
|
512
|
+
<EntityList items={data.sources || data.tiers} titleKey="label" metaKey="health" />
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function DigitalBrainExplorer({ data }: { data: unknown }) {
|
|
518
|
+
const parsed = React.useMemo(() => parseGraph(data), [data]);
|
|
519
|
+
const [search, setSearch] = React.useState("");
|
|
520
|
+
const [groupFilter, setGroupFilter] = React.useState("all");
|
|
521
|
+
const [minImportance, setMinImportance] = React.useState(0);
|
|
522
|
+
const [labelMode, setLabelMode] = React.useState<LabelMode>("important");
|
|
523
|
+
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
|
|
524
|
+
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
|
525
|
+
const [fitSignal, setFitSignal] = React.useState(0);
|
|
526
|
+
const backendSearch = useMutation({ mutationFn: () => latticeApi.hybridSearch(search.trim()) });
|
|
527
|
+
const model = React.useMemo(() => buildExplorerModel({
|
|
528
|
+
graph: parsed,
|
|
529
|
+
search,
|
|
530
|
+
groupFilter,
|
|
531
|
+
minImportance,
|
|
532
|
+
collapsedGroups,
|
|
533
|
+
selectedId,
|
|
534
|
+
labelMode,
|
|
535
|
+
maxNodes: 220,
|
|
536
|
+
}), [parsed, search, groupFilter, minImportance, collapsedGroups, selectedId, labelMode]);
|
|
537
|
+
const selected = parsed.nodes.find((node) => node.id === selectedId);
|
|
538
|
+
const selectedGroup = selectedId?.startsWith("group:") ? groupDefinition(selectedId.replace("group:", "")) : null;
|
|
539
|
+
const toggleGroup = (id: string) => {
|
|
540
|
+
setCollapsedGroups((current) => {
|
|
541
|
+
const next = new Set(current);
|
|
542
|
+
if (next.has(id)) next.delete(id);
|
|
543
|
+
else next.add(id);
|
|
544
|
+
return next;
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
if (!parsed.nodes.length) {
|
|
548
|
+
return (
|
|
549
|
+
<EmptyState
|
|
550
|
+
title="No graph records yet"
|
|
551
|
+
detail="Capture a document, note, or local folder to create graph nodes with provenance."
|
|
552
|
+
/>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return (
|
|
556
|
+
<div className="space-y-4">
|
|
557
|
+
<div className="grid gap-3 xl:grid-cols-[1fr_220px_180px_170px]">
|
|
558
|
+
<div className="relative">
|
|
559
|
+
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
560
|
+
<Input className="pl-9" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search graph labels, types, provenance..." />
|
|
561
|
+
</div>
|
|
562
|
+
<select className="h-9 rounded-md border border-border bg-background px-3 text-sm" value={groupFilter} onChange={(event) => setGroupFilter(event.target.value)}>
|
|
563
|
+
<option value="all">All semantic groups</option>
|
|
564
|
+
{model.groups.map((group) => <option key={group.id} value={group.id}>{group.label}</option>)}
|
|
565
|
+
</select>
|
|
566
|
+
<select className="h-9 rounded-md border border-border bg-background px-3 text-sm" value={labelMode} onChange={(event) => setLabelMode(event.target.value as LabelMode)}>
|
|
567
|
+
<option value="important">Important labels</option>
|
|
568
|
+
<option value="all">All labels</option>
|
|
569
|
+
<option value="off">Hide labels</option>
|
|
570
|
+
</select>
|
|
571
|
+
<Button variant="outline" onClick={() => setFitSignal((value) => value + 1)}><LocateFixed className="h-4 w-4" /> Fit</Button>
|
|
572
|
+
</div>
|
|
573
|
+
<div className="grid gap-3 lg:grid-cols-[1fr_18rem]">
|
|
574
|
+
<Card>
|
|
575
|
+
<CardHeader className="flex-row items-start justify-between gap-3">
|
|
576
|
+
<div>
|
|
577
|
+
<CardTitle className="flex items-center gap-2"><Layers3 className="h-4 w-4" /> Semantic map</CardTitle>
|
|
578
|
+
<CardDescription>
|
|
579
|
+
Showing {fmtNumber(model.visibleNodes.length)} nodes and {fmtNumber(model.visibleEdges.length)} relationships from {fmtNumber(model.totalNodes)} graph nodes.
|
|
580
|
+
</CardDescription>
|
|
581
|
+
</div>
|
|
582
|
+
<Badge variant={model.hiddenByFilters ? "warning" : "success"}>{model.hiddenByFilters ? `${fmtNumber(model.hiddenByFilters)} filtered` : "all in view"}</Badge>
|
|
583
|
+
</CardHeader>
|
|
584
|
+
<CardContent className="space-y-3">
|
|
585
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
586
|
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
|
587
|
+
<label className="text-sm text-muted-foreground" htmlFor="importance">Importance</label>
|
|
588
|
+
<input
|
|
589
|
+
id="importance"
|
|
590
|
+
type="range"
|
|
591
|
+
min="0"
|
|
592
|
+
max="0.9"
|
|
593
|
+
step="0.05"
|
|
594
|
+
value={minImportance}
|
|
595
|
+
onChange={(event) => setMinImportance(Number(event.target.value))}
|
|
596
|
+
className="w-44"
|
|
597
|
+
aria-label="Minimum graph importance"
|
|
598
|
+
/>
|
|
599
|
+
<Badge variant="muted">{Math.round(minImportance * 100)}%+</Badge>
|
|
600
|
+
{selectedId ? <Button variant="outline" size="sm" onClick={() => setSelectedId(null)}>Clear focus</Button> : null}
|
|
601
|
+
{search.trim() ? <Button variant="outline" size="sm" onClick={() => backendSearch.mutate()} disabled={backendSearch.isPending}>Search brain</Button> : null}
|
|
602
|
+
</div>
|
|
603
|
+
<div className="flex flex-wrap gap-2">
|
|
604
|
+
{model.groups.map((group) => (
|
|
605
|
+
<button
|
|
606
|
+
key={group.id}
|
|
607
|
+
onClick={() => toggleGroup(group.id)}
|
|
608
|
+
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs hover:bg-muted"
|
|
609
|
+
>
|
|
610
|
+
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: group.color }} />
|
|
611
|
+
<span>{group.label}</span>
|
|
612
|
+
<Badge variant={group.collapsed ? "warning" : "muted"}>{group.collapsed ? "collapsed" : fmtNumber(group.count)}</Badge>
|
|
613
|
+
</button>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
<CytoscapeGraph model={model} selectedId={selectedId} onSelect={setSelectedId} fitSignal={fitSignal} />
|
|
617
|
+
</CardContent>
|
|
618
|
+
</Card>
|
|
619
|
+
<aside className="space-y-3">
|
|
620
|
+
<Card>
|
|
621
|
+
<CardHeader>
|
|
622
|
+
<CardTitle className="flex items-center gap-2"><Focus className="h-4 w-4" /> Focus</CardTitle>
|
|
623
|
+
<CardDescription>Click a node to inspect its neighborhood.</CardDescription>
|
|
624
|
+
</CardHeader>
|
|
625
|
+
<CardContent className="space-y-3">
|
|
626
|
+
{selected ? (
|
|
627
|
+
<>
|
|
628
|
+
<div>
|
|
629
|
+
<div className="text-lg font-semibold">{selected.label}</div>
|
|
630
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
631
|
+
<Badge variant="muted">{selected.type}</Badge>
|
|
632
|
+
<Badge variant="muted">{groupDefinition(selected.group).label}</Badge>
|
|
633
|
+
<Badge variant="success">{Math.round(selected.importance * 100)} importance</Badge>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
{selected.summary ? <p className="text-sm text-muted-foreground">{selected.summary}</p> : null}
|
|
637
|
+
<StructuredView value={{
|
|
638
|
+
id: selected.id,
|
|
639
|
+
degree: selected.degree,
|
|
640
|
+
source: selected.source || "not reported",
|
|
641
|
+
}} />
|
|
642
|
+
</>
|
|
643
|
+
) : selectedGroup ? (
|
|
644
|
+
<div className="space-y-2">
|
|
645
|
+
<Badge variant="warning">Collapsed group</Badge>
|
|
646
|
+
<div className="text-lg font-semibold">{selectedGroup.label}</div>
|
|
647
|
+
<Button variant="outline" onClick={() => toggleGroup(selectedGroup.id)}>Expand group</Button>
|
|
648
|
+
</div>
|
|
649
|
+
) : <EmptyState title="No node selected" detail="Select a node or collapsed group in the graph." />}
|
|
650
|
+
</CardContent>
|
|
651
|
+
</Card>
|
|
652
|
+
<Card>
|
|
653
|
+
<CardHeader>
|
|
654
|
+
<CardTitle>Important nodes</CardTitle>
|
|
655
|
+
<CardDescription>Highest-ranked visible graph records.</CardDescription>
|
|
656
|
+
</CardHeader>
|
|
657
|
+
<CardContent className="space-y-2">
|
|
658
|
+
{model.visibleNodes.slice(0, 8).map((node) => (
|
|
659
|
+
<button
|
|
660
|
+
key={node.id}
|
|
661
|
+
onClick={() => setSelectedId(node.id)}
|
|
662
|
+
className="block w-full rounded-md border border-border bg-background p-2 text-left text-sm hover:bg-muted"
|
|
663
|
+
>
|
|
664
|
+
<div className="flex items-center justify-between gap-2">
|
|
665
|
+
<span className="font-medium">{node.label}</span>
|
|
666
|
+
<Badge variant="muted">{node.type}</Badge>
|
|
667
|
+
</div>
|
|
668
|
+
<div className="mt-1 text-xs text-muted-foreground">{Math.round(node.importance * 100)} importance · {fmtNumber(node.degree)} links</div>
|
|
669
|
+
</button>
|
|
670
|
+
))}
|
|
671
|
+
</CardContent>
|
|
672
|
+
</Card>
|
|
673
|
+
</aside>
|
|
674
|
+
</div>
|
|
675
|
+
{backendSearch.data ? (
|
|
676
|
+
<DataPanel title="Brain search results" result={backendSearch.data}>
|
|
677
|
+
{(result) => <EntityList items={(result as Record<string, unknown>).matches || result} titleKey="title" metaKey="type" limit={8} />}
|
|
678
|
+
</DataPanel>
|
|
679
|
+
) : null}
|
|
680
|
+
</div>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
164
684
|
function HybridSearch() {
|
|
165
|
-
const [query, setQuery] = React.useState("
|
|
685
|
+
const [query, setQuery] = React.useState("");
|
|
166
686
|
const search = useMutation({ mutationFn: () => latticeApi.hybridSearch(query) });
|
|
167
687
|
return (
|
|
168
688
|
<Card>
|
|
169
689
|
<CardHeader>
|
|
170
690
|
<CardTitle className="flex items-center gap-2"><Search className="h-4 w-4" /> Hybrid search</CardTitle>
|
|
171
|
-
<CardDescription>Calls the backend fused search endpoint and renders
|
|
691
|
+
<CardDescription>Calls the backend fused search endpoint and renders records with returned source scores.</CardDescription>
|
|
172
692
|
</CardHeader>
|
|
173
693
|
<CardContent className="space-y-3">
|
|
174
694
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
175
|
-
<Input placeholder="
|
|
695
|
+
<Input placeholder="Search memories, graph nodes, and indexed documents" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search.mutate()} />
|
|
176
696
|
<Button onClick={() => search.mutate()} disabled={!query.trim() || search.isPending}>Search</Button>
|
|
177
697
|
</div>
|
|
178
698
|
{search.data ? (
|
|
@@ -192,7 +712,7 @@ function MemoryPanel() {
|
|
|
192
712
|
return (
|
|
193
713
|
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
|
194
714
|
<DataPanel title="Memory manager" result={manager.data}>
|
|
195
|
-
{(data) => <
|
|
715
|
+
{(data) => <MemoryStatus data={data as Record<string, unknown>} />}
|
|
196
716
|
</DataPanel>
|
|
197
717
|
<Card>
|
|
198
718
|
<CardHeader>
|
|
@@ -201,12 +721,12 @@ function MemoryPanel() {
|
|
|
201
721
|
</CardHeader>
|
|
202
722
|
<CardContent className="space-y-3">
|
|
203
723
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Recall memories about..." />
|
|
204
|
-
<div className="flex gap-2">
|
|
724
|
+
<div className="flex flex-wrap gap-2">
|
|
205
725
|
<Button disabled={!query.trim() || recall.isPending} onClick={() => recall.mutate()}>Recall</Button>
|
|
206
726
|
<ActionButton label="Compact" action={() => latticeApi.memoryCompact()} />
|
|
207
727
|
<ActionButton label="Rebuild vector" action={() => latticeApi.memoryRebuild()} />
|
|
208
728
|
</div>
|
|
209
|
-
{recall.data ? <
|
|
729
|
+
{recall.data ? <OperationResult result={recall.data} successLabel="Recall completed" /> : null}
|
|
210
730
|
</CardContent>
|
|
211
731
|
</Card>
|
|
212
732
|
</div>
|
|
@@ -219,7 +739,7 @@ function ProvenancePanel() {
|
|
|
219
739
|
return (
|
|
220
740
|
<div className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
|
221
741
|
<DataPanel title="Coverage" result={coverage.data}>
|
|
222
|
-
{(data) => <
|
|
742
|
+
{(data) => <StructuredView value={data} />}
|
|
223
743
|
</DataPanel>
|
|
224
744
|
<DataPanel title="Recent ingestion provenance" result={provenance.data}>
|
|
225
745
|
{(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" limit={14} />}
|
|
@@ -233,25 +753,31 @@ function PortabilityPanel() {
|
|
|
233
753
|
const [artifact, setArtifact] = React.useState("");
|
|
234
754
|
const port = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
|
|
235
755
|
const importMutation = useMutation({
|
|
236
|
-
mutationFn: () =>
|
|
756
|
+
mutationFn: async () => {
|
|
757
|
+
try {
|
|
758
|
+
return await latticeApi.graphImport(JSON.parse(artifact), true);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
return { ok: false, status: 0, data: {}, source: "unavailable" as const, error: err instanceof Error ? err.message : String(err) };
|
|
761
|
+
}
|
|
762
|
+
},
|
|
237
763
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["portability"] }),
|
|
238
764
|
});
|
|
239
765
|
return (
|
|
240
766
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
241
767
|
<DataPanel title="Portability status" result={port.data}>
|
|
242
|
-
{(data) => <
|
|
768
|
+
{(data) => <PortabilityStatus data={data as Record<string, unknown>} />}
|
|
243
769
|
</DataPanel>
|
|
244
770
|
<Card>
|
|
245
771
|
<CardHeader>
|
|
246
772
|
<CardTitle className="flex items-center gap-2"><DatabaseBackup className="h-4 w-4" /> Export, backup, import</CardTitle>
|
|
247
|
-
<CardDescription>Every control calls a real portability endpoint. Import is dry-run by default from pasted
|
|
773
|
+
<CardDescription>Every control calls a real portability endpoint. Import is dry-run by default from a pasted export artifact.</CardDescription>
|
|
248
774
|
</CardHeader>
|
|
249
775
|
<CardContent className="space-y-3">
|
|
250
776
|
<div className="flex flex-wrap gap-2">
|
|
251
|
-
<ActionButton label="Export graph
|
|
777
|
+
<ActionButton label="Export graph artifact" action={() => latticeApi.graphExport()} />
|
|
252
778
|
<ActionButton label="Create backup" action={() => latticeApi.graphBackup()} />
|
|
253
779
|
</div>
|
|
254
|
-
<Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an
|
|
780
|
+
<Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an exported graph artifact for dry-run import" />
|
|
255
781
|
<Button
|
|
256
782
|
variant="outline"
|
|
257
783
|
disabled={!artifact.trim() || importMutation.isPending}
|
|
@@ -259,9 +785,25 @@ function PortabilityPanel() {
|
|
|
259
785
|
>
|
|
260
786
|
Dry-run import
|
|
261
787
|
</Button>
|
|
262
|
-
{importMutation.data ? <
|
|
788
|
+
{importMutation.data ? <OperationResult result={importMutation.data} successLabel="Dry run completed" /> : null}
|
|
263
789
|
</CardContent>
|
|
264
790
|
</Card>
|
|
265
791
|
</div>
|
|
266
792
|
);
|
|
267
793
|
}
|
|
794
|
+
|
|
795
|
+
function PortabilityStatus({ data }: { data: Record<string, unknown> }) {
|
|
796
|
+
const stats = isRecord(data.stats) ? data.stats : {};
|
|
797
|
+
const storage = isRecord(data.storage) ? data.storage : {};
|
|
798
|
+
return (
|
|
799
|
+
<div className="space-y-3">
|
|
800
|
+
<StatGrid stats={[
|
|
801
|
+
{ label: "Schema", value: data.graph_schema_version || data.schema_version || "reported" },
|
|
802
|
+
{ label: "Nodes", value: (stats.total_nodes as number) || Object.values((stats.nodes as Record<string, unknown>) || {}).reduce((sum: number, value) => sum + Number(value || 0), 0) },
|
|
803
|
+
{ label: "Edges", value: (stats.total_edges as number) || Object.values((stats.edges as Record<string, unknown>) || {}).reduce((sum: number, value) => sum + Number(value || 0), 0) },
|
|
804
|
+
{ label: "Storage", value: storage.engine || "reported" },
|
|
805
|
+
]} />
|
|
806
|
+
<StructuredView value={data} />
|
|
807
|
+
</div>
|
|
808
|
+
);
|
|
809
|
+
}
|