ltcai 4.0.1 → 4.2.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 (192) hide show
  1. package/README.md +33 -24
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +84 -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_2_BRAIN_CORE_ARCHITECTURE.md +97 -0
  8. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +91 -0
  9. package/docs/V4_2_VALIDATION_REPORT.md +89 -0
  10. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +31 -26
  11. package/frontend/index.html +24 -0
  12. package/frontend/openapi.json +14436 -0
  13. package/frontend/src/App.tsx +184 -0
  14. package/frontend/src/api/client.ts +320 -0
  15. package/frontend/src/api/openapi.ts +16921 -0
  16. package/frontend/src/components/primitives.tsx +204 -0
  17. package/frontend/src/components/ui/badge.tsx +27 -0
  18. package/frontend/src/components/ui/button.tsx +37 -0
  19. package/frontend/src/components/ui/card.tsx +22 -0
  20. package/frontend/src/components/ui/input.tsx +16 -0
  21. package/frontend/src/components/ui/textarea.tsx +16 -0
  22. package/frontend/src/lib/utils.ts +33 -0
  23. package/frontend/src/main.tsx +23 -0
  24. package/frontend/src/pages/Act.tsx +245 -0
  25. package/frontend/src/pages/Ask.tsx +200 -0
  26. package/frontend/src/pages/Brain.tsx +267 -0
  27. package/frontend/src/pages/Capture.tsx +158 -0
  28. package/frontend/src/pages/Library.tsx +187 -0
  29. package/frontend/src/pages/System.tsx +378 -0
  30. package/frontend/src/routes.ts +85 -0
  31. package/frontend/src/store/appStore.ts +54 -0
  32. package/frontend/src/styles.css +107 -0
  33. package/kg_schema.py +1 -1
  34. package/knowledge_graph.py +4 -4
  35. package/lattice_brain/__init__.py +70 -0
  36. package/lattice_brain/_kg_common.py +1 -0
  37. package/lattice_brain/archive.py +133 -0
  38. package/lattice_brain/context.py +3 -0
  39. package/lattice_brain/conversations.py +3 -0
  40. package/lattice_brain/core.py +82 -0
  41. package/lattice_brain/discovery.py +1 -0
  42. package/lattice_brain/documents.py +1 -0
  43. package/lattice_brain/embeddings.py +82 -0
  44. package/lattice_brain/identity.py +13 -0
  45. package/lattice_brain/ingest.py +1 -0
  46. package/lattice_brain/memory.py +3 -0
  47. package/lattice_brain/network.py +1 -0
  48. package/lattice_brain/projection.py +1 -0
  49. package/lattice_brain/provenance.py +1 -0
  50. package/lattice_brain/retrieval.py +1 -0
  51. package/lattice_brain/schema.py +1 -0
  52. package/lattice_brain/storage/__init__.py +22 -0
  53. package/lattice_brain/storage/base.py +72 -0
  54. package/lattice_brain/storage/docker.py +105 -0
  55. package/lattice_brain/storage/factory.py +31 -0
  56. package/lattice_brain/storage/migration.py +190 -0
  57. package/lattice_brain/storage/postgres.py +123 -0
  58. package/lattice_brain/storage/sqlite.py +128 -0
  59. package/lattice_brain/store.py +3 -0
  60. package/lattice_brain/write_master.py +1 -0
  61. package/latticeai/__init__.py +1 -1
  62. package/latticeai/api/portability.py +69 -0
  63. package/latticeai/api/setup.py +5 -4
  64. package/latticeai/api/static_routes.py +4 -4
  65. package/latticeai/app_factory.py +17 -10
  66. package/latticeai/brain/__init__.py +6 -6
  67. package/latticeai/brain/_kg_common.py +1 -1
  68. package/latticeai/brain/network.py +1 -1
  69. package/latticeai/brain/retrieval.py +15 -0
  70. package/latticeai/brain/store.py +22 -6
  71. package/latticeai/core/config.py +8 -0
  72. package/latticeai/core/marketplace.py +1 -1
  73. package/latticeai/core/multi_agent.py +1 -1
  74. package/latticeai/core/workspace_os.py +1 -1
  75. package/latticeai/services/kg_portability.py +82 -1
  76. package/package.json +55 -15
  77. package/scripts/build_frontend_assets.mjs +38 -0
  78. package/scripts/bump_version.py +4 -1
  79. package/scripts/export_openapi.py +31 -0
  80. package/scripts/lint_frontend.mjs +91 -0
  81. package/scripts/migrate_brain_storage.py +53 -0
  82. package/scripts/run_python.mjs +47 -0
  83. package/scripts/wheel_smoke.py +3 -0
  84. package/src-tauri/Cargo.lock +4833 -0
  85. package/src-tauri/Cargo.toml +19 -0
  86. package/src-tauri/build.rs +3 -0
  87. package/src-tauri/capabilities/default.json +7 -0
  88. package/src-tauri/src/main.rs +78 -0
  89. package/src-tauri/tauri.conf.json +39 -0
  90. package/static/app/asset-manifest.json +32 -0
  91. package/static/app/assets/core-CwxXejkd.js +2 -0
  92. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  93. package/static/app/assets/index-CDjiH_se.css +2 -0
  94. package/static/app/assets/index-C_HAkbAg.js +333 -0
  95. package/static/app/assets/index-C_HAkbAg.js.map +1 -0
  96. package/static/app/index.html +25 -0
  97. package/static/manifest.json +2 -2
  98. package/static/sw.js +4 -4
  99. package/scripts/build_v3_assets.mjs +0 -170
  100. package/scripts/lint_v3.mjs +0 -120
  101. package/static/v3/asset-manifest.json +0 -63
  102. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  103. package/static/v3/css/lattice.base.css +0 -128
  104. package/static/v3/css/lattice.components.cde18231.css +0 -472
  105. package/static/v3/css/lattice.components.css +0 -472
  106. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  107. package/static/v3/css/lattice.shell.css +0 -452
  108. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  109. package/static/v3/css/lattice.tokens.css +0 -135
  110. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  111. package/static/v3/css/lattice.views.css +0 -360
  112. package/static/v3/index.html +0 -68
  113. package/static/v3/js/app.c5c80c46.js +0 -26
  114. package/static/v3/js/app.js +0 -26
  115. package/static/v3/js/core/api.ba0fbf14.js +0 -625
  116. package/static/v3/js/core/api.js +0 -625
  117. package/static/v3/js/core/components.f25b3b93.js +0 -230
  118. package/static/v3/js/core/components.js +0 -230
  119. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  120. package/static/v3/js/core/dom.js +0 -148
  121. package/static/v3/js/core/i18n.880e1fec.js +0 -575
  122. package/static/v3/js/core/i18n.js +0 -575
  123. package/static/v3/js/core/router.584570f2.js +0 -37
  124. package/static/v3/js/core/router.js +0 -37
  125. package/static/v3/js/core/routes.37522821.js +0 -101
  126. package/static/v3/js/core/routes.js +0 -101
  127. package/static/v3/js/core/shell.e3f6bbfa.js +0 -420
  128. package/static/v3/js/core/shell.js +0 -420
  129. package/static/v3/js/core/store.7b2aa044.js +0 -123
  130. package/static/v3/js/core/store.js +0 -123
  131. package/static/v3/js/views/account.eff40715.js +0 -143
  132. package/static/v3/js/views/account.js +0 -143
  133. package/static/v3/js/views/activity.0d271ef9.js +0 -67
  134. package/static/v3/js/views/activity.js +0 -67
  135. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  136. package/static/v3/js/views/admin-audit.js +0 -185
  137. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  138. package/static/v3/js/views/admin-permissions.js +0 -177
  139. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  140. package/static/v3/js/views/admin-policies.js +0 -102
  141. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  142. package/static/v3/js/views/admin-private-vpc.js +0 -135
  143. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  144. package/static/v3/js/views/admin-security.js +0 -180
  145. package/static/v3/js/views/admin-users.f7ac7b43.js +0 -166
  146. package/static/v3/js/views/admin-users.js +0 -166
  147. package/static/v3/js/views/agents.17c5288d.js +0 -564
  148. package/static/v3/js/views/agents.js +0 -564
  149. package/static/v3/js/views/chat.e250e2cc.js +0 -624
  150. package/static/v3/js/views/chat.js +0 -624
  151. package/static/v3/js/views/files.adad14c1.js +0 -365
  152. package/static/v3/js/views/files.js +0 -365
  153. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  154. package/static/v3/js/views/graph-canvas.js +0 -509
  155. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  156. package/static/v3/js/views/home.js +0 -200
  157. package/static/v3/js/views/hooks.37895880.js +0 -220
  158. package/static/v3/js/views/hooks.js +0 -220
  159. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  160. package/static/v3/js/views/hybrid-search.js +0 -194
  161. package/static/v3/js/views/knowledge-graph.4d09c537.js +0 -529
  162. package/static/v3/js/views/knowledge-graph.js +0 -529
  163. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  164. package/static/v3/js/views/marketplace.js +0 -141
  165. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  166. package/static/v3/js/views/mcp.js +0 -114
  167. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  168. package/static/v3/js/views/memory.js +0 -147
  169. package/static/v3/js/views/models.a1ffa147.js +0 -256
  170. package/static/v3/js/views/models.js +0 -256
  171. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  172. package/static/v3/js/views/my-computer.js +0 -463
  173. package/static/v3/js/views/network.52a4f181.js +0 -97
  174. package/static/v3/js/views/network.js +0 -97
  175. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  176. package/static/v3/js/views/pipeline.js +0 -157
  177. package/static/v3/js/views/planning.4876fd77.js +0 -174
  178. package/static/v3/js/views/planning.js +0 -174
  179. package/static/v3/js/views/runs.b63b2afa.js +0 -144
  180. package/static/v3/js/views/runs.js +0 -144
  181. package/static/v3/js/views/settings.b7140634.js +0 -317
  182. package/static/v3/js/views/settings.js +0 -317
  183. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  184. package/static/v3/js/views/skills.js +0 -109
  185. package/static/v3/js/views/snapshots.6f5db095.js +0 -135
  186. package/static/v3/js/views/snapshots.js +0 -135
  187. package/static/v3/js/views/tools.e4f11276.js +0 -108
  188. package/static/v3/js/views/tools.js +0 -108
  189. package/static/v3/js/views/workflows.7752225a.js +0 -213
  190. package/static/v3/js/views/workflows.js +0 -213
  191. package/static/v3/js/views/workspace-admin.c466029b.js +0 -156
  192. 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
+ }