ltcai 4.0.1 → 4.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 (150) hide show
  1. package/README.md +28 -23
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +42 -0
  4. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  5. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  6. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +26 -19
  8. package/frontend/index.html +24 -0
  9. package/frontend/openapi.json +14190 -0
  10. package/frontend/src/App.tsx +184 -0
  11. package/frontend/src/api/client.ts +317 -0
  12. package/frontend/src/api/openapi.ts +16637 -0
  13. package/frontend/src/components/primitives.tsx +204 -0
  14. package/frontend/src/components/ui/badge.tsx +27 -0
  15. package/frontend/src/components/ui/button.tsx +37 -0
  16. package/frontend/src/components/ui/card.tsx +22 -0
  17. package/frontend/src/components/ui/input.tsx +16 -0
  18. package/frontend/src/components/ui/textarea.tsx +16 -0
  19. package/frontend/src/lib/utils.ts +33 -0
  20. package/frontend/src/main.tsx +23 -0
  21. package/frontend/src/pages/Act.tsx +245 -0
  22. package/frontend/src/pages/Ask.tsx +200 -0
  23. package/frontend/src/pages/Brain.tsx +267 -0
  24. package/frontend/src/pages/Capture.tsx +158 -0
  25. package/frontend/src/pages/Library.tsx +187 -0
  26. package/frontend/src/pages/System.tsx +344 -0
  27. package/frontend/src/routes.ts +85 -0
  28. package/frontend/src/store/appStore.ts +54 -0
  29. package/frontend/src/styles.css +107 -0
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/setup.py +5 -4
  32. package/latticeai/api/static_routes.py +4 -4
  33. package/latticeai/core/marketplace.py +1 -1
  34. package/latticeai/core/multi_agent.py +1 -1
  35. package/latticeai/core/workspace_os.py +1 -1
  36. package/package.json +54 -15
  37. package/scripts/build_frontend_assets.mjs +38 -0
  38. package/scripts/bump_version.py +1 -1
  39. package/scripts/export_openapi.py +31 -0
  40. package/scripts/lint_frontend.mjs +86 -0
  41. package/scripts/run_python.mjs +47 -0
  42. package/src-tauri/Cargo.lock +4833 -0
  43. package/src-tauri/Cargo.toml +19 -0
  44. package/src-tauri/build.rs +3 -0
  45. package/src-tauri/capabilities/default.json +7 -0
  46. package/src-tauri/src/main.rs +78 -0
  47. package/src-tauri/tauri.conf.json +36 -0
  48. package/static/app/asset-manifest.json +32 -0
  49. package/static/app/assets/core-CwxXejkd.js +2 -0
  50. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  51. package/static/app/assets/index-CJRAzNnf.js +333 -0
  52. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  53. package/static/app/assets/index-CSwBBgf4.css +2 -0
  54. package/static/app/index.html +25 -0
  55. package/static/manifest.json +2 -2
  56. package/static/sw.js +4 -4
  57. package/scripts/build_v3_assets.mjs +0 -170
  58. package/scripts/lint_v3.mjs +0 -120
  59. package/static/v3/asset-manifest.json +0 -63
  60. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  61. package/static/v3/css/lattice.base.css +0 -128
  62. package/static/v3/css/lattice.components.cde18231.css +0 -472
  63. package/static/v3/css/lattice.components.css +0 -472
  64. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  65. package/static/v3/css/lattice.shell.css +0 -452
  66. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  67. package/static/v3/css/lattice.tokens.css +0 -135
  68. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  69. package/static/v3/css/lattice.views.css +0 -360
  70. package/static/v3/index.html +0 -68
  71. package/static/v3/js/app.c5c80c46.js +0 -26
  72. package/static/v3/js/app.js +0 -26
  73. package/static/v3/js/core/api.ba0fbf14.js +0 -625
  74. package/static/v3/js/core/api.js +0 -625
  75. package/static/v3/js/core/components.f25b3b93.js +0 -230
  76. package/static/v3/js/core/components.js +0 -230
  77. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  78. package/static/v3/js/core/dom.js +0 -148
  79. package/static/v3/js/core/i18n.880e1fec.js +0 -575
  80. package/static/v3/js/core/i18n.js +0 -575
  81. package/static/v3/js/core/router.584570f2.js +0 -37
  82. package/static/v3/js/core/router.js +0 -37
  83. package/static/v3/js/core/routes.37522821.js +0 -101
  84. package/static/v3/js/core/routes.js +0 -101
  85. package/static/v3/js/core/shell.e3f6bbfa.js +0 -420
  86. package/static/v3/js/core/shell.js +0 -420
  87. package/static/v3/js/core/store.7b2aa044.js +0 -123
  88. package/static/v3/js/core/store.js +0 -123
  89. package/static/v3/js/views/account.eff40715.js +0 -143
  90. package/static/v3/js/views/account.js +0 -143
  91. package/static/v3/js/views/activity.0d271ef9.js +0 -67
  92. package/static/v3/js/views/activity.js +0 -67
  93. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  94. package/static/v3/js/views/admin-audit.js +0 -185
  95. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  96. package/static/v3/js/views/admin-permissions.js +0 -177
  97. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  98. package/static/v3/js/views/admin-policies.js +0 -102
  99. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  100. package/static/v3/js/views/admin-private-vpc.js +0 -135
  101. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  102. package/static/v3/js/views/admin-security.js +0 -180
  103. package/static/v3/js/views/admin-users.f7ac7b43.js +0 -166
  104. package/static/v3/js/views/admin-users.js +0 -166
  105. package/static/v3/js/views/agents.17c5288d.js +0 -564
  106. package/static/v3/js/views/agents.js +0 -564
  107. package/static/v3/js/views/chat.e250e2cc.js +0 -624
  108. package/static/v3/js/views/chat.js +0 -624
  109. package/static/v3/js/views/files.adad14c1.js +0 -365
  110. package/static/v3/js/views/files.js +0 -365
  111. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  112. package/static/v3/js/views/graph-canvas.js +0 -509
  113. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  114. package/static/v3/js/views/home.js +0 -200
  115. package/static/v3/js/views/hooks.37895880.js +0 -220
  116. package/static/v3/js/views/hooks.js +0 -220
  117. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  118. package/static/v3/js/views/hybrid-search.js +0 -194
  119. package/static/v3/js/views/knowledge-graph.4d09c537.js +0 -529
  120. package/static/v3/js/views/knowledge-graph.js +0 -529
  121. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  122. package/static/v3/js/views/marketplace.js +0 -141
  123. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  124. package/static/v3/js/views/mcp.js +0 -114
  125. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  126. package/static/v3/js/views/memory.js +0 -147
  127. package/static/v3/js/views/models.a1ffa147.js +0 -256
  128. package/static/v3/js/views/models.js +0 -256
  129. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  130. package/static/v3/js/views/my-computer.js +0 -463
  131. package/static/v3/js/views/network.52a4f181.js +0 -97
  132. package/static/v3/js/views/network.js +0 -97
  133. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  134. package/static/v3/js/views/pipeline.js +0 -157
  135. package/static/v3/js/views/planning.4876fd77.js +0 -174
  136. package/static/v3/js/views/planning.js +0 -174
  137. package/static/v3/js/views/runs.b63b2afa.js +0 -144
  138. package/static/v3/js/views/runs.js +0 -144
  139. package/static/v3/js/views/settings.b7140634.js +0 -317
  140. package/static/v3/js/views/settings.js +0 -317
  141. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  142. package/static/v3/js/views/skills.js +0 -109
  143. package/static/v3/js/views/snapshots.6f5db095.js +0 -135
  144. package/static/v3/js/views/snapshots.js +0 -135
  145. package/static/v3/js/views/tools.e4f11276.js +0 -108
  146. package/static/v3/js/views/tools.js +0 -108
  147. package/static/v3/js/views/workflows.7752225a.js +0 -213
  148. package/static/v3/js/views/workflows.js +0 -213
  149. package/static/v3/js/views/workspace-admin.c466029b.js +0 -156
  150. package/static/v3/js/views/workspace-admin.js +0 -156
