ltcai 4.6.0 → 4.6.1

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.
@@ -1,220 +1,704 @@
1
1
  import * as React from "react";
2
- import { useQuery } from "@tanstack/react-query";
3
- import { BrainCircuit, Command, Menu, Moon, Search, Sun, X } from "lucide-react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { ImagePlus, Search, Send } from "lucide-react";
4
4
  import { latticeApi } from "@/api/client";
5
5
  import { Button } from "@/components/ui/button";
6
- import { Input } from "@/components/ui/input";
6
+ import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
7
7
  import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
8
8
  import { useAppStore } from "@/store/appStore";
9
- import { commandRoutes, go, parseHash, primaryRoutes, PrimaryRoute } from "@/routes";
10
- import { BrainPage } from "@/pages/Brain";
11
- import { CapturePage } from "@/pages/Capture";
12
- import { ActPage } from "@/pages/Act";
13
- import { LibraryPage } from "@/pages/Library";
14
- import { SystemPage } from "@/pages/System";
15
- import { cn } from "@/lib/utils";
16
-
17
- function useRoute() {
18
- const [route, setRoute] = React.useState(parseHash);
9
+ import { asArray } from "@/lib/utils";
10
+
11
+ type ApiRecord = Record<string, unknown>;
12
+ type BrainDepth = 1 | 2 | 3 | 4 | 5;
13
+
14
+ type Message = {
15
+ role: "user" | "assistant";
16
+ content: string;
17
+ };
18
+
19
+ type MemoryFragment = {
20
+ id: string;
21
+ title: string;
22
+ kind: string;
23
+ };
24
+
25
+ type KnowledgeConcept = {
26
+ id: string;
27
+ label: string;
28
+ type: string;
29
+ summary: string;
30
+ importance: number;
31
+ };
32
+
33
+ type RelationshipThread = {
34
+ id: string;
35
+ source: string;
36
+ target: string;
37
+ label: string;
38
+ weight: number;
39
+ };
40
+
41
+ type KnowledgeGraphModel = {
42
+ nodes: KnowledgeConcept[];
43
+ edges: RelationshipThread[];
44
+ };
45
+
46
+ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
47
+ { level: 1, label: "Living Brain", state: "idle" },
48
+ { level: 2, label: "Memory Layer", state: "recalling" },
49
+ { level: 3, label: "Knowledge Layer", state: "synthesizing" },
50
+ { level: 4, label: "Relationship Layer", state: "planning" },
51
+ { level: 5, label: "Knowledge Graph", state: "synthesizing" },
52
+ ];
53
+
54
+ export default function App() {
55
+ const theme = useAppStore((state) => state.theme);
56
+ const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
57
+ const { state: brainState, intensity, setBrain } = useBrainState();
58
+
59
+ React.useEffect(() => {
60
+ document.documentElement.dataset.theme = theme;
61
+ }, [theme]);
62
+
19
63
  React.useEffect(() => {
20
- const onHash = () => setRoute(parseHash());
21
- window.addEventListener("hashchange", onHash);
22
- return () => window.removeEventListener("hashchange", onHash);
64
+ const onKey = (event: KeyboardEvent) => {
65
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
66
+ event.preventDefault();
67
+ document.querySelector<HTMLTextAreaElement>(".brain-composer textarea")?.focus();
68
+ }
69
+ };
70
+ window.addEventListener("keydown", onKey);
71
+ return () => window.removeEventListener("keydown", onKey);
23
72
  }, []);
24
- return route;
25
- }
26
73
 
27
- function Page({ primary, tab }: { primary: PrimaryRoute; tab?: string }) {
28
- if (primary === "memory") return <BrainPage initialTab="memory" />;
29
- if (primary === "capture") return <CapturePage initialTab={tab} />;
30
- if (primary === "act") return <ActPage initialTab={tab} />;
31
- if (primary === "library") return <LibraryPage initialTab={tab} />;
32
- if (primary === "system") return <SystemPage initialTab={tab} />;
33
- return <BrainPage initialTab={tab} />;
34
- }
74
+ if (!flowComplete) {
75
+ return <ProductFlow onComplete={() => setFlowComplete(true)} />;
76
+ }
35
77
 
36
- function AmbientBrain() {
37
78
  return (
38
- <div className="ambient-brain" aria-hidden="true">
39
- <span className="signal-line signal-line-a" />
40
- <span className="signal-line signal-line-b" />
41
- <span className="signal-line signal-line-c" />
42
- <span className="signal-tile signal-tile-a" />
43
- <span className="signal-tile signal-tile-b" />
44
- <span className="signal-tile signal-tile-c" />
79
+ <div className="brain-space">
80
+ <div className="brain-field" />
81
+ <BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
45
82
  </div>
46
83
  );
47
84
  }
