ltcai 4.5.1 → 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.
- package/README.md +123 -179
- package/docs/CHANGELOG.md +120 -0
- package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +72 -0
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +42 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +19 -17
- package/frontend/index.html +2 -2
- package/frontend/src/App.tsx +653 -208
- package/frontend/src/api/client.ts +1 -0
- package/frontend/src/components/BrainConversation.tsx +309 -0
- package/frontend/src/components/FirstRunGuide.tsx +4 -4
- package/frontend/src/components/LivingBrain.tsx +212 -0
- package/frontend/src/components/ProductFlow.tsx +654 -0
- package/frontend/src/pages/Ask.tsx +2 -229
- package/frontend/src/pages/Brain.tsx +68 -49
- package/frontend/src/routes.ts +15 -26
- package/frontend/src/styles.css +2375 -87
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +2 -2
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-7U86v70r.css +2 -0
- package/static/app/assets/index-D1jAPQws.js +16 -0
- package/static/app/assets/index-D1jAPQws.js.map +1 -0
- package/static/app/index.html +4 -4
- package/static/manifest.json +1 -1
- package/static/app/assets/index-3G8qcrIS.js +0 -336
- package/static/app/assets/index-3G8qcrIS.js.map +0 -1
- package/static/app/assets/index-C0wYZp7k.css +0 -2
|
@@ -341,6 +341,7 @@ export const latticeApi = {
|
|
|
341
341
|
connectFolder: (path: string) => post("/knowledge-graph/local/index", { path, approved: true, watch_enabled: true, consent: { approved: true, source: "desktop-spa" } }, {}),
|
|
342
342
|
localWatchStop: (source_id: string) => post("/knowledge-graph/local/watch/stop", { source_id }, {}),
|
|
343
343
|
models: () => get("/models", { catalog: [], loaded: [], recommended: [] }),
|
|
344
|
+
setupScan: () => get("/setup/scan", { environment: {}, recommendations: {}, zero_config: {} }),
|
|
344
345
|
modelRecommendations: (engine = "local_mlx") => get("/models/recommendations", { profile: {}, recommendations: { models: [], families: [], counts: {} } }, { engine }),
|
|
345
346
|
installEngine: (engine: string) => post("/engines/install", { engine }, {}),
|
|
346
347
|
prepareModel: (model: string, engine?: string, allow_download = false) => post("/engines/prepare-model", { model, engine: engine || null, allow_download }, {}),
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { ImagePlus, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
|
|
4
|
+
import { latticeApi } from "@/api/client";
|
|
5
|
+
import { EmptyState, EntityList, SourceBadge, StructuredView } from "@/components/primitives";
|
|
6
|
+
import { type BrainState, LivingBrain } from "@/components/LivingBrain";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
10
|
+
import { asArray } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
type Msg = { role?: string; content?: string; timestamp?: string };
|
|
13
|
+
type BrainActivity = BrainState;
|
|
14
|
+
type BrainVitals = {
|
|
15
|
+
connected: boolean;
|
|
16
|
+
memories: number;
|
|
17
|
+
knowledge: number;
|
|
18
|
+
conversations: number;
|
|
19
|
+
model: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function fileToDataUrl(file: File) {
|
|
23
|
+
return new Promise<string>((resolve, reject) => {
|
|
24
|
+
const reader = new FileReader();
|
|
25
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
26
|
+
reader.onerror = () => reject(reader.error);
|
|
27
|
+
reader.readAsDataURL(file);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function newConversationId() {
|
|
32
|
+
const suffix = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.round(Math.random() * 10000)}`;
|
|
33
|
+
return `brain-${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function currentModelName(data: unknown) {
|
|
37
|
+
const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
38
|
+
if (typeof record.current === "string" && record.current) return record.current;
|
|
39
|
+
const loaded = asArray<Record<string, unknown>>(record.loaded);
|
|
40
|
+
const firstLoaded = loaded.find((item) => item.id || item.name || item.model_id);
|
|
41
|
+
if (firstLoaded) return String(firstLoaded.name || firstLoaded.id || firstLoaded.model_id);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usageNumber(data: unknown, key: string) {
|
|
46
|
+
const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
47
|
+
const usage = record.usage && typeof record.usage === "object" ? record.usage as Record<string, unknown> : {};
|
|
48
|
+
const value = Number(usage[key] ?? record[key]);
|
|
49
|
+
return Number.isFinite(value) ? value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function BrainConversation({ className }: { className?: string }) {
|
|
53
|
+
const qc = useQueryClient();
|
|
54
|
+
const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
|
|
55
|
+
const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
|
|
56
|
+
const memory = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
|
|
57
|
+
const agentRuntime = useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime, refetchInterval: 12000 });
|
|
58
|
+
const [conversationId, setConversationId] = React.useState<string | null>(null);
|
|
59
|
+
const [selectedConversationId, setSelectedConversationId] = React.useState<string | null>(null);
|
|
60
|
+
const conversation = useQuery({
|
|
61
|
+
queryKey: ["conversation", selectedConversationId],
|
|
62
|
+
queryFn: () => latticeApi.conversation(selectedConversationId || ""),
|
|
63
|
+
enabled: !!selectedConversationId,
|
|
64
|
+
});
|
|
65
|
+
const [messages, setMessages] = React.useState<Msg[]>([]);
|
|
66
|
+
const [draft, setDraft] = React.useState("");
|
|
67
|
+
const [imageData, setImageData] = React.useState<string | null>(null);
|
|
68
|
+
const [trace, setTrace] = React.useState<unknown>(null);
|
|
69
|
+
const [streaming, setStreaming] = React.useState(false);
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (conversation.data?.ok) {
|
|
73
|
+
setMessages(asArray<Msg>((conversation.data.data as Record<string, unknown>).messages || conversation.data.data));
|
|
74
|
+
}
|
|
75
|
+
}, [conversation.data]);
|
|
76
|
+
|
|
77
|
+
const send = async () => {
|
|
78
|
+
const message = draft.trim();
|
|
79
|
+
if (!message || streaming) return;
|
|
80
|
+
const activeConversationId = conversationId || newConversationId();
|
|
81
|
+
if (!conversationId) setConversationId(activeConversationId);
|
|
82
|
+
setDraft("");
|
|
83
|
+
setMessages((items) => [...items, { role: "user", content: message }, { role: "assistant", content: "" }]);
|
|
84
|
+
setStreaming(true);
|
|
85
|
+
try {
|
|
86
|
+
const result = await latticeApi.streamChat(
|
|
87
|
+
{ message, conversation_id: activeConversationId, image_data: imageData || undefined },
|
|
88
|
+
{
|
|
89
|
+
onChunk: (_delta, fullText) => {
|
|
90
|
+
setMessages((items) => {
|
|
91
|
+
const next = [...items];
|
|
92
|
+
const last = next[next.length - 1] || { role: "assistant" };
|
|
93
|
+
next[next.length - 1] = { ...last, role: "assistant", content: fullText };
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
onTrace: setTrace,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
if (result.error) {
|
|
101
|
+
setMessages((items) => {
|
|
102
|
+
const next = [...items];
|
|
103
|
+
next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
|
|
104
|
+
return next;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
setStreaming(false);
|
|
109
|
+
setImageData(null);
|
|
110
|
+
await qc.invalidateQueries({ queryKey: ["chatHistory"] });
|
|
111
|
+
await qc.invalidateQueries({ queryKey: ["memoryManager"] });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const deleteMutation = useMutation({
|
|
116
|
+
mutationFn: (id: string) => latticeApi.deleteConversation(id),
|
|
117
|
+
onSuccess: async (_result, id) => {
|
|
118
|
+
if (conversationId === id) {
|
|
119
|
+
setConversationId(null);
|
|
120
|
+
setSelectedConversationId(null);
|
|
121
|
+
setMessages([]);
|
|
122
|
+
}
|
|
123
|
+
await qc.invalidateQueries({ queryKey: ["chatHistory"] });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const historyItems = asArray<Record<string, unknown>>(history.data?.data);
|
|
128
|
+
const memoryData = memory.data?.data;
|
|
129
|
+
const runtimeData = agentRuntime.data?.data as Record<string, unknown> | undefined;
|
|
130
|
+
const runs = asArray<Record<string, unknown>>(runtimeData?.runs);
|
|
131
|
+
const activity: BrainActivity =
|
|
132
|
+
streaming ? "thinking" :
|
|
133
|
+
imageData ? "recalling" :
|
|
134
|
+
runs.some((run) => String(run.status || run.state || "").match(/running|active|queued/i)) ? "acting" :
|
|
135
|
+
draft.trim().length > 2 ? "listening" :
|
|
136
|
+
trace ? "recalling" :
|
|
137
|
+
"idle";
|
|
138
|
+
const vitals: BrainVitals = {
|
|
139
|
+
connected: Boolean(models.data?.ok),
|
|
140
|
+
memories: usageNumber(memoryData, "total_items") ?? asArray((memoryData as Record<string, unknown> | undefined)?.sources).length,
|
|
141
|
+
knowledge: usageNumber(memoryData, "sources") ?? asArray((memoryData as Record<string, unknown> | undefined)?.tiers).length,
|
|
142
|
+
conversations: historyItems.length,
|
|
143
|
+
model: currentModelName(models.data?.data),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={className}>
|
|
148
|
+
<div className="brain-conversation-grid">
|
|
149
|
+
<section className="brain-presence-column" aria-label="Living Brain presence">
|
|
150
|
+
<LivingBrain state={activity} size="normal" />
|
|
151
|
+
</section>
|
|
152
|
+
|
|
153
|
+
<section className="brain-chat-panel premium-surface" aria-label="Conversation with Lattice Brain">
|
|
154
|
+
<div className="brain-chat-head">
|
|
155
|
+
<div>
|
|
156
|
+
<div className="brain-chat-kicker"><MessageSquare className="h-4 w-4" /> Conversation</div>
|
|
157
|
+
<h1>Talk to your Brain.</h1>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="brain-chat-model">
|
|
160
|
+
<Badge variant="muted">{currentModelName(models.data?.data) || "model readying"}</Badge>
|
|
161
|
+
<SourceBadge result={models.data} />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="brain-message-stream soft-scrollbar">
|
|
166
|
+
{messages.length ? messages.map((msg, index) => (
|
|
167
|
+
<div key={`${msg.role || "message"}-${index}`} className={`brain-message-row ${msg.role === "user" ? "from-user" : "from-brain"}`}>
|
|
168
|
+
<div className="brain-message-bubble">
|
|
169
|
+
<div className="brain-message-role">{msg.role === "user" ? "You" : "Brain"}</div>
|
|
170
|
+
<div className="whitespace-pre-wrap">{msg.content}</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)) : (
|
|
174
|
+
<div className="brain-empty-conversation">
|
|
175
|
+
<EmptyState
|
|
176
|
+
title="What should we think through?"
|
|
177
|
+
detail="Bring a question, a project, or a loose thought. Lattice will answer through your private memory when a model is ready."
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="brain-composer">
|
|
184
|
+
{imageData ? <Badge variant="success" className="mb-2">image attached</Badge> : null}
|
|
185
|
+
<Textarea
|
|
186
|
+
value={draft}
|
|
187
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
188
|
+
onKeyDown={(event) => {
|
|
189
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
void send();
|
|
192
|
+
}
|
|
193
|
+
}}
|
|
194
|
+
placeholder="Ask the Brain anything..."
|
|
195
|
+
/>
|
|
196
|
+
<div className="brain-composer-actions">
|
|
197
|
+
<label className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted">
|
|
198
|
+
<ImagePlus className="h-4 w-4" />
|
|
199
|
+
Image
|
|
200
|
+
<input
|
|
201
|
+
type="file"
|
|
202
|
+
accept="image/*"
|
|
203
|
+
className="sr-only"
|
|
204
|
+
onChange={async (event) => {
|
|
205
|
+
const file = event.target.files?.[0];
|
|
206
|
+
if (file) setImageData(await fileToDataUrl(file));
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</label>
|
|
210
|
+
<Button disabled={!draft.trim() || streaming} onClick={() => void send()}>
|
|
211
|
+
<Send className="h-4 w-4" /> Send
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</section>
|
|
216
|
+
|
|
217
|
+
<aside className="brain-context-column" aria-label="Conversation memory">
|
|
218
|
+
<RecentConversations
|
|
219
|
+
conversations={historyItems}
|
|
220
|
+
result={history.data}
|
|
221
|
+
activeId={conversationId}
|
|
222
|
+
onNew={() => {
|
|
223
|
+
setConversationId(null);
|
|
224
|
+
setSelectedConversationId(null);
|
|
225
|
+
setMessages([]);
|
|
226
|
+
setTrace(null);
|
|
227
|
+
}}
|
|
228
|
+
onSelect={(id) => {
|
|
229
|
+
setConversationId(id);
|
|
230
|
+
setSelectedConversationId(id);
|
|
231
|
+
setTrace(null);
|
|
232
|
+
}}
|
|
233
|
+
onDelete={(id) => deleteMutation.mutate(id)}
|
|
234
|
+
/>
|
|
235
|
+
<MemoryNearby question={draft || [...messages].reverse().find((msg) => msg.role === "user")?.content || ""} trace={trace} />
|
|
236
|
+
</aside>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function RecentConversations({
|
|
243
|
+
conversations,
|
|
244
|
+
result,
|
|
245
|
+
activeId,
|
|
246
|
+
onNew,
|
|
247
|
+
onSelect,
|
|
248
|
+
onDelete,
|
|
249
|
+
}: {
|
|
250
|
+
conversations: Array<Record<string, unknown>>;
|
|
251
|
+
result?: Parameters<typeof SourceBadge>[0]["result"];
|
|
252
|
+
activeId: string | null;
|
|
253
|
+
onNew: () => void;
|
|
254
|
+
onSelect: (id: string) => void;
|
|
255
|
+
onDelete: (id: string) => void;
|
|
256
|
+
}) {
|
|
257
|
+
return (
|
|
258
|
+
<section className="brain-side-panel">
|
|
259
|
+
<div className="brain-side-head">
|
|
260
|
+
<div>
|
|
261
|
+
<h3>Recent conversations</h3>
|
|
262
|
+
<SourceBadge result={result} />
|
|
263
|
+
</div>
|
|
264
|
+
<Button variant="outline" size="sm" onClick={onNew}><Plus className="h-4 w-4" /> New</Button>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="brain-conversation-list soft-scrollbar">
|
|
267
|
+
{conversations.length ? conversations.slice(0, 8).map((item) => {
|
|
268
|
+
const id = String(item.id || item.conversation_id || "");
|
|
269
|
+
return (
|
|
270
|
+
<div key={id} className={`brain-conversation-item ${activeId === id ? "is-active" : ""}`}>
|
|
271
|
+
<button onClick={() => onSelect(id)} className="min-w-0 text-left">
|
|
272
|
+
<span>{String(item.title || id || "Conversation")}</span>
|
|
273
|
+
<small>{String(item.updated_at || item.started_at || "")}</small>
|
|
274
|
+
</button>
|
|
275
|
+
<button className="brain-delete-button" onClick={() => onDelete(id)} aria-label="Delete conversation">
|
|
276
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}) : <EmptyState title="No conversations yet" detail="New exchanges will appear here." />}
|
|
281
|
+
</div>
|
|
282
|
+
</section>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function MemoryNearby({ question, trace }: { question: string; trace: unknown }) {
|
|
287
|
+
const hybrid = useQuery({
|
|
288
|
+
queryKey: ["brainNearbyMemory", question],
|
|
289
|
+
queryFn: () => latticeApi.hybridSearch(question),
|
|
290
|
+
enabled: question.trim().length > 2,
|
|
291
|
+
});
|
|
292
|
+
return (
|
|
293
|
+
<section className="brain-side-panel">
|
|
294
|
+
<div className="brain-side-head">
|
|
295
|
+
<div>
|
|
296
|
+
<h3>Memory nearby</h3>
|
|
297
|
+
<SourceBadge result={hybrid.data} />
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
{hybrid.data?.ok ? (
|
|
301
|
+
<EntityList items={(hybrid.data.data as Record<string, unknown>).matches || hybrid.data.data} titleKey="title" metaKey="type" limit={4} />
|
|
302
|
+
) : trace ? (
|
|
303
|
+
<StructuredView value={trace} limit={4} />
|
|
304
|
+
) : (
|
|
305
|
+
<EmptyState title="Quiet for now" detail="Relevant memory wakes up as the conversation gets specific." />
|
|
306
|
+
)}
|
|
307
|
+
</section>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
@@ -41,9 +41,9 @@ export function FirstRunGuide() {
|
|
|
41
41
|
{ label: "Meet your Mac", done: recs.isSuccess, icon: Cpu, action: "models", detail: "Let Lattice inspect what can run locally." },
|
|
42
42
|
{ label: "Pick a brain", done: Boolean(topPick || currentModel), icon: Library, action: "models", detail: "Use the recommended local model." },
|
|
43
43
|
{ label: "Install locally", done: Boolean(currentModel || loadedModels.length), icon: Download, action: "models", detail: "Download only with explicit consent." },
|
|
44
|
-
{ label: "
|
|
44
|
+
{ label: "Talk to Brain", done: Boolean(readyProfile || currentModel || loadedModels.length), icon: PlayCircle, action: "chat", detail: "Confirm the model can answer." },
|
|
45
45
|
{ label: "Set the pace", done: Boolean(mode), icon: SlidersHorizontal, action: "settings", detail: "Stay Calm or switch deeper." },
|
|
46
|
-
{ label: "Explore
|
|
46
|
+
{ label: "Explore deeply", done: true, icon: Layers3, action: "knowledge-graph", detail: "Open advanced relationships." },
|
|
47
47
|
];
|
|
48
48
|
const completed = steps.filter((step) => step.done).length;
|
|
49
49
|
const nextStep = steps.find((step) => !step.done) || steps[steps.length - 1];
|
|
@@ -53,13 +53,13 @@ export function FirstRunGuide() {
|
|
|
53
53
|
<section className="arrival-panel" aria-label="First 10 minutes">
|
|
54
54
|
<div className="arrival-copy">
|
|
55
55
|
<div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
|
|
56
|
-
<h2>Build your
|
|
56
|
+
<h2>Build your living Brain without guessing.</h2>
|
|
57
57
|
<p>
|
|
58
58
|
Start with a space, let Lattice recommend a private local model, then add the first pieces of knowledge.
|
|
59
59
|
Every step keeps the next action visible.
|
|
60
60
|
</p>
|
|
61
61
|
<div className="arrival-actions">
|
|
62
|
-
<Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open
|
|
62
|
+
<Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open relationships" : `Continue: ${nextStep.label}`}</Button>
|
|
63
63
|
<Button variant="outline" onClick={() => go("models")}>Set up model</Button>
|
|
64
64
|
<Button variant="ghost" onClick={() => {
|
|
65
65
|
try { localStorage.setItem("lattice.onboarding.dismissed", "true"); } catch {}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export type BrainState = "idle" | "listening" | "thinking" | "recalling" | "synthesizing" | "planning" | "acting" | "resting";
|
|
5
|
+
|
|
6
|
+
export interface LivingBrainProps {
|
|
7
|
+
state?: BrainState;
|
|
8
|
+
intensity?: number; // 0-1 how "alive" it feels right now
|
|
9
|
+
onPulse?: () => void; // allow parent to trigger a memory pulse
|
|
10
|
+
size?: "normal" | "large" | "trace";
|
|
11
|
+
label?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
showLabel?: boolean;
|
|
14
|
+
depth?: number; // 0-5 progressive exploration depth; higher = more "open" / revealing
|
|
15
|
+
onInteract?: () => void; // called on click to advance exploration (travel deeper)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Living Brain — the primary visual and emotional object in the product.
|
|
20
|
+
* It is not decoration. It is the other participant.
|
|
21
|
+
* It breathes. It reacts. It remembers.
|
|
22
|
+
*/
|
|
23
|
+
export function LivingBrain({
|
|
24
|
+
state = "idle",
|
|
25
|
+
intensity = 0.6,
|
|
26
|
+
onPulse,
|
|
27
|
+
size = "large",
|
|
28
|
+
label,
|
|
29
|
+
className,
|
|
30
|
+
showLabel = true,
|
|
31
|
+
depth = 0,
|
|
32
|
+
onInteract,
|
|
33
|
+
}: LivingBrainProps) {
|
|
34
|
+
const [isPulsing, setIsPulsing] = React.useState(false);
|
|
35
|
+
const organismRef = React.useRef<HTMLButtonElement>(null);
|
|
36
|
+
|
|
37
|
+
// External trigger for memory / important recall moments
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (onPulse) {
|
|
40
|
+
const handler = () => firePulse();
|
|
41
|
+
// allow global window event too for simplicity across components
|
|
42
|
+
window.addEventListener("brain:recall", handler as EventListener);
|
|
43
|
+
return () => window.removeEventListener("brain:recall", handler as EventListener);
|
|
44
|
+
}
|
|
45
|
+
}, [onPulse]);
|
|
46
|
+
|
|
47
|
+
function firePulse() {
|
|
48
|
+
setIsPulsing(true);
|
|
49
|
+
if (organismRef.current) {
|
|
50
|
+
organismRef.current.classList.add("pulse");
|
|
51
|
+
// clean after animation
|
|
52
|
+
window.setTimeout(() => {
|
|
53
|
+
if (organismRef.current) organismRef.current.classList.remove("pulse");
|
|
54
|
+
setIsPulsing(false);
|
|
55
|
+
}, 1350);
|
|
56
|
+
}
|
|
57
|
+
onPulse?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Auto gentle pulse when recalling or high intensity
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
if ((state === "recalling" || intensity > 0.82) && !isPulsing) {
|
|
63
|
+
const t = window.setTimeout(() => firePulse(), 180);
|
|
64
|
+
return () => clearTimeout(t);
|
|
65
|
+
}
|
|
66
|
+
}, [state, intensity]);
|
|
67
|
+
|
|
68
|
+
const dataState = state;
|
|
69
|
+
const isLarge = size === "large";
|
|
70
|
+
const isTrace = size === "trace";
|
|
71
|
+
|
|
72
|
+
const dynamicIntensity = Math.max(0.35, Math.min(1, intensity));
|
|
73
|
+
const effectiveDepth = Math.max(0, Math.min(5, depth || 0));
|
|
74
|
+
const canTravel = state !== "thinking";
|
|
75
|
+
|
|
76
|
+
const handleClick = () => {
|
|
77
|
+
if (!canTravel) return;
|
|
78
|
+
firePulse();
|
|
79
|
+
onInteract?.();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className={cn(
|
|
85
|
+
"brain-presence select-none",
|
|
86
|
+
isLarge && "large",
|
|
87
|
+
isTrace && "trace",
|
|
88
|
+
className,
|
|
89
|
+
effectiveDepth > 0 && "is-exploring"
|
|
90
|
+
)}
|
|
91
|
+
aria-label="Your living Brain"
|
|
92
|
+
role="group"
|
|
93
|
+
data-depth={effectiveDepth}
|
|
94
|
+
>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
ref={organismRef}
|
|
98
|
+
className={cn("brain-organism", `size-${size}`, `depth-${effectiveDepth}`)}
|
|
99
|
+
data-state={dataState}
|
|
100
|
+
aria-label={effectiveDepth < 5 ? "Travel deeper into your Brain" : "Rest inside the Knowledge Graph"}
|
|
101
|
+
aria-disabled={!canTravel}
|
|
102
|
+
style={{
|
|
103
|
+
transform: `scale(${0.96 + (dynamicIntensity - 0.5) * 0.09 + effectiveDepth * 0.015})`,
|
|
104
|
+
}}
|
|
105
|
+
onClick={handleClick}
|
|
106
|
+
title={effectiveDepth < 5 ? "Travel deeper into your Brain" : "The core of your knowledge"}
|
|
107
|
+
>
|
|
108
|
+
{/* Living anatomical presence. The glow opens with depth; the folds make it unmistakably a Brain. */}
|
|
109
|
+
<div className="brain-core" style={{ transform: `scale(${1 + effectiveDepth * 0.045})` }}>
|
|
110
|
+
<svg className="brain-anatomy" viewBox="0 0 220 174" aria-hidden>
|
|
111
|
+
<path
|
|
112
|
+
className="brain-lobe brain-lobe-left"
|
|
113
|
+
d="M102 30c-13-20-44-19-55 1-18 1-29 16-28 33-13 8-18 25-11 39-9 16-1 36 17 42 5 19 27 26 43 15 13 10 33 8 43-5 5-7 8-16 8-27V52c0-8-6-17-17-22Z"
|
|
114
|
+
/>
|
|
115
|
+
<path
|
|
116
|
+
className="brain-lobe brain-lobe-right"
|
|
117
|
+
d="M118 30c13-20 44-19 55 1 18 1 29 16 28 33 13 8 18 25 11 39 9 16 1 36-17 42-5 19-27 26-43 15-13 10-33 8-43-5-5-7-8-16-8-27V52c0-8 6-17 17-22Z"
|
|
118
|
+
/>
|
|
119
|
+
<path className="brain-bridge" d="M103 48c9-8 24-8 33 0 7 6 9 16 5 25-5 11-16 15-31 12-15 3-26-1-31-12-4-9-2-19 5-25 5-4 12-6 19 0Z" />
|
|
120
|
+
<path className="brain-stem" d="M92 137c10 9 26 9 36 0 1 14 7 25 20 33H76c12-8 17-19 16-33Z" />
|
|
121
|
+
<path className="brain-fold fold-a" d="M48 50c18-11 38-8 47 8" />
|
|
122
|
+
<path className="brain-fold fold-b" d="M34 82c22-8 45-5 58 8" />
|
|
123
|
+
<path className="brain-fold fold-c" d="M43 119c18 5 35 2 49-11" />
|
|
124
|
+
<path className="brain-fold fold-d" d="M172 50c-18-11-38-8-47 8" />
|
|
125
|
+
<path className="brain-fold fold-e" d="M186 82c-22-8-45-5-58 8" />
|
|
126
|
+
<path className="brain-fold fold-f" d="M177 119c-18 5-35 2-49-11" />
|
|
127
|
+
<path className="brain-fold fold-mid" d="M110 38c-5 30-5 70 0 112" />
|
|
128
|
+
</svg>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Breathing field expands as we go deeper. */}
|
|
132
|
+
<div
|
|
133
|
+
className="brain-aura"
|
|
134
|
+
style={{
|
|
135
|
+
animationDuration: state === "thinking" ? "1.65s" : state === "recalling" ? "2.4s" : "6.8s",
|
|
136
|
+
transform: `scale(${1 + effectiveDepth * 0.12})`,
|
|
137
|
+
opacity: 0.65 + effectiveDepth * 0.05
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
|
|
141
|
+
{/* Thought activity — increases and starts to "resolve" into structure at higher depths */}
|
|
142
|
+
<div className="thought-activity" aria-hidden>
|
|
143
|
+
{Array.from({ length: Math.min(12, 5 + effectiveDepth * 2) }).map((_, i) => (
|
|
144
|
+
<div
|
|
145
|
+
key={i}
|
|
146
|
+
className={cn("thought-particle", effectiveDepth >= 3 && "resolving")}
|
|
147
|
+
style={{
|
|
148
|
+
left: `${18 + ((i * 13 + effectiveDepth * 4) % 64)}%`,
|
|
149
|
+
top: `${22 + (i % 5) * 14}%`,
|
|
150
|
+
animationDelay: `-${i * 0.55 + (intensity * 1.1) - effectiveDepth * 0.2}s`,
|
|
151
|
+
animationDuration: `${2.8 + (1 - dynamicIntensity) * 1.6 - effectiveDepth * 0.15}s`,
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Memory ripples — more and stronger as depth increases (echoes surfacing) */}
|
|
158
|
+
{Array.from({ length: 1 + Math.floor(effectiveDepth / 1.5) }).map((_, i) => (
|
|
159
|
+
<div
|
|
160
|
+
key={i}
|
|
161
|
+
className="memory-ripple"
|
|
162
|
+
aria-hidden
|
|
163
|
+
style={{
|
|
164
|
+
inset: `${18 + i * 6}%`,
|
|
165
|
+
animationDelay: `${180 + i * 220}ms`,
|
|
166
|
+
opacity: 0.55 + effectiveDepth * 0.06
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
|
|
171
|
+
{/* Relationship structure appears only near the deepest layers. */}
|
|
172
|
+
{effectiveDepth >= 4 && (
|
|
173
|
+
<svg className="brain-inner-structure" viewBox="0 0 100 100" aria-hidden>
|
|
174
|
+
<g stroke="hsl(var(--brain-core) / 0.35)" strokeWidth="0.6" fill="none">
|
|
175
|
+
<circle cx="50" cy="50" r="18" />
|
|
176
|
+
<circle cx="50" cy="50" r="28" />
|
|
177
|
+
<path d="M32 50 Q50 32 68 50" />
|
|
178
|
+
<path d="M32 50 Q50 68 68 50" />
|
|
179
|
+
</g>
|
|
180
|
+
</svg>
|
|
181
|
+
)}
|
|
182
|
+
</button>
|
|
183
|
+
|
|
184
|
+
{showLabel && !isTrace && (
|
|
185
|
+
<div className="brain-presence-label" data-state={state}>
|
|
186
|
+
<span className="dot" />
|
|
187
|
+
{label || (effectiveDepth > 0 ? `Depth ${effectiveDepth}` : humanState(state))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function humanState(s: BrainState) {
|
|
195
|
+
switch (s) {
|
|
196
|
+
case "listening": return "Listening";
|
|
197
|
+
case "thinking": return "Thinking with you";
|
|
198
|
+
case "recalling": return "Remembering";
|
|
199
|
+
case "synthesizing": return "Making sense";
|
|
200
|
+
case "planning": return "Planning";
|
|
201
|
+
case "acting": return "Acting";
|
|
202
|
+
case "resting": return "With you";
|
|
203
|
+
default: return "Here";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Helper to broadcast recall pulses from anywhere (conversation, memory surface, etc)
|
|
208
|
+
export function triggerBrainRecall() {
|
|
209
|
+
if (typeof window !== "undefined") {
|
|
210
|
+
window.dispatchEvent(new CustomEvent("brain:recall"));
|
|
211
|
+
}
|
|
212
|
+
}
|