ltcai 4.0.0 → 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 (195) hide show
  1. package/README.md +42 -33
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +106 -0
  4. package/docs/REALTIME_COLLABORATION.md +3 -3
  5. package/docs/V3_FRONTEND.md +9 -8
  6. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  7. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  8. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  9. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
  10. package/docs/kg-schema.md +6 -2
  11. package/docs/spec-vs-impl.md +10 -10
  12. package/frontend/index.html +24 -0
  13. package/frontend/openapi.json +14190 -0
  14. package/frontend/src/App.tsx +184 -0
  15. package/frontend/src/api/client.ts +317 -0
  16. package/frontend/src/api/openapi.ts +16637 -0
  17. package/frontend/src/components/primitives.tsx +204 -0
  18. package/frontend/src/components/ui/badge.tsx +27 -0
  19. package/frontend/src/components/ui/button.tsx +37 -0
  20. package/frontend/src/components/ui/card.tsx +22 -0
  21. package/frontend/src/components/ui/input.tsx +16 -0
  22. package/frontend/src/components/ui/textarea.tsx +16 -0
  23. package/frontend/src/lib/utils.ts +33 -0
  24. package/frontend/src/main.tsx +23 -0
  25. package/frontend/src/pages/Act.tsx +245 -0
  26. package/frontend/src/pages/Ask.tsx +200 -0
  27. package/frontend/src/pages/Brain.tsx +267 -0
  28. package/frontend/src/pages/Capture.tsx +158 -0
  29. package/frontend/src/pages/Library.tsx +187 -0
  30. package/frontend/src/pages/System.tsx +344 -0
  31. package/frontend/src/routes.ts +85 -0
  32. package/frontend/src/store/appStore.ts +54 -0
  33. package/frontend/src/styles.css +107 -0
  34. package/kg_schema.py +2 -603
  35. package/knowledge_graph.py +37 -4958
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +15 -16
  38. package/latticeai/api/agents.py +13 -6
  39. package/latticeai/api/auth.py +19 -11
  40. package/latticeai/api/invitations.py +100 -0
  41. package/latticeai/api/knowledge_graph.py +4 -11
  42. package/latticeai/api/plugins.py +3 -6
  43. package/latticeai/api/realtime.py +4 -7
  44. package/latticeai/api/setup.py +5 -4
  45. package/latticeai/api/static_routes.py +13 -16
  46. package/latticeai/api/ui_redirects.py +26 -0
  47. package/latticeai/api/workflow_designer.py +39 -6
  48. package/latticeai/api/workspace.py +24 -10
  49. package/latticeai/app_factory.py +88 -17
  50. package/latticeai/brain/_kg_common.py +1123 -0
  51. package/latticeai/brain/discovery.py +1455 -0
  52. package/latticeai/brain/documents.py +218 -0
  53. package/latticeai/brain/ingest.py +644 -0
  54. package/latticeai/brain/projection.py +561 -0
  55. package/latticeai/brain/provenance.py +401 -0
  56. package/latticeai/brain/retrieval.py +1316 -0
  57. package/latticeai/brain/schema.py +640 -0
  58. package/latticeai/brain/store.py +216 -0
  59. package/latticeai/brain/write_master.py +225 -0
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/multi_agent.py +1 -1
  63. package/latticeai/core/policy.py +54 -0
  64. package/latticeai/core/realtime.py +65 -44
  65. package/latticeai/core/sessions.py +31 -5
  66. package/latticeai/core/users.py +147 -0
  67. package/latticeai/core/workspace_os.py +420 -20
  68. package/latticeai/services/agent_runtime.py +242 -4
  69. package/latticeai/services/run_executor.py +328 -0
  70. package/latticeai/services/workspace_service.py +27 -19
  71. package/package.json +54 -27
  72. package/scripts/build_frontend_assets.mjs +38 -0
  73. package/scripts/bump_version.py +1 -1
  74. package/scripts/export_openapi.py +31 -0
  75. package/scripts/lint_frontend.mjs +86 -0
  76. package/scripts/run_python.mjs +47 -0
  77. package/src-tauri/Cargo.lock +4833 -0
  78. package/src-tauri/Cargo.toml +19 -0
  79. package/src-tauri/build.rs +3 -0
  80. package/src-tauri/capabilities/default.json +7 -0
  81. package/src-tauri/src/main.rs +78 -0
  82. package/src-tauri/tauri.conf.json +36 -0
  83. package/static/app/asset-manifest.json +32 -0
  84. package/static/app/assets/core-CwxXejkd.js +2 -0
  85. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  86. package/static/app/assets/index-CJRAzNnf.js +333 -0
  87. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  88. package/static/app/assets/index-CSwBBgf4.css +2 -0
  89. package/static/app/index.html +25 -0
  90. package/static/manifest.json +2 -2
  91. package/static/sw.js +4 -4
  92. package/scripts/build_v3_assets.mjs +0 -170
  93. package/scripts/lint_v3.mjs +0 -97
  94. package/static/account.html +0 -113
  95. package/static/activity.html +0 -73
  96. package/static/admin.html +0 -486
  97. package/static/agents.html +0 -139
  98. package/static/chat.html +0 -841
  99. package/static/css/reference/account.css +0 -439
  100. package/static/css/reference/admin.css +0 -610
  101. package/static/css/reference/base.css +0 -1661
  102. package/static/css/reference/chat.css +0 -4623
  103. package/static/css/reference/graph.css +0 -1016
  104. package/static/css/responsive.css +0 -861
  105. package/static/graph.html +0 -122
  106. package/static/platform.css +0 -104
  107. package/static/plugins.html +0 -136
  108. package/static/scripts/account.js +0 -238
  109. package/static/scripts/admin.js +0 -1614
  110. package/static/scripts/chat.js +0 -5081
  111. package/static/scripts/graph.js +0 -1804
  112. package/static/scripts/platform.js +0 -64
  113. package/static/scripts/ux.js +0 -167
  114. package/static/scripts/workspace.js +0 -948
  115. package/static/v3/asset-manifest.json +0 -56
  116. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  117. package/static/v3/css/lattice.base.css +0 -128
  118. package/static/v3/css/lattice.components.cde18231.css +0 -472
  119. package/static/v3/css/lattice.components.css +0 -472
  120. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  121. package/static/v3/css/lattice.shell.css +0 -452
  122. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  123. package/static/v3/css/lattice.tokens.css +0 -135
  124. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  125. package/static/v3/css/lattice.views.css +0 -360
  126. package/static/v3/index.html +0 -68
  127. package/static/v3/js/app.356e6452.js +0 -26
  128. package/static/v3/js/app.js +0 -26
  129. package/static/v3/js/core/api.7a308b89.js +0 -568
  130. package/static/v3/js/core/api.js +0 -568
  131. package/static/v3/js/core/components.f25b3b93.js +0 -230
  132. package/static/v3/js/core/components.js +0 -230
  133. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  134. package/static/v3/js/core/dom.js +0 -148
  135. package/static/v3/js/core/router.584570f2.js +0 -37
  136. package/static/v3/js/core/router.js +0 -37
  137. package/static/v3/js/core/routes.7222343d.js +0 -93
  138. package/static/v3/js/core/routes.js +0 -93
  139. package/static/v3/js/core/shell.a1657f20.js +0 -391
  140. package/static/v3/js/core/shell.js +0 -391
  141. package/static/v3/js/core/store.204a08b2.js +0 -113
  142. package/static/v3/js/core/store.js +0 -113
  143. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  144. package/static/v3/js/views/admin-audit.js +0 -185
  145. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  146. package/static/v3/js/views/admin-permissions.js +0 -177
  147. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  148. package/static/v3/js/views/admin-policies.js +0 -102
  149. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  150. package/static/v3/js/views/admin-private-vpc.js +0 -135
  151. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  152. package/static/v3/js/views/admin-security.js +0 -180
  153. package/static/v3/js/views/admin-users.03bac88c.js +0 -168
  154. package/static/v3/js/views/admin-users.js +0 -168
  155. package/static/v3/js/views/agents.014d0b74.js +0 -541
  156. package/static/v3/js/views/agents.js +0 -541
  157. package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
  158. package/static/v3/js/views/chat.js +0 -601
  159. package/static/v3/js/views/files.adad14c1.js +0 -365
  160. package/static/v3/js/views/files.js +0 -365
  161. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  162. package/static/v3/js/views/graph-canvas.js +0 -509
  163. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  164. package/static/v3/js/views/home.js +0 -200
  165. package/static/v3/js/views/hooks.37895880.js +0 -220
  166. package/static/v3/js/views/hooks.js +0 -220
  167. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  168. package/static/v3/js/views/hybrid-search.js +0 -194
  169. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
  170. package/static/v3/js/views/knowledge-graph.js +0 -509
  171. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  172. package/static/v3/js/views/marketplace.js +0 -141
  173. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  174. package/static/v3/js/views/mcp.js +0 -114
  175. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  176. package/static/v3/js/views/memory.js +0 -147
  177. package/static/v3/js/views/models.a1ffa147.js +0 -256
  178. package/static/v3/js/views/models.js +0 -256
  179. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  180. package/static/v3/js/views/my-computer.js +0 -463
  181. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  182. package/static/v3/js/views/pipeline.js +0 -157
  183. package/static/v3/js/views/planning.9ac3e313.js +0 -153
  184. package/static/v3/js/views/planning.js +0 -153
  185. package/static/v3/js/views/settings.8631fa5e.js +0 -318
  186. package/static/v3/js/views/settings.js +0 -318
  187. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  188. package/static/v3/js/views/skills.js +0 -109
  189. package/static/v3/js/views/tools.e4f11276.js +0 -108
  190. package/static/v3/js/views/tools.js +0 -108
  191. package/static/v3/js/views/workflows.26c57290.js +0 -128
  192. package/static/v3/js/views/workflows.js +0 -128
  193. package/static/workflows.html +0 -146
  194. package/static/workspace.css +0 -1121
  195. package/static/workspace.html +0 -357
@@ -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
+ }