48
85
 
49
- function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
50
- const [query, setQuery] = React.useState("");
51
- const normalized = query.trim().toLowerCase();
52
- const matches = commandRoutes.filter((route) => (
53
- route.label.toLowerCase().includes(normalized) || route.key.includes(normalized)
54
- ));
86
+ function useBrainState() {
87
+ const [state, setState] = React.useState<BrainState>("idle");
88
+ const [intensity, setIntensity] = React.useState(0.58);
89
+
90
+ const setBrain = React.useCallback((next: BrainState, nextIntensity?: number) => {
91
+ setState(next);
92
+ if (nextIntensity !== undefined) setIntensity(clamp(nextIntensity, 0.38, 1));
93
+ }, []);
94
+
95
+ return { state, intensity, setBrain };
96
+ }
97
+
98
+ function BrainHome({
99
+ brainState,
100
+ intensity,
101
+ onBrainChange,
102
+ }: {
103
+ brainState: BrainState;
104
+ intensity: number;
105
+ onBrainChange: (state: BrainState, intensity?: number) => void;
106
+ }) {
107
+ const qc = useQueryClient();
108
+ const [messages, setMessages] = React.useState<Message[]>([]);
109
+ const [draft, setDraft] = React.useState("");
110
+ const [imageData, setImageData] = React.useState<string | null>(null);
111
+ const [streaming, setStreaming] = React.useState(false);
112
+ const [conversationId, setConversationId] = React.useState<string | null>(null);
113
+ const [explorationDepth, setExplorationDepth] = React.useState<BrainDepth>(1);
114
+ const [graphSearch, setGraphSearch] = React.useState("");
115
+ const [selectedGraphId, setSelectedGraphId] = React.useState<string | null>(null);
116
+ const streamRef = React.useRef<HTMLDivElement>(null);
117
+ const recallTimerRef = React.useRef<number | null>(null);
118
+
119
+ const memoriesQ = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
120
+ const historyQ = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
121
+ const graphQ = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
122
+ const modelsQ = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
123
+
124
+ const memoryFragments = React.useMemo(
125
+ () => buildMemoryFragments(memoriesQ.data?.data, historyQ.data?.data),
126
+ [memoriesQ.data, historyQ.data],
127
+ );
128
+ const graphModel = React.useMemo(() => parseKnowledgeGraph(graphQ.data?.data), [graphQ.data]);
129
+ const knowledgeConcepts = React.useMemo(
130
+ () => graphModel.nodes.slice(0, 10),
131
+ [graphModel.nodes],
132
+ );
133
+ const relationshipThreads = React.useMemo(
134
+ () => graphModel.edges.slice(0, 10),
135
+ [graphModel.edges],
136
+ );
137
+ const modelName = React.useMemo(() => currentModelName(modelsQ.data?.data), [modelsQ.data]);
138
+ const currentDepth = DEPTHS[explorationDepth - 1];
139
+
140
+ React.useEffect(() => {
141
+ if (streaming) onBrainChange("thinking", 0.94);
142
+ else if (draft.trim().length > 4) onBrainChange("listening", 0.76);
143
+ else onBrainChange(currentDepth.state, explorationDepth === 1 ? 0.58 : 0.66 + explorationDepth * 0.06);
144
+ }, [streaming, draft, currentDepth.state, explorationDepth, onBrainChange]);
55
145
 
56
146
  React.useEffect(() => {
57
- if (!open) return;
58
- const onKey = (event: KeyboardEvent) => {
59
- if (event.key === "Escape") onClose();
147
+ const stream = streamRef.current;
148
+ if (stream) stream.scrollTop = stream.scrollHeight;
149
+ }, [messages]);
150
+
151
+ React.useEffect(() => {
152
+ return () => {
153
+ if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
60
154
  };
61
- window.addEventListener("keydown", onKey);
62
- return () => window.removeEventListener("keydown", onKey);
63
- }, [open, onClose]);
155
+ }, []);
156
+
157
+ async function send() {
158
+ const text = draft.trim();
159
+ if (!text || streaming) return;
160
+ const activeConversationId = conversationId || `brain-${Date.now()}`;
161
+ if (!conversationId) setConversationId(activeConversationId);
162
+
163
+ setMessages((items) => [...items, { role: "user", content: text }, { role: "assistant", content: "" }]);
164
+ setDraft("");
165
+ setImageData(null);
166
+ setStreaming(true);
167
+ onBrainChange("thinking", 0.96);
168
+
169
+ try {
170
+ const result = await latticeApi.streamChat(
171
+ { message: text, conversation_id: activeConversationId, image_data: imageData || undefined },
172
+ {
173
+ onChunk: (_delta, fullText) => {
174
+ setMessages((items) => {
175
+ const next = [...items];
176
+ next[next.length - 1] = { role: "assistant", content: fullText };
177
+ return next;
178
+ });
179
+ },
180
+ onTrace: (trace) => {
181
+ if (!trace) return;
182
+ onBrainChange("recalling", 0.9);
183
+ triggerBrainRecall();
184
+ if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
185
+ recallTimerRef.current = window.setTimeout(() => onBrainChange("thinking", 0.9), 900);
186
+ },
187
+ },
188
+ );
189
+ if (result.error) {
190
+ setMessages((items) => {
191
+ const next = [...items];
192
+ next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
193
+ return next;
194
+ });
195
+ }
196
+ } finally {
197
+ setStreaming(false);
198
+ void qc.invalidateQueries({ queryKey: ["chatHistory"] });
199
+ void qc.invalidateQueries({ queryKey: ["memoryManager"] });
200
+ void qc.invalidateQueries({ queryKey: ["graph"] });
201
+ }
202
+ }
203
+
204
+ function deepen() {
205
+ setExplorationDepth((depth) => {
206
+ const next = Math.min(5, depth + 1) as BrainDepth;
207
+ const nextDepth = DEPTHS[next - 1];
208
+ onBrainChange(nextDepth.state, 0.66 + next * 0.06);
209
+ if (next >= 2) triggerBrainRecall();
210
+ return next;
211
+ });
212
+ }
213
+
214
+ function surface() {
215
+ setExplorationDepth(1);
216
+ setSelectedGraphId(null);
217
+ setGraphSearch("");
218
+ onBrainChange("idle", 0.58);
219
+ }
220
+
221
+ function recallMemory(fragment: MemoryFragment) {
222
+ triggerBrainRecall();
223
+ setExplorationDepth((depth) => Math.max(depth, 2) as BrainDepth);
224
+ setMessages((items) => [
225
+ ...items,
226
+ { role: "assistant", content: `I am recalling ${fragment.kind.toLowerCase()}: ${fragment.title}` },
227
+ ]);
228
+ }
64
229
 