@@ -0,0 +1,200 @@
1
+ import * as React from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { ImagePlus, MessageSquare, Send, Trash2 } from "lucide-react";
4
+ import { latticeApi } from "@/api/client";
5
+ import { DataPanel, EmptyState, EntityList, JsonView, SourceBadge } from "@/components/primitives";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
9
+ import { Textarea } from "@/components/ui/textarea";
10
+ import { asArray } from "@/lib/utils";
11
+
12
+ type Msg = { role?: string; content?: string; timestamp?: string };
13
+
14
+ function fileToDataUrl(file: File) {
15
+ return new Promise<string>((resolve, reject) => {
16
+ const reader = new FileReader();
17
+ reader.onload = () => resolve(String(reader.result || ""));
18
+ reader.onerror = () => reject(reader.error);
19
+ reader.readAsDataURL(file);
20
+ });
21
+ }
22
+
23
+ export function AskPage() {
24
+ const qc = useQueryClient();
25
+ const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
26
+ const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
27
+ const [conversationId, setConversationId] = React.useState<string | null>(null);
28
+ const conversation = useQuery({
29
+ queryKey: ["conversation", conversationId],
30
+ queryFn: () => latticeApi.conversation(conversationId || ""),
31
+ enabled: !!conversationId,
32
+ });
33
+ const [messages, setMessages] = React.useState<Msg[]>([]);
34
+ const [draft, setDraft] = React.useState("");
35
+ const [imageData, setImageData] = React.useState<string | null>(null);
36
+ const [trace, setTrace] = React.useState<unknown>(null);
37
+ const [streaming, setStreaming] = React.useState(false);
38
+
39
+ React.useEffect(() => {
40
+ if (conversation.data?.ok) setMessages(asArray<Msg>((conversation.data.data as Record<string, unknown>).messages || conversation.data.data));
41
+ }, [conversation.data]);
42
+
43
+ const send = async () => {
44
+ const message = draft.trim();
45
+ if (!message || streaming) return;
46
+ setDraft("");
47
+ setMessages((items) => [...items, { role: "user", content: message }, { role: "assistant", content: "" }]);
48
+ setStreaming(true);
49
+ try {
50
+ const result = await latticeApi.streamChat(
51
+ { message, conversation_id: conversationId || undefined, image_data: imageData || undefined },
52
+ {
53
+ onChunk: (_delta, fullText) => {
54
+ setMessages((items) => {
55
+ const next = [...items];
56
+ const last = next[next.length - 1] || { role: "assistant" };
57
+ next[next.length - 1] = { ...last, role: "assistant", content: fullText };
58
+ return next;
59
+ });
60
+ },
61
+ onTrace: setTrace,
62
+ },
63
+ );
64
+ if (result.error) {
65
+ setMessages((items) => {
66
+ const next = [...items];
67
+ next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
68
+ return next;
69
+ });
70
+ }
71
+ } finally {
72
+ setStreaming(false);
73
+ setImageData(null);
74
+ await qc.invalidateQueries({ queryKey: ["chatHistory"] });
75
+ }
76
+ };
77
+
78
+ const deleteMutation = useMutation({
79
+ mutationFn: (id: string) => latticeApi.deleteConversation(id),
80
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["chatHistory"] }),
81
+ });
82
+
83
+ return (
84
+ <div className="grid min-h-[calc(100vh-7rem)] gap-4 xl:grid-cols-[18rem_minmax(0,1fr)_22rem]">
85
+ <Card className="overflow-hidden">
86
+ <CardHeader>
87
+ <CardTitle className="flex items-center gap-2"><MessageSquare className="h-4 w-4" /> Conversations</CardTitle>
88
+ <CardDescription>Durable history from the backend conversation store.</CardDescription>
89
+ </CardHeader>
90
+ <CardContent className="space-y-2">
91
+ <SourceBadge result={history.data} />
92
+ {asArray<Record<string, unknown>>(history.data?.data).length ? asArray<Record<string, unknown>>(history.data?.data).map((item) => (
93
+ <button
94
+ key={String(item.id)}
95
+ onClick={() => setConversationId(String(item.id))}
96
+ className="block w-full rounded-md border border-border bg-background p-3 text-left text-sm transition hover:bg-muted"
97
+ >
98
+ <div className="font-medium">{String(item.title || item.id)}</div>
99
+ <div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
100
+ <span>{String(item.updated_at || "")}</span>
101
+ <Trash2
102
+ className="h-3.5 w-3.5"
103
+ onClick={(e) => {
104
+ e.stopPropagation();
105
+ deleteMutation.mutate(String(item.id));
106
+ }}
107
+ />
108
+ </div>
109
+ </button>
110
+ )) : <EmptyState title="No conversations" detail="Start a new exchange or sign in to load history." />}
111
+ </CardContent>
112
+ </Card>
113
+
114
+ <section className="flex min-h-[40rem] flex-col rounded-lg border border-border bg-card">
115
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border p-4">
116
+ <div>
117
+ <h1 className="text-xl font-semibold">Ask</h1>
118
+ <p className="text-sm text-muted-foreground">Streams through `/chat`; no local answer is fabricated when the model is unavailable.</p>
119
+ </div>
120
+ <div className="flex flex-wrap items-center gap-2">
121
+ <Badge variant="muted">{String((models.data?.data as Record<string, unknown>)?.current || "no model loaded")}</Badge>
122
+ <SourceBadge result={models.data} />
123
+ </div>
124
+ </div>
125
+ <div className="scrollbar-thin flex-1 space-y-3 overflow-auto p-4">
126
+ {messages.length ? messages.map((msg, index) => (
127
+ <div key={index} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
128
+ <div className={`max-w-[78%] rounded-lg border p-3 text-sm ${msg.role === "user" ? "border-primary/30 bg-primary/15" : "border-border bg-background"}`}>
129
+ <div className="mb-1 text-xs uppercase text-muted-foreground">{msg.role || "message"}</div>
130
+ <div className="whitespace-pre-wrap">{msg.content}</div>
131
+ </div>
132
+ </div>
133
+ )) : <EmptyState title="Ready" detail="Ask a question once the backend and model are available." />}
134
+ </div>
135
+ <div className="border-t border-border p-4">
136
+ {imageData ? <Badge variant="success" className="mb-2">image attached</Badge> : null}
137
+ <Textarea
138
+ value={draft}
139
+ onChange={(e) => setDraft(e.target.value)}
140
+ onKeyDown={(e) => {
141
+ if (e.key === "Enter" && !e.shiftKey) {
142
+ e.preventDefault();
143
+ void send();
144
+ }
145
+ }}
146
+ placeholder="Ask the brain..."
147
+ />
148
+ <div className="mt-2 flex flex-wrap items-center justify-between gap-2">
149
+ <label className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted">
150
+ <ImagePlus className="h-4 w-4" />
151
+ Attach image
152
+ <input
153
+ type="file"
154
+ accept="image/*"
155
+ className="sr-only"
156
+ onChange={async (e) => {
157
+ const file = e.target.files?.[0];
158
+ if (file) setImageData(await fileToDataUrl(file));
159
+ }}
160
+ />
161
+ </label>
162
+ <Button disabled={!draft.trim() || streaming} onClick={() => void send()}>
163
+ <Send className="h-4 w-4" /> Send
164
+ </Button>
165
+ </div>
166
+ </div>
167
+ </section>
168
+
169
+ <aside className="space-y-4">
170
+ <ContextPreview question={draft || [...messages].reverse().find((m: Msg) => m.role === "user")?.content || ""} trace={trace} />
171
+ </aside>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function ContextPreview({ question, trace }: { question: string; trace: unknown }) {
177
+ const hybrid = useQuery({
178
+ queryKey: ["askHybrid", question],
179
+ queryFn: () => latticeApi.hybridSearch(question),
180
+ enabled: question.trim().length > 2,
181
+ });
182
+ const graph = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
183
+ return (
184
+ <>
185
+ <DataPanel title="Retrieval preview" result={hybrid.data}>
186
+ {(data) => <EntityList items={(data as Record<string, unknown>).matches || data} titleKey="title" metaKey="type" limit={5} />}
187
+ </DataPanel>
188
+ <DataPanel title="Graph context" result={graph.data}>
189
+ {(data) => <EntityList items={(data as Record<string, unknown>).nodes} titleKey="title" metaKey="type" limit={5} />}
190
+ </DataPanel>
191
+ <Card>
192
+ <CardHeader>
193
+ <CardTitle>Why this context</CardTitle>
194
+ <CardDescription>Trace emitted by `/chat` when the backend includes it.</CardDescription>
195
+ </CardHeader>
196
+ <CardContent>{trace ? <JsonView value={trace} /> : <EmptyState title="No trace yet" />}</CardContent>
197
+ </Card>
198
+ </>
199
+ );
200
+ }
@@ -0,0 +1,267 @@
1
+ import * as React from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import cytoscape, { Core } from "cytoscape";
4
+ import { BrainCircuit, DatabaseBackup, Network, Search, Sparkles } from "lucide-react";
5
+ import { latticeApi } from "@/api/client";
6
+ import { ActionButton, DataPanel, EntityList, JsonView, LoadingPanel, StatGrid, Tabs } from "@/components/primitives";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Textarea } from "@/components/ui/textarea";
12
+ import { asArray, pct } from "@/lib/utils";
13
+
14
+ type BrainTab = "overview" | "graph" | "search" | "memory" | "provenance" | "portability";
15
+
16
+ const tabs: Array<{ id: BrainTab; label: string }> = [
17
+ { id: "overview", label: "Overview" },
18
+ { id: "graph", label: "Graph" },
19
+ { id: "search", label: "Search" },
20
+ { id: "memory", label: "Memory" },
21
+ { id: "provenance", label: "Provenance" },
22
+ { id: "portability", label: "Portability" },
23
+ ];
24
+
25
+ function graphElements(data: unknown) {
26
+ const graph = data as { nodes?: Array<Record<string, unknown>>; edges?: Array<Record<string, unknown>> };
27
+ const nodes = asArray<Record<string, unknown>>(graph.nodes).slice(0, 160).map((node) => ({
28
+ data: {
29
+ id: String(node.id || node.node_id || node.title),
30
+ label: String(node.title || node.label || node.id || "Node"),
31
+ type: String(node.type || "Node"),
32
+ },
33
+ }));
34
+ const nodeIds = new Set(nodes.map((node) => node.data.id));
35
+ const edges = asArray<Record<string, unknown>>(graph.edges).slice(0, 260).flatMap((edge, index) => {
36
+ const source = String(edge.from || edge.source || edge.source_id || "");
37
+ const target = String(edge.to || edge.target || edge.target_id || "");
38
+ if (!nodeIds.has(source) || !nodeIds.has(target)) return [];
39
+ return [{
40
+ data: {
41
+ id: String(edge.id || `edge-${index}`),
42
+ source,
43
+ target,
44
+ label: String(edge.type || edge.label || ""),
45
+ },
46
+ }];
47
+ });
48
+ return [...nodes, ...edges];
49
+ }
50
+
51
+ function CytoscapeGraph({ data }: { data: unknown }) {
52
+ const hostRef = React.useRef<HTMLDivElement | null>(null);
53
+ const cyRef = React.useRef<Core | null>(null);
54
+ React.useEffect(() => {
55
+ if (!hostRef.current) return;
56
+ const elements = graphElements(data);
57
+ cyRef.current?.destroy();
58
+ cyRef.current = cytoscape({
59
+ container: hostRef.current,
60
+ elements,
61
+ style: [
62
+ {
63
+ selector: "node",
64
+ style: {
65
+ "background-color": "#21c7bd",
66
+ "border-color": "#88fff5",
67
+ "border-width": 1,
68
+ color: "#f7ffff",
69
+ label: "data(label)",
70
+ "font-size": 9,
71
+ "text-outline-color": "#071012",
72
+ "text-outline-width": 2,
73
+ width: 22,
74
+ height: 22,
75
+ },
76
+ },
77
+ {
78
+ selector: "edge",
79
+ style: {
80
+ width: 1.2,
81
+ "line-color": "#6b7893",
82
+ "target-arrow-shape": "triangle",
83
+ "target-arrow-color": "#6b7893",
84
+ "curve-style": "bezier",
85
+ },
86
+ },
87
+ ],
88
+ layout: { name: "cose", animate: false, idealEdgeLength: 110, nodeRepulsion: 4500 },
89
+ wheelSensitivity: 0.25,
90
+ });
91
+ return () => cyRef.current?.destroy();
92
+ }, [data]);
93
+ return <div ref={hostRef} data-testid="brain-cytoscape" className="h-[520px] w-full overflow-hidden rounded-lg border border-border bg-background brain-grid" />;
94
+ }
95
+
96
+ export function BrainPage({ initialTab }: { initialTab?: string }) {
97
+ const [tab, setTab] = React.useState<BrainTab>((initialTab as BrainTab) || "graph");
98
+ React.useEffect(() => {
99
+ if (initialTab && tabs.some((item) => item.id === initialTab)) setTab(initialTab as BrainTab);
100
+ }, [initialTab]);
101
+ const graph = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
102
+ const stats = useQuery({ queryKey: ["graphStats"], queryFn: latticeApi.graphStats });
103
+ const index = useQuery({ queryKey: ["index"], queryFn: latticeApi.indexStatus });
104
+ const coverage = useQuery({ queryKey: ["coverage"], queryFn: latticeApi.graphCoverage });
105
+ const provenance = useQuery({ queryKey: ["provenance"], queryFn: () => latticeApi.graphProvenance(50) });
106
+ const memory = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
107
+
108
+ return (
109
+ <div className="space-y-4">
110
+ <header className="grid gap-4 xl:grid-cols-[1.4fr_0.6fr]">
111
+ <div>
112
+ <div className="flex items-center gap-2 text-sm text-primary"><BrainCircuit className="h-4 w-4" /> Graph-first Digital Brain</div>
113
+ <h1 className="mt-2 text-3xl font-semibold tracking-normal">Brain</h1>
114
+ <p className="mt-2 max-w-3xl text-sm text-muted-foreground">
115
+ The visible knowledge substrate: graph, memory, provenance, retrieval, and local portability. Empty states come from API availability, not canned data.
116
+ </p>
117
+ </div>
118
+ <div className="rounded-lg border border-border bg-card p-4">
119
+ <div className="text-xs uppercase text-muted-foreground">Provenance coverage</div>
120
+ <div className="mt-2 text-3xl font-semibold">{pct((coverage.data?.data as Record<string, unknown>)?.coverage_ratio)}</div>
121
+ <div className="mt-2 text-sm text-muted-foreground">Source: {coverage.data?.source || "loading"}</div>
122
+ </div>
123
+ </header>
124
+ <Tabs tabs={tabs} value={tab} onChange={(id) => setTab(id as BrainTab)} />
125
+
126
+ {tab === "overview" ? (
127
+ <div className="grid gap-4 xl:grid-cols-2">
128
+ <DataPanel title="Brain status" result={stats.data}>
129
+ {(data) => <StatGrid stats={[
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
+ ]} />}
135
+ </DataPanel>
136
+ <DataPanel title="Retrieval index" result={index.data}>
137
+ {(data) => <JsonView value={data} />}
138
+ </DataPanel>
139
+ <DataPanel title="Memory tiers" result={memory.data}>
140
+ {(data) => <EntityList items={(data as Record<string, unknown>).tiers || (data as Record<string, unknown>).sources} titleKey="name" metaKey="health" />}
141
+ </DataPanel>
142
+ <DataPanel title="Recent provenance" result={provenance.data}>
143
+ {(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" />}
144
+ </DataPanel>
145
+ </div>
146
+ ) : null}
147
+
148
+ {tab === "graph" ? (
149
+ graph.isLoading ? <LoadingPanel title="Knowledge graph" /> : (
150
+ <DataPanel title="Knowledge graph" description="Cytoscape.js explorer backed by /knowledge-graph/graph." result={graph.data}>
151
+ {(data) => <CytoscapeGraph data={data} />}
152
+ </DataPanel>
153
+ )
154
+ ) : null}
155
+
156
+ {tab === "search" ? <HybridSearch /> : null}
157
+ {tab === "memory" ? <MemoryPanel /> : null}
158
+ {tab === "provenance" ? <ProvenancePanel /> : null}
159
+ {tab === "portability" ? <PortabilityPanel /> : null}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function HybridSearch() {
165
+ const [query, setQuery] = React.useState("lattice brain");
166
+ const search = useMutation({ mutationFn: () => latticeApi.hybridSearch(query) });
167
+ return (
168
+ <Card>
169
+ <CardHeader>
170
+ <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 per-result source scores when returned.</CardDescription>
172
+ </CardHeader>
173
+ <CardContent className="space-y-3">
174
+ <div className="flex flex-col gap-2 sm:flex-row">
175
+ <Input placeholder="lattice brain" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search.mutate()} />
176
+ <Button onClick={() => search.mutate()} disabled={!query.trim() || search.isPending}>Search</Button>
177
+ </div>
178
+ {search.data ? (
179
+ <DataPanel title="Results" result={search.data}>
180
+ {(data) => <EntityList items={(data as Record<string, unknown>).matches || data} titleKey="title" metaKey="type" limit={12} />}
181
+ </DataPanel>
182
+ ) : null}
183
+ </CardContent>
184
+ </Card>
185
+ );
186
+ }
187
+
188
+ function MemoryPanel() {
189
+ const [query, setQuery] = React.useState("");
190
+ const manager = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
191
+ const recall = useMutation({ mutationFn: () => latticeApi.memoryRecall(query, 25) });
192
+ return (
193
+ <div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
194
+ <DataPanel title="Memory manager" result={manager.data}>
195
+ {(data) => <JsonView value={data} />}
196
+ </DataPanel>
197
+ <Card>
198
+ <CardHeader>
199
+ <CardTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> Recall</CardTitle>
200
+ <CardDescription>Searches the real memory recall endpoint.</CardDescription>
201
+ </CardHeader>
202
+ <CardContent className="space-y-3">
203
+ <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Recall memories about..." />
204
+ <div className="flex gap-2">
205
+ <Button disabled={!query.trim() || recall.isPending} onClick={() => recall.mutate()}>Recall</Button>
206
+ <ActionButton label="Compact" action={() => latticeApi.memoryCompact()} />
207
+ <ActionButton label="Rebuild vector" action={() => latticeApi.memoryRebuild()} />
208
+ </div>
209
+ {recall.data ? <JsonView value={recall.data.data} /> : null}
210
+ </CardContent>
211
+ </Card>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function ProvenancePanel() {
217
+ const provenance = useQuery({ queryKey: ["provenance"], queryFn: () => latticeApi.graphProvenance(80) });
218
+ const coverage = useQuery({ queryKey: ["coverage"], queryFn: latticeApi.graphCoverage });
219
+ return (
220
+ <div className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
221
+ <DataPanel title="Coverage" result={coverage.data}>
222
+ {(data) => <JsonView value={data} />}
223
+ </DataPanel>
224
+ <DataPanel title="Recent ingestion provenance" result={provenance.data}>
225
+ {(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" limit={14} />}
226
+ </DataPanel>
227
+ </div>
228
+ );
229
+ }
230
+
231
+ function PortabilityPanel() {
232
+ const qc = useQueryClient();
233
+ const [artifact, setArtifact] = React.useState("");
234
+ const port = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
235
+ const importMutation = useMutation({
236
+ mutationFn: () => latticeApi.graphImport(JSON.parse(artifact), true),
237
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["portability"] }),
238
+ });
239
+ return (
240
+ <div className="grid gap-4 xl:grid-cols-2">
241
+ <DataPanel title="Portability status" result={port.data}>
242
+ {(data) => <JsonView value={data} />}
243
+ </DataPanel>
244
+ <Card>
245
+ <CardHeader>
246
+ <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 JSON.</CardDescription>
248
+ </CardHeader>
249
+ <CardContent className="space-y-3">
250
+ <div className="flex flex-wrap gap-2">
251
+ <ActionButton label="Export graph JSON" action={() => latticeApi.graphExport()} />
252
+ <ActionButton label="Create backup" action={() => latticeApi.graphBackup()} />
253
+ </div>
254
+ <Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an export artifact JSON for dry-run import" />
255
+ <Button
256
+ variant="outline"
257
+ disabled={!artifact.trim() || importMutation.isPending}
258
+ onClick={() => importMutation.mutate()}
259
+ >
260
+ Dry-run import
261
+ </Button>
262
+ {importMutation.data ? <JsonView value={importMutation.data.data} /> : null}
263
+ </CardContent>
264
+ </Card>
265
+ </div>
266
+ );
267
+ }
@@ -0,0 +1,158 @@
1
+ import * as React from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { FolderPlus, Globe2, HardDrive, Upload } from "lucide-react";
4
+ import { latticeApi } from "@/api/client";
5
+ import { ActionButton, DataPanel, EntityList, JsonView, Tabs } from "@/components/primitives";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { Input } from "@/components/ui/input";
9
+ import { asArray } from "@/lib/utils";
10
+
11
+ type CaptureTab = "files" | "local" | "browser" | "pipeline";
12
+
13
+ const tabs: Array<{ id: CaptureTab; label: string }> = [
14
+ { id: "files", label: "Files" },
15
+ { id: "local", label: "Local folders" },
16
+ { id: "browser", label: "Web capture" },
17
+ { id: "pipeline", label: "Pipeline" },
18
+ ];
19
+
20
+ export function CapturePage({ initialTab }: { initialTab?: string }) {
21
+ const [tab, setTab] = React.useState<CaptureTab>((initialTab as CaptureTab) || "files");
22
+ React.useEffect(() => {
23
+ if (initialTab === "pipeline" || initialTab === "local" || initialTab === "files") setTab(initialTab);
24
+ }, [initialTab]);
25
+ return (
26
+ <div className="space-y-4">
27
+ <header>
28
+ <div className="flex items-center gap-2 text-sm text-primary"><Upload className="h-4 w-4" /> One ingestion door</div>
29
+ <h1 className="mt-2 text-3xl font-semibold">Capture</h1>
30
+ <p className="mt-2 max-w-3xl text-sm text-muted-foreground">Documents, folders, and URLs enter the brain through existing ingestion endpoints with provenance.</p>
31
+ </header>
32
+ <Tabs tabs={tabs} value={tab} onChange={(id) => setTab(id as CaptureTab)} />
33
+ {tab === "files" ? <FilesPanel /> : null}
34
+ {tab === "local" ? <LocalPanel /> : null}
35
+ {tab === "browser" ? <BrowserPanel /> : null}
36
+ {tab === "pipeline" ? <PipelinePanel /> : null}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function FilesPanel() {
42
+ const qc = useQueryClient();
43
+ const docs = useQuery({ queryKey: ["documents"], queryFn: () => latticeApi.documents(200) });
44
+ const upload = useMutation({
45
+ mutationFn: (files: FileList) => Promise.all(Array.from(files).map((file) => latticeApi.uploadDocument(file))),
46
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
47
+ });
48
+ return (
49
+ <div className="grid gap-4 xl:grid-cols-[0.75fr_1.25fr]">
50
+ <Card>
51
+ <CardHeader>
52
+ <CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" /> Upload documents</CardTitle>
53
+ <CardDescription>Multipart upload to `/upload/document`; accepted files are parsed and indexed by the backend.</CardDescription>
54
+ </CardHeader>
55
+ <CardContent>
56
+ <label className="flex min-h-44 cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/30 p-5 text-center">
57
+ <Upload className="h-7 w-7 text-primary" />
58
+ <span className="font-medium">Choose files</span>
59
+ <span className="text-sm text-muted-foreground">PDF, DOCX, XLSX, PPTX, TXT, MD, CSV according to backend policy.</span>
60
+ <input type="file" multiple className="sr-only" onChange={(e) => e.target.files && upload.mutate(e.target.files)} />
61
+ </label>
62
+ {upload.data ? <JsonView value={upload.data.map((item) => item.data)} /> : null}
63
+ </CardContent>
64
+ </Card>
65
+ <DataPanel title="Uploaded documents" result={docs.data}>
66
+ {(data) => <EntityList items={(data as Record<string, unknown>).documents || data} titleKey="filename" metaKey="ingest_state" limit={12} />}
67
+ </DataPanel>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function LocalPanel() {
73
+ const qc = useQueryClient();
74
+ const [path, setPath] = React.useState("");
75
+ const local = useQuery({ queryKey: ["localSources"], queryFn: latticeApi.localSources });
76
+ const agent = useQuery({ queryKey: ["localAgent"], queryFn: latticeApi.localAgent });
77
+ const connect = useMutation({
78
+ mutationFn: () => latticeApi.connectFolder(path),
79
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["localSources"] }),
80
+ });
81
+ return (
82
+ <div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
83
+ <Card>
84
+ <CardHeader>
85
+ <CardTitle className="flex items-center gap-2"><FolderPlus className="h-4 w-4" /> Connect folder</CardTitle>
86
+ <CardDescription>The click is explicit consent; the backend still enforces its permission workflow.</CardDescription>
87
+ </CardHeader>
88
+ <CardContent className="space-y-3">
89
+ <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="/Users/me/Documents/project" />
90
+ <Button disabled={!path.trim() || connect.isPending} onClick={() => connect.mutate()}>Connect and watch</Button>
91
+ {connect.data ? <JsonView value={connect.data.data || connect.data.error} /> : null}
92
+ </CardContent>
93
+ </Card>
94
+ <DataPanel title="Connected sources" result={local.data}>
95
+ {(data) => (
96
+ <div className="space-y-3">
97
+ <EntityList items={(data as Record<string, unknown>).sources} titleKey="path" metaKey="status" />
98
+ {asArray<Record<string, unknown>>((data as Record<string, unknown>).sources).map((source) => (
99
+ <ActionButton
100
+ key={String(source.id || source.source_id || source.path)}
101
+ label={`Stop ${String(source.path || source.id || "source")}`}
102
+ action={() => latticeApi.localWatchStop(String(source.id || source.source_id))}
103
+ invalidate={["localSources"]}
104
+ />
105
+ ))}
106
+ </div>
107
+ )}
108
+ </DataPanel>
109
+ <DataPanel title="Local runtime probe" result={agent.data} className="xl:col-span-2">
110
+ {(data) => <JsonView value={data} />}
111
+ </DataPanel>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function BrowserPanel() {
117
+ const [url, setUrl] = React.useState("");
118
+ const read = useMutation({ mutationFn: () => latticeApi.browserReadUrl(url) });
119
+ return (
120
+ <Card>
121
+ <CardHeader>
122
+ <CardTitle className="flex items-center gap-2"><Globe2 className="h-4 w-4" /> URL capture</CardTitle>
123
+ <CardDescription>Fetches a URL locally through `/api/browser/read-url` and ingests the content with provenance.</CardDescription>
124
+ </CardHeader>
125
+ <CardContent className="space-y-3">
126
+ <div className="flex flex-col gap-2 sm:flex-row">
127
+ <Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/article" />
128
+ <Button disabled={!url.trim() || read.isPending} onClick={() => read.mutate()}>Capture URL</Button>
129
+ </div>
130
+ {read.data ? <JsonView value={read.data.data || read.data.error} /> : null}
131
+ </CardContent>
132
+ </Card>
133
+ );
134
+ }
135
+
136
+ function PipelinePanel() {
137
+ const index = useQuery({ queryKey: ["index"], queryFn: latticeApi.indexStatus });
138
+ const stats = useQuery({ queryKey: ["graphStats"], queryFn: latticeApi.graphStats });
139
+ return (
140
+ <div className="grid gap-4 xl:grid-cols-2">
141
+ <DataPanel title="Index pipeline" result={index.data}>
142
+ {(data) => <JsonView value={data} />}
143
+ </DataPanel>
144
+ <DataPanel title="Graph totals" result={stats.data}>
145
+ {(data) => <JsonView value={data} />}
146
+ </DataPanel>
147
+ <Card className="xl:col-span-2">
148
+ <CardHeader>
149
+ <CardTitle className="flex items-center gap-2"><HardDrive className="h-4 w-4" /> Rebuild controls</CardTitle>
150
+ <CardDescription>Rebuild calls the existing index endpoint. No background work is implied unless the API accepts it.</CardDescription>
151
+ </CardHeader>
152
+ <CardContent>
153
+ <ActionButton label="Rebuild retrieval index" action={() => latticeApi.rebuildIndex()} invalidate={["index"]} />
154
+ </CardContent>
155
+ </Card>
156
+ </div>
157
+ );
158
+ }