65
- if (!open) return null;
66
230
  return (
67
- <div className="command-scrim" role="dialog" aria-modal="true" aria-label="Lattice command palette">
68
- <div className="command-panel">
69
- <div className="command-search">
70
- <Search className="h-4 w-4 text-muted-foreground" />
71
- <Input value={query} onChange={(event) => setQuery(event.target.value)} autoFocus placeholder="Jump to anything in Lattice" />
72
- <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close command palette"><X className="h-4 w-4" /></Button>
231
+ <main className="brain-home" aria-label="Lattice Brain">
232
+ <section className="brain-presence" aria-label="Brain exploration">
233
+ <div className="brain-exploration" data-depth={explorationDepth}>
234
+ <LivingBrain
235
+ state={brainState}
236
+ intensity={intensity + explorationDepth * 0.035}
237
+ size="large"
238
+ depth={explorationDepth}
239
+ showLabel={false}
240
+ onInteract={deepen}
241
+ />
242
+
243
+ <div className="brain-depth-badge" aria-live="polite">
244
+ <span>Level {explorationDepth}</span>
245
+ <strong>{currentDepth.label}</strong>
246
+ </div>
247
+
248
+ <div className="brain-field-layer" aria-hidden={explorationDepth < 2}>
249
+ <DepthEmergence
250
+ depth={explorationDepth}
251
+ memories={memoryFragments}
252
+ concepts={knowledgeConcepts}
253
+ relationships={relationshipThreads}
254
+ graphModel={graphModel}
255
+ graphSearch={graphSearch}
256
+ selectedGraphId={selectedGraphId}
257
+ onGraphSearch={setGraphSearch}
258
+ onSelectGraphNode={setSelectedGraphId}
259
+ onRecallMemory={recallMemory}
260
+ />
261
+ </div>
262
+
263
+ {explorationDepth > 1 ? (
264
+ <button className="brain-surface-control" type="button" onClick={surface}>
265
+ Surface
266
+ </button>
267
+ ) : null}
73
268
  </div>
74
- <div className="command-list soft-scrollbar">
75
- {matches.map((route) => {
76
- const Icon = route.icon;
77
- return (
78
- <button
79
- key={route.key}
80
- onClick={() => {
81
- go(route.key);
82
- onClose();
269
+ </section>
270
+
271
+ <section className="brain-conversation" aria-label="Conversation">
272
+ <div className="brain-conversation-header">
273
+ <div>
274
+ <h1>Lattice Brain</h1>
275
+ <span>{currentDepth.label}</span>
276
+ </div>
277
+ <div>{modelName}</div>
278
+ </div>
279
+
280
+ <div ref={streamRef} className="brain-stream">
281
+ {messages.length === 0 ? (
282
+ <div className="mind-empty">
283
+ <div className="mind-empty-kicker">Begin</div>
284
+ <div>What are you thinking about?</div>
285
+ </div>
286
+ ) : (
287
+ messages.map((message, index) => (
288
+ <div key={`${message.role}-${index}`} className={`brain-message ${message.role}`}>
289
+ <div className="brain-message-bubble">{message.content}</div>
290
+ </div>
291
+ ))
292
+ )}
293
+ </div>
294
+
295
+ <div className="brain-composer">
296
+ <textarea
297
+ value={draft}
298
+ onChange={(event) => setDraft(event.target.value)}
299
+ onKeyDown={(event) => {
300
+ if (event.key === "Enter" && !event.shiftKey) {
301
+ event.preventDefault();
302
+ void send();
303
+ }
304
+ }}
305
+ placeholder="Talk to your Brain..."
306
+ />
307
+ <div className="brain-composer-actions">
308
+ <label className="brain-image-input">
309
+ <ImagePlus className="h-3.5 w-3.5" />
310
+ <span>Image</span>
311
+ <input
312
+ type="file"
313
+ accept="image/*"
314
+ className="sr-only"
315
+ onChange={async (event) => {
316
+ const file = event.target.files?.[0];
317
+ if (file) setImageData(await fileToDataUrl(file));
83
318
  }}
84
- className="command-row"
85
- >
86
- <span className="command-icon"><Icon className="h-4 w-4" /></span>
87
- <span>
88
- <span className="block text-sm font-semibold">{route.label}</span>
89
- <span className="block text-xs text-muted-foreground">Open {route.key.replace(/[-/]/g, " ")}</span>
90
- </span>
91
- </button>
92
- );
93
- })}
319
+ />
320
+ </label>
321
+ {imageData ? <span className="brain-quiet-success">Image attached</span> : null}
322
+ <Button onClick={() => void send()} disabled={!draft.trim() || streaming} className="rounded-full px-5">
323
+ <Send className="h-4 w-4" /> Send
324
+ </Button>
325
+ </div>
94
326
  </div>
95
- </div>
96
- </div>
327
+ </section>
328
+ </main>
329
+ );
330
+ }
331
+
332
+ function DepthEmergence({
333
+ depth,
334
+ memories,
335
+ concepts,
336
+ relationships,
337
+ graphModel,
338
+ graphSearch,
339
+ selectedGraphId,
340
+ onGraphSearch,
341
+ onSelectGraphNode,
342
+ onRecallMemory,
343
+ }: {
344
+ depth: BrainDepth;
345
+ memories: MemoryFragment[];
346
+ concepts: KnowledgeConcept[];
347
+ relationships: RelationshipThread[];
348
+ graphModel: KnowledgeGraphModel;
349
+ graphSearch: string;
350
+ selectedGraphId: string | null;
351
+ onGraphSearch: (value: string) => void;
352
+ onSelectGraphNode: (id: string | null) => void;
353
+ onRecallMemory: (fragment: MemoryFragment) => void;
354
+ }) {
355
+ if (depth === 1) return null;
356
+
357
+ return (
358
+ <>
359
+ {depth >= 2 ? (
360
+ <MemoryLayer memories={memories} depth={depth} onRecallMemory={onRecallMemory} />
361
+ ) : null}
362
+ {depth >= 3 && depth < 5 ? (
363
+ <KnowledgeLayer concepts={concepts} depth={depth} />
364
+ ) : null}
365
+ {depth >= 4 && depth < 5 ? (
366
+ <RelationshipLayer concepts={concepts} relationships={relationships} />
367
+ ) : null}
368
+ {depth >= 5 ? (
369
+ <EmergentKnowledgeGraph
370
+ model={graphModel}
371
+ search={graphSearch}
372
+ selectedId={selectedGraphId}
373
+ onSearch={onGraphSearch}
374
+ onSelect={onSelectGraphNode}
375
+ />
376
+ ) : null}
377
+ </>
97
378
  );
98
379
  }
99
380
 
100
- function PrimaryDock({ active, onNavigate }: { active: PrimaryRoute; onNavigate?: () => void }) {
381
+ function MemoryLayer({
382
+ memories,
383
+ depth,
384
+ onRecallMemory,
385
+ }: {
386
+ memories: MemoryFragment[];
387
+ depth: BrainDepth;
388
+ onRecallMemory: (fragment: MemoryFragment) => void;
389
+ }) {
390
+ const visible = memories.slice(0, depth >= 3 ? 8 : 6);
391
+ if (!visible.length) return <div className="memory-fragment is-empty">Memory is quiet</div>;
392
+
101
393
  return (
102
- <nav className="primary-dock" aria-label="Primary navigation">
103
- {primaryRoutes.map((item) => {
104
- const Icon = item.icon;
105
- const selected = active === item.id;
394
+ <>
395
+ {visible.map((memory, index) => {
396
+ const point = polarPoint(index, visible.length, depth >= 3 ? 39 : 31, depth >= 3 ? 24 : 18, -112);
106
397
  return (
107
398
  <button
108
- key={item.id}
109
- className={cn("dock-button", selected && "is-active")}
110
- onClick={() => {
111
- go(item.id);
112
- onNavigate?.();
113
- }}
114
- aria-current={selected ? "page" : undefined}
399
+ key={memory.id}
400
+ type="button"
401
+ className="memory-fragment"
402
+ style={layerStyle({ "--x": `${point.x}%`, "--y": `${point.y}%`, "--delay": `${index * 55}ms` })}
403
+ onClick={() => onRecallMemory(memory)}
115
404
  >
116
- <Icon className="h-4 w-4" />
117
- <span>{item.label}</span>
405
+ <span>{memory.kind}</span>
406
+ <strong>{memory.title}</strong>
118
407
  </button>
119
408
  );
120
409
  })}
121
- </nav>
410
+ </>
122
411
  );
123
412
  }
124
413
 
125
- export default function App() {
126
- const route = useRoute();
127
- const { theme, setTheme } = useAppStore();
128
- const [drawer, setDrawer] = React.useState(false);
129
- const [palette, setPalette] = React.useState(false);
130
- const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
131
- const health = useQuery({ queryKey: ["health"], queryFn: latticeApi.health, enabled: flowComplete });
132
- const desktop = useQuery({
133
- queryKey: ["desktopBackendStatus"],
134
- queryFn: latticeApi.desktopBackendStatus,
135
- enabled: flowComplete && Boolean(window.__TAURI_INTERNALS__),
136
- refetchInterval: 5000,
137
- });
138
-
139
- React.useEffect(() => {
140
- document.documentElement.dataset.theme = theme;
141
- }, [theme]);
414
+ function KnowledgeLayer({ concepts, depth }: { concepts: KnowledgeConcept[]; depth: BrainDepth }) {
415
+ const visible = concepts.slice(0, depth >= 4 ? 10 : 7);
416
+ if (!visible.length) return <div className="concept-signal is-empty">Knowledge is forming</div>;
142
417
 
143
- React.useEffect(() => {
144
- const onKey = (event: KeyboardEvent) => {
145
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
146
- event.preventDefault();
147
- setPalette(true);
148
- }
149
- };
150
- window.addEventListener("keydown", onKey);
151
- return () => window.removeEventListener("keydown", onKey);
152
- }, []);
418
+ return (
419
+ <>
420
+ {visible.map((concept, index) => {
421
+ const point = polarPoint(index, visible.length, 24, 15, -70);
422
+ return (
423
+ <button
424
+ key={concept.id}
425
+ type="button"
426
+ className="concept-signal"
427
+ style={layerStyle({ "--x": `${point.x}%`, "--y": `${point.y}%`, "--delay": `${index * 45}ms` })}
428
+ title={concept.summary || concept.type}
429
+ >
430
+ <span>{concept.type}</span>
431
+ {concept.label}
432
+ </button>
433
+ );
434
+ })}
435
+ </>
436
+ );
437
+ }
153
438
 
154
- if (!flowComplete) {
155
- return <ProductFlow onComplete={() => {
156
- setFlowComplete(true);
157
- go("brain");
158
- }} />;
159
- }
439
+ function RelationshipLayer({
440
+ concepts,
441
+ relationships,
442
+ }: {
443
+ concepts: KnowledgeConcept[];
444
+ relationships: RelationshipThread[];
445
+ }) {
446
+ const visibleConcepts = concepts.slice(0, 10);
447
+ const layout = layoutGraphNodes(visibleConcepts, 30, 20);
448
+ const positionById = new Map(layout.map((item) => [item.node.id, item]));
449
+ const visibleRelationships = relationships
450
+ .map((relationship, index) => {
451
+ const source = positionById.get(relationship.source) || layout[index % Math.max(layout.length, 1)];
452
+ const target = positionById.get(relationship.target) || layout[(index + 3) % Math.max(layout.length, 1)];
453
+ return source && target && source.node.id !== target.node.id ? { relationship, source, target } : null;
454
+ })
455
+ .filter(Boolean)
456
+ .slice(0, 8) as Array<{
457
+ relationship: RelationshipThread;
458
+ source: ReturnType<typeof layoutGraphNodes>[number];
459
+ target: ReturnType<typeof layoutGraphNodes>[number];
460
+ }>;
160
461
 
161
- const healthData = (health.data?.data || {}) as Record<string, unknown>;
162
- const desktopData = (desktop.data?.data || {}) as Record<string, unknown>;
163
- const backendReady = Boolean(health.data?.ok);
164
- const desktopReady = !window.__TAURI_INTERNALS__ || Boolean(desktopData.running);
462
+ if (!visibleRelationships.length) return null;
165
463
 
166
464
  return (
167
- <div className="app-backdrop min-h-screen text-foreground">
168
- <AmbientBrain />
169
- <CommandPalette open={palette} onClose={() => setPalette(false)} />
170
-
171
- <header className="app-chrome">
172
- <div className="brand-lockup">
173
- <button className="mobile-menu" onClick={() => setDrawer(true)} aria-label="Open navigation"><Menu className="h-5 w-5" /></button>
174
- <button className="brand-mark" onClick={() => go("brain")} aria-label="Open Lattice Brain">
175
- <BrainCircuit className="h-5 w-5" />
176
- </button>
177
- <div className="brand-copy">
178
- <div className="brand-name">Lattice</div>
179
- <div className="brand-subtitle">Living Brain</div>
180
- </div>
181
- </div>
465
+ <svg className="relationship-weave" viewBox="0 0 100 100" aria-hidden>
466
+ {visibleRelationships.map(({ relationship, source, target }, index) => (
467
+ <line
468
+ key={`${relationship.id}-${index}`}
469
+ x1={source.x}
470
+ y1={source.y}
471
+ x2={target.x}
472
+ y2={target.y}
473
+ style={{ animationDelay: `${index * 80}ms` }}
474
+ />
475
+ ))}
476
+ </svg>
477
+ );
478
+ }
182
479
 
183
- <div className="desktop-dock">
184
- <PrimaryDock active={route.primary} />
185
- </div>
480
+ function EmergentKnowledgeGraph({
481
+ model,
482
+ search,
483
+ selectedId,
484
+ onSearch,
485
+ onSelect,
486
+ }: {
487
+ model: KnowledgeGraphModel;
488
+ search: string;
489
+ selectedId: string | null;
490
+ onSearch: (value: string) => void;
491
+ onSelect: (id: string | null) => void;
492
+ }) {
493
+ const query = search.trim().toLowerCase();
494
+ const visibleNodes = React.useMemo(() => {
495
+ const filtered = model.nodes.filter((node) => {
496
+ if (!query) return true;
497
+ return `${node.label} ${node.type} ${node.summary}`.toLowerCase().includes(query);
498
+ });
499
+ return filtered.slice(0, 18);
500
+ }, [model.nodes, query]);
501
+ const layout = React.useMemo(() => layoutGraphNodes(visibleNodes, 38, 24), [visibleNodes]);
502
+ const positionById = React.useMemo(() => new Map(layout.map((item) => [item.node.id, item])), [layout]);
503
+ const visibleEdges = React.useMemo(
504
+ () => model.edges.filter((edge) => positionById.has(edge.source) && positionById.has(edge.target)).slice(0, 36),
505
+ [model.edges, positionById],
506
+ );
507
+ const selected = visibleNodes.find((node) => node.id === selectedId) || visibleNodes[0] || null;
186
508
 
187
- <div className="chrome-actions">
188
- <button className="status-chip" onClick={() => go("settings")}>
189
- <span className={cn("status-light", backendReady && desktopReady ? "is-ready" : "is-waiting")} />
190
- <span>{backendReady && desktopReady ? "Ready" : "Starting"}</span>
191
- </button>
192
- <Button variant="outline" onClick={() => setPalette(true)}><Command className="h-4 w-4" /> Find</Button>
193
- <Button variant="outline" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")} aria-label="Toggle theme">
194
- {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
195
- </Button>
509
+ return (
510
+ <section className="mind-core-graph" data-testid="emergent-knowledge-graph" aria-label="Knowledge Graph">
511
+ <div className="brain-graph-head">
512
+ <div>
513
+ <span>Level 5</span>
514
+ <strong>Knowledge Graph</strong>
196
515
  </div>
197
- </header>
198
-
199
- {drawer ? (
200
- <div className="mobile-drawer">
201
- <button className="drawer-scrim" aria-label="Close navigation" onClick={() => setDrawer(false)} />
202
- <div className="drawer-panel">
203
- <div className="drawer-header">
204
- <div>
205
- <div className="font-semibold">Lattice</div>
206
- <div className="text-xs text-muted-foreground">Choose a layer</div>
207
- </div>
208
- <Button variant="ghost" size="icon" onClick={() => setDrawer(false)} aria-label="Close navigation"><X className="h-4 w-4" /></Button>
209
- </div>
210
- <PrimaryDock active={route.primary} onNavigate={() => setDrawer(false)} />
211
- </div>
516
+ <label className="brain-graph-search">
517
+ <Search className="h-3.5 w-3.5" />
518
+ <input
519
+ value={search}
520
+ onChange={(event) => onSearch(event.target.value)}
521
+ placeholder="Search"
522
+ aria-label="Search knowledge graph"
523
+ />
524
+ </label>
525
+ </div>
526
+
527
+ {visibleNodes.length ? (
528
+ <div className="brain-graph-canvas">
529
+ <svg className="brain-graph-edges" viewBox="0 0 100 100" aria-hidden>
530
+ {visibleEdges.map((edge, index) => {
531
+ const source = positionById.get(edge.source);
532
+ const target = positionById.get(edge.target);
533
+ if (!source || !target) return null;
534
+ return (
535
+ <line
536
+ key={`${edge.id}-${index}`}
537
+ x1={source.x}
538
+ y1={source.y}
539
+ x2={target.x}
540
+ y2={target.y}
541
+ style={{ "--weight": String(clamp(edge.weight, 0.4, 2.8)) } as React.CSSProperties}
542
+ />
543
+ );
544
+ })}
545
+ </svg>
546
+ {layout.map(({ node, x, y }, index) => (
547
+ <button
548
+ key={node.id}
549
+ type="button"
550
+ className={`graph-node ${selected?.id === node.id ? "is-selected" : ""}`}
551
+ style={layerStyle({ "--x": `${x}%`, "--y": `${y}%`, "--delay": `${index * 35}ms` })}
552
+ onClick={() => onSelect(node.id)}
553
+ >
554
+ <span>{node.type}</span>
555
+ {node.label}
556
+ </button>
557
+ ))}
212
558
  </div>
213
- ) : null}
559
+ ) : (
560
+ <div className="brain-graph-empty">No matching knowledge yet</div>
561
+ )}
214
562
 
215
- <main className="page-shell">
216
- <Page primary={route.primary} tab={route.tab} />
217
- </main>
218
- </div>
563
+ <div className="brain-graph-focus">
564
+ {selected ? (
565
+ <>
566
+ <span>{selected.type}</span>
567
+ <strong>{selected.label}</strong>
568
+ <p>{selected.summary || "This concept is part of the deepest knowledge layer."}</p>
569
+ </>
570
+ ) : (
571
+ <p>Capture documents, conversations, or projects to grow the graph.</p>
572
+ )}
573
+ </div>
574
+ </section>
219
575
  );
220
576
  }
577
+
578
+ function buildMemoryFragments(memoryData: unknown, historyData: unknown): MemoryFragment[] {
579
+ const memory = isRecord(memoryData) ? memoryData : {};
580
+ const sourceRows = asArray<ApiRecord>(memory.sources).length
581
+ ? asArray<ApiRecord>(memory.sources)
582
+ : asArray<ApiRecord>(memory.tiers);
583
+ const sourceFragments = sourceRows.map((item, index) => ({
584
+ id: textValue(item, ["id", "source", "label"], `memory-${index}`),
585
+ title: textValue(item, ["title", "label", "source", "path", "name"], "Workspace memory"),
586
+ kind: titleValue(item, ["type", "source_type", "kind", "health"], "Memory"),
587
+ }));
588
+ const conversationFragments = asArray<ApiRecord>(historyData).map((item, index) => ({
589
+ id: textValue(item, ["id", "conversation_id"], `conversation-${index}`),
590
+ title: textValue(item, ["title", "summary", "id"], "Conversation"),
591
+ kind: "Conversation",
592
+ }));
593
+
594
+ return uniqueById([...sourceFragments, ...conversationFragments]).slice(0, 10);
595
+ }
596
+
597
+ function parseKnowledgeGraph(data: unknown): KnowledgeGraphModel {
598
+ const graph = isRecord(data) ? data : {};
599
+ const rawNodes = asArray<ApiRecord>(graph.nodes);
600
+ const rawEdges = asArray<ApiRecord>(graph.edges);
601
+ const nodes = rawNodes.flatMap((node): KnowledgeConcept[] => {
602
+ const id = textValue(node, ["id", "node_id", "title", "label"]);
603
+ if (!id) return [];
604
+ const metadata = isRecord(node.metadata) ? node.metadata : {};
605
+ const type = titleValue(node, ["type", "kind", "category"], "Concept");
606
+ const label = textValue(node, ["title", "label", "name"], id.replace(/^[^:]+:/, ""));
607
+ const summary = textValue(node, ["summary", "description", "snippet"]) || textValue(metadata, ["summary", "description", "relative_path", "filename"]);
608
+ const importance = clamp(numberValue(node, ["importance_norm", "importance", "score"]) || 0.5, 0.08, 1);
609
+ return [{ id, label, type, summary, importance }];
610
+ }).sort((left, right) => right.importance - left.importance);
611
+ const ids = new Set(nodes.map((node) => node.id));
612
+ const edges = rawEdges.flatMap((edge, index): RelationshipThread[] => {
613
+ const source = textValue(edge, ["from", "source", "source_id"]);
614
+ const target = textValue(edge, ["to", "target", "target_id"]);
615
+ if (!source || !target || !ids.has(source) || !ids.has(target)) return [];
616
+ return [{
617
+ id: textValue(edge, ["id"], `edge-${index}`),
618
+ source,
619
+ target,
620
+ label: titleValue(edge, ["type", "label", "relationship"], "Relates"),
621
+ weight: numberValue(edge, ["weight", "score", "confidence"]) || 1,
622
+ }];
623
+ });
624
+ return { nodes, edges };
625
+ }
626
+
627
+ function currentModelName(data: unknown) {
628
+ const record = isRecord(data) ? data : {};
629
+ const current = textValue(record, ["current", "current_model", "local_model"]);
630
+ if (current) return current;
631
+ const loaded = asArray<ApiRecord>(record.loaded || record.loaded_models);
632
+ const firstLoaded = loaded.find((item) => item.id || item.name || item.model_id);
633
+ return firstLoaded ? textValue(firstLoaded, ["name", "id", "model_id"], "local mind") : "local mind";
634
+ }
635
+
636
+ function fileToDataUrl(file: File) {
637
+ return new Promise<string>((resolve, reject) => {
638
+ const reader = new FileReader();
639
+ reader.onload = () => resolve(String(reader.result || ""));
640
+ reader.onerror = () => reject(reader.error);
641
+ reader.readAsDataURL(file);
642
+ });
643
+ }
644
+
645
+ function layoutGraphNodes(nodes: KnowledgeConcept[], radiusX: number, radiusY: number) {
646
+ return nodes.map((node, index) => {
647
+ const point = polarPoint(index, nodes.length, radiusX, radiusY, -88);
648
+ return { node, x: point.x, y: point.y };
649
+ });
650
+ }
651
+
652
+ function polarPoint(index: number, total: number, radiusX: number, radiusY: number, offsetDegrees = -90) {
653
+ const count = Math.max(total, 1);
654
+ const angle = ((360 / count) * index + offsetDegrees) * Math.PI / 180;
655
+ return {
656
+ x: 50 + Math.cos(angle) * radiusX,
657
+ y: 50 + Math.sin(angle) * radiusY,
658
+ };
659
+ }
660
+
661
+ function layerStyle(values: Record<string, string>) {
662
+ return values as React.CSSProperties;
663
+ }
664
+
665
+ function uniqueById<T extends { id: string }>(items: T[]) {
666
+ const seen = new Set<string>();
667
+ return items.filter((item) => {
668
+ if (seen.has(item.id)) return false;
669
+ seen.add(item.id);
670
+ return true;
671
+ });
672
+ }
673
+
674
+ function isRecord(value: unknown): value is ApiRecord {
675
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
676
+ }
677
+
678
+ function textValue(record: ApiRecord, keys: string[], fallback = "") {
679
+ for (const key of keys) {
680
+ const value = record[key];
681
+ if (typeof value === "string" && value.trim()) return value;
682
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
683
+ }
684
+ return fallback;
685
+ }
686
+
687
+ function titleValue(record: ApiRecord, keys: string[], fallback = "") {
688
+ const value = textValue(record, keys, fallback);
689
+ return value
690
+ .replace(/[_-]+/g, " ")
691
+ .replace(/\b\w/g, (character) => character.toUpperCase());
692
+ }
693
+
694
+ function numberValue(record: ApiRecord, keys: string[]) {
695
+ for (const key of keys) {
696
+ const value = Number(record[key]);
697
+ if (Number.isFinite(value)) return value;
698
+ }
699
+ return 0;
700
+ }
701
+
702
+ function clamp(value: number, min: number, max: number) {
703
+ return Math.max(min, Math.min(max, value));
704
+ }