ltcai 7.7.0 → 7.9.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 (61) hide show
  1. package/README.md +38 -26
  2. package/docs/CHANGELOG.md +37 -0
  3. package/docs/DEVELOPMENT.md +1 -2
  4. package/docs/MULTI_AGENT_RUNTIME.md +6 -5
  5. package/docs/V2_ARCHITECTURE.md +3 -2
  6. package/frontend/src/App.tsx +3 -1
  7. package/frontend/src/components/ProductFlow.tsx +19 -23
  8. package/frontend/src/components/onboarding/RecommendationScreen.tsx +11 -12
  9. package/frontend/src/features/brain/BrainConversation.tsx +107 -108
  10. package/frontend/src/features/brain/BrainHome.tsx +32 -33
  11. package/frontend/src/i18n.ts +53 -19
  12. package/frontend/src/pages/Ask.tsx +11 -2
  13. package/frontend/src/pages/Brain.tsx +12 -2
  14. package/frontend/src/styles.css +390 -65
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/runtime/__init__.py +8 -2
  17. package/lattice_brain/runtime/agent_runtime.py +17 -1
  18. package/lattice_brain/runtime/contracts.py +64 -1
  19. package/lattice_brain/runtime/multi_agent.py +1 -1
  20. package/latticeai/__init__.py +1 -1
  21. package/latticeai/core/agent.py +39 -12
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/latticeai/services/architecture_readiness.py +1 -1
  25. package/latticeai/services/product_readiness.py +22 -15
  26. package/latticeai/services/tool_dispatch.py +15 -3
  27. package/package.json +1 -1
  28. package/scripts/product_readiness.py +1 -1
  29. package/src-tauri/Cargo.lock +1 -1
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/tauri.conf.json +1 -1
  32. package/static/app/asset-manifest.json +28 -28
  33. package/static/app/assets/Act-DOvf59ru.js +2 -0
  34. package/static/app/assets/{Act-CX5hL0Z3.js.map → Act-DOvf59ru.js.map} +1 -1
  35. package/static/app/assets/Brain-C7_0mEiI.js +322 -0
  36. package/static/app/assets/Brain-C7_0mEiI.js.map +1 -0
  37. package/static/app/assets/Capture-B9Tlhzqr.js +2 -0
  38. package/static/app/assets/{Capture-CdFSrZnA.js.map → Capture-B9Tlhzqr.js.map} +1 -1
  39. package/static/app/assets/{Library-CZFVGSmG.js → Library-BJPEEm5O.js} +2 -2
  40. package/static/app/assets/{Library-CZFVGSmG.js.map → Library-BJPEEm5O.js.map} +1 -1
  41. package/static/app/assets/System-D6t9jo9V.js +2 -0
  42. package/static/app/assets/{System-CMoOoUrZ.js.map → System-D6t9jo9V.js.map} +1 -1
  43. package/static/app/assets/index-C7g26IF6.css +2 -0
  44. package/static/app/assets/index-DbcEYJQ2.js +17 -0
  45. package/static/app/assets/{index-B4_drWel.js.map → index-DbcEYJQ2.js.map} +1 -1
  46. package/static/app/assets/primitives-CD38lt4n.js +2 -0
  47. package/static/app/assets/{primitives-CFhU5Rka.js.map → primitives-CD38lt4n.js.map} +1 -1
  48. package/static/app/assets/textarea-BZk6ybp5.js +2 -0
  49. package/static/app/assets/{textarea-uBpGDOJM.js.map → textarea-BZk6ybp5.js.map} +1 -1
  50. package/static/app/index.html +2 -2
  51. package/frontend/src/components/BrainConversation.tsx +0 -533
  52. package/frontend/src/components/FirstRunGuide.tsx +0 -99
  53. package/static/app/assets/Act-CX5hL0Z3.js +0 -2
  54. package/static/app/assets/Brain-m19en5wz.js +0 -322
  55. package/static/app/assets/Brain-m19en5wz.js.map +0 -1
  56. package/static/app/assets/Capture-CdFSrZnA.js +0 -2
  57. package/static/app/assets/System-CMoOoUrZ.js +0 -2
  58. package/static/app/assets/index-B4_drWel.js +0 -17
  59. package/static/app/assets/index-DjpDeE0c.css +0 -2
  60. package/static/app/assets/primitives-CFhU5Rka.js +0 -2
  61. package/static/app/assets/textarea-uBpGDOJM.js +0 -2
@@ -1,533 +0,0 @@
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 { FeedbackState } from "@/components/FeedbackState";
7
- import { type BrainState, LivingBrain } from "@/components/LivingBrain";
8
- import { Badge } from "@/components/ui/badge";
9
- import { Button } from "@/components/ui/button";
10
- import { Textarea } from "@/components/ui/textarea";
11
- import { asArray } from "@/lib/utils";
12
- import { t, type Language } from "@/i18n";
13
- import { useAppStore } from "@/store/appStore";
14
-
15
- type Citation = { id: string; source: string; title: string; snippet: string; score: number | null };
16
- type Msg = { role?: string; content?: string; timestamp?: string; citations?: Citation[] };
17
- type BrainActivity = BrainState;
18
- type BrainVitals = {
19
- connected: boolean;
20
- memories: number;
21
- knowledge: number;
22
- conversations: number;
23
- model: string | null;
24
- };
25
-
26
- function fileToDataUrl(file: File) {
27
- return new Promise<string>((resolve, reject) => {
28
- const reader = new FileReader();
29
- reader.onload = () => resolve(String(reader.result || ""));
30
- reader.onerror = () => reject(reader.error);
31
- reader.readAsDataURL(file);
32
- });
33
- }
34
-
35
- function newConversationId() {
36
- const suffix = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.round(Math.random() * 10000)}`;
37
- return `brain-${suffix}`;
38
- }
39
-
40
- function currentModelName(data: unknown) {
41
- const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
42
- if (typeof record.current === "string" && record.current) return record.current;
43
- const loaded = asArray<Record<string, unknown>>(record.loaded);
44
- const firstLoaded = loaded.find((item) => item.id || item.name || item.model_id);
45
- if (firstLoaded) return String(firstLoaded.name || firstLoaded.id || firstLoaded.model_id);
46
- return null;
47
- }
48
-
49
- function usageNumber(data: unknown, key: string) {
50
- const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
51
- const usage = record.usage && typeof record.usage === "object" ? record.usage as Record<string, unknown> : {};
52
- const value = Number(usage[key] ?? record[key]);
53
- return Number.isFinite(value) ? value : null;
54
- }
55
-
56
- // Pull human-readable citation records out of the heterogeneous streaming trace.
57
- // The backend trace shape varies, so we defensively scan the common containers
58
- // (matches / sources / retrieved / context / chunks) for source-like records.
59
- function extractCitations(trace: unknown): Citation[] {
60
- if (!trace || typeof trace !== "object") return [];
61
- const record = trace as Record<string, unknown>;
62
- const containerKeys = ["matches", "sources", "retrieved", "context", "chunks", "citations", "evidence"];
63
- let rows: Array<Record<string, unknown>> = [];
64
- for (const key of containerKeys) {
65
- const candidate = asArray<Record<string, unknown>>(record[key]);
66
- if (candidate.length) {
67
- rows = candidate;
68
- break;
69
- }
70
- }
71
- if (!rows.length && Array.isArray(trace)) rows = trace as Array<Record<string, unknown>>;
72
-
73
- const seen = new Set<string>();
74
- const citations: Citation[] = [];
75
- for (const row of rows) {
76
- if (!row || typeof row !== "object") continue;
77
- const title = String(row.title || row.name || row.heading || row.label || row.id || "").trim();
78
- const source = String(row.source || row.type || row.origin || row.kind || row.provider || "memory").trim();
79
- const snippet = String(row.snippet || row.text || row.content || row.summary || row.excerpt || "").trim();
80
- if (!title && !snippet) continue;
81
- const scoreRaw = Number(row.score ?? row.relevance ?? row.similarity);
82
- const score = Number.isFinite(scoreRaw) ? Math.round((scoreRaw <= 1 ? scoreRaw * 100 : scoreRaw)) : null;
83
- const id = String(row.id || `${source}:${title}:${snippet.slice(0, 24)}`);
84
- if (seen.has(id)) continue;
85
- seen.add(id);
86
- citations.push({ id, source: source || "memory", title: title || snippet.slice(0, 48), snippet, score });
87
- if (citations.length >= 6) break;
88
- }
89
- return citations;
90
- }
91
-
92
- // Anchor citation chips after the first N sentences (conservative heuristic).
93
- // Each segment carries an optional citation index so chips stay tied to text.
94
- function splitWithCitations(content: string, citationCount: number): Array<{ text: string; citation: number | null }> {
95
- if (!citationCount || !content) return [{ text: content, citation: null }];
96
- const sentences = content.match(/[^.!?。!?\n]+[.!?。!?]?\s*/g);
97
- if (!sentences || sentences.length <= 1) return [{ text: content, citation: 0 }];
98
- const segments: Array<{ text: string; citation: number | null }> = [];
99
- sentences.forEach((sentence, index) => {
100
- const citation = index < citationCount ? index : null;
101
- segments.push({ text: sentence, citation });
102
- });
103
- return segments;
104
- }
105
-
106
- export function BrainConversation({ className }: { className?: string }) {
107
- const language = useAppStore((state) => state.language);
108
- const qc = useQueryClient();
109
- const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
110
- const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
111
- const memory = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
112
- const agentRuntime = useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime, refetchInterval: 12000 });
113
- const [conversationId, setConversationId] = React.useState<string | null>(null);
114
- const [selectedConversationId, setSelectedConversationId] = React.useState<string | null>(null);
115
- const conversation = useQuery({
116
- queryKey: ["conversation", selectedConversationId],
117
- queryFn: () => latticeApi.conversation(selectedConversationId || ""),
118
- enabled: !!selectedConversationId,
119
- });
120
- const [messages, setMessages] = React.useState<Msg[]>([]);
121
- const [draft, setDraft] = React.useState("");
122
- const [imageData, setImageData] = React.useState<string | null>(null);
123
- const [trace, setTrace] = React.useState<unknown>(null);
124
- const [streaming, setStreaming] = React.useState(false);
125
-
126
- React.useEffect(() => {
127
- if (conversation.data?.ok) {
128
- setMessages(asArray<Msg>((conversation.data.data as Record<string, unknown>).messages || conversation.data.data));
129
- }
130
- }, [conversation.data]);
131
-
132
- const send = async () => {
133
- const message = draft.trim();
134
- if (!message || streaming) return;
135
- const activeConversationId = conversationId || newConversationId();
136
- if (!conversationId) setConversationId(activeConversationId);
137
- setDraft("");
138
- setMessages((items) => [...items, { role: "user", content: message }, { role: "assistant", content: "" }]);
139
- setStreaming(true);
140
- try {
141
- const result = await latticeApi.streamChat(
142
- { message, conversation_id: activeConversationId, image_data: imageData || undefined },
143
- {
144
- onChunk: (_delta, fullText) => {
145
- setMessages((items) => {
146
- const next = [...items];
147
- const last = next[next.length - 1] || { role: "assistant" };
148
- next[next.length - 1] = { ...last, role: "assistant", content: fullText };
149
- return next;
150
- });
151
- },
152
- onTrace: (incoming) => {
153
- setTrace(incoming);
154
- const citations = extractCitations(incoming);
155
- if (citations.length) {
156
- setMessages((items) => {
157
- const next = [...items];
158
- const last = next[next.length - 1];
159
- if (last && last.role === "assistant") {
160
- next[next.length - 1] = { ...last, citations };
161
- }
162
- return next;
163
- });
164
- }
165
- },
166
- },
167
- );
168
- if (result.error) {
169
- setMessages((items) => {
170
- const next = [...items];
171
- next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
172
- return next;
173
- });
174
- }
175
- } finally {
176
- setStreaming(false);
177
- setImageData(null);
178
- await qc.invalidateQueries({ queryKey: ["chatHistory"] });
179
- await qc.invalidateQueries({ queryKey: ["memoryManager"] });
180
- }
181
- };
182
-
183
- const deleteMutation = useMutation({
184
- mutationFn: (id: string) => latticeApi.deleteConversation(id),
185
- onSuccess: async (_result, id) => {
186
- if (conversationId === id) {
187
- setConversationId(null);
188
- setSelectedConversationId(null);
189
- setMessages([]);
190
- }
191
- await qc.invalidateQueries({ queryKey: ["chatHistory"] });
192
- },
193
- });
194
-
195
- const historyItems = asArray<Record<string, unknown>>(history.data?.data);
196
- const memoryData = memory.data?.data;
197
- const runtimeData = agentRuntime.data?.data as Record<string, unknown> | undefined;
198
- const runs = asArray<Record<string, unknown>>(runtimeData?.runs);
199
- const activity: BrainActivity =
200
- streaming ? "thinking" :
201
- imageData ? "recalling" :
202
- runs.some((run) => String(run.status || run.state || "").match(/running|active|queued/i)) ? "acting" :
203
- draft.trim().length > 2 ? "listening" :
204
- trace ? "recalling" :
205
- "idle";
206
- const vitals: BrainVitals = {
207
- connected: Boolean(models.data?.ok),
208
- memories: usageNumber(memoryData, "total_items") ?? asArray((memoryData as Record<string, unknown> | undefined)?.sources).length,
209
- knowledge: usageNumber(memoryData, "sources") ?? asArray((memoryData as Record<string, unknown> | undefined)?.tiers).length,
210
- conversations: historyItems.length,
211
- model: currentModelName(models.data?.data),
212
- };
213
-
214
- return (
215
- <div className={className}>
216
- <div className="brain-conversation-grid">
217
- <section className="brain-presence-column" aria-label="Living Brain presence">
218
- <LivingBrain state={activity} size="normal" />
219
- </section>
220
-
221
- <section className="brain-chat-panel premium-surface" aria-label="Conversation with Lattice Brain">
222
- <div className="brain-chat-head">
223
- <div>
224
- <div className="brain-chat-kicker"><MessageSquare className="h-4 w-4" /> Conversation</div>
225
- <h1>Talk to your Brain.</h1>
226
- </div>
227
- <div className="brain-chat-model">
228
- <Badge variant="muted">{currentModelName(models.data?.data) || "model readying"}</Badge>
229
- <SourceBadge result={models.data} />
230
- </div>
231
- </div>
232
-
233
- <div className="brain-message-stream soft-scrollbar">
234
- {messages.length ? messages.map((msg, index) => (
235
- <div key={`${msg.role || "message"}-${index}`} className={`brain-message-row ${msg.role === "user" ? "from-user" : "from-brain"}`}>
236
- <div className="brain-message-bubble">
237
- <div className="brain-message-role">{msg.role === "user" ? "You" : "Brain"}</div>
238
- {msg.role === "user" ? (
239
- <div className="whitespace-pre-wrap">{msg.content}</div>
240
- ) : (
241
- <MessageWithCitations
242
- language={language}
243
- content={msg.content || ""}
244
- citations={msg.citations || []}
245
- />
246
- )}
247
- </div>
248
- </div>
249
- )) : (
250
- <div className="brain-empty-conversation">
251
- <EmptyState
252
- title="What should we think through?"
253
- detail="Bring a question, a project, or a loose thought. Lattice will answer through your private memory when a model is ready."
254
- />
255
- </div>
256
- )}
257
- </div>
258
-
259
- <div className="brain-composer">
260
- {imageData ? <Badge variant="success" className="mb-2">image attached</Badge> : null}
261
- <Textarea
262
- value={draft}
263
- onChange={(event) => setDraft(event.target.value)}
264
- onKeyDown={(event) => {
265
- if (event.key === "Enter" && !event.shiftKey) {
266
- event.preventDefault();
267
- void send();
268
- }
269
- }}
270
- placeholder="Ask the Brain anything..."
271
- />
272
- <div className="brain-composer-actions">
273
- <label className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted">
274
- <ImagePlus className="h-4 w-4" />
275
- Image
276
- <input
277
- type="file"
278
- accept="image/*"
279
- className="sr-only"
280
- onChange={async (event) => {
281
- const file = event.target.files?.[0];
282
- if (file) setImageData(await fileToDataUrl(file));
283
- }}
284
- />
285
- </label>
286
- <Button disabled={!draft.trim() || streaming} onClick={() => void send()}>
287
- <Send className="h-4 w-4" /> Send
288
- </Button>
289
- </div>
290
- </div>
291
- </section>
292
-
293
- <aside className="brain-context-column" aria-label="Conversation memory">
294
- <RecentConversations
295
- conversations={historyItems}
296
- result={history.data}
297
- activeId={conversationId}
298
- onNew={() => {
299
- setConversationId(null);
300
- setSelectedConversationId(null);
301
- setMessages([]);
302
- setTrace(null);
303
- }}
304
- onSelect={(id) => {
305
- setConversationId(id);
306
- setSelectedConversationId(id);
307
- setTrace(null);
308
- }}
309
- onDelete={(id) => deleteMutation.mutate(id)}
310
- />
311
- <MemoryNearby question={draft || [...messages].reverse().find((msg) => msg.role === "user")?.content || ""} trace={trace} />
312
- </aside>
313
- </div>
314
- </div>
315
- );
316
- }
317
-
318
- // Renders assistant text with inline, keyboard-accessible citation chips that are
319
- // linked to a proof list below. Selecting a chip highlights and reveals its source.
320
- function MessageWithCitations({
321
- language,
322
- content,
323
- citations,
324
- }: {
325
- language: Language;
326
- content: string;
327
- citations: Citation[];
328
- }) {
329
- const [activeId, setActiveId] = React.useState<string | null>(null);
330
- const [announce, setAnnounce] = React.useState("");
331
- const listRef = React.useRef<HTMLOListElement | null>(null);
332
-
333
- if (!citations.length) {
334
- return <div className="whitespace-pre-wrap">{content}</div>;
335
- }
336
-
337
- const segments = splitWithCitations(content, citations.length);
338
-
339
- const focusListItem = (id: string) => {
340
- const node = listRef.current?.querySelector<HTMLLIElement>(`[data-citation-target="${id}"]`);
341
- node?.focus?.();
342
- node?.scrollIntoView?.({ block: "nearest" });
343
- };
344
-
345
- const toggle = (citation: Citation) => {
346
- setActiveId((prev) => {
347
- const next = prev === citation.id ? null : citation.id;
348
- if (next) {
349
- const idx = citations.findIndex((c) => c.id === citation.id) + 1;
350
- setAnnounce(t(language, "brain.citation.announce.open", { num: idx, title: citation.title }));
351
- focusListItem(citation.id);
352
- } else {
353
- setAnnounce(t(language, "brain.citation.announce.close"));
354
- }
355
- return next;
356
- });
357
- };
358
-
359
- const onChipKey = (event: React.KeyboardEvent<HTMLSpanElement>, citation: Citation) => {
360
- if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
361
- event.preventDefault();
362
- toggle(citation);
363
- } else if (event.key === "Escape" && activeId) {
364
- event.preventDefault();
365
- setActiveId(null);
366
- setAnnounce(t(language, "brain.citation.announce.close"));
367
- }
368
- };
369
-
370
- return (
371
- <div className="brain-message-cited">
372
- <div className="whitespace-pre-wrap brain-message-text">
373
- {segments.map((segment, index) => {
374
- const citation = segment.citation != null ? citations[segment.citation] : null;
375
- const num = segment.citation != null ? segment.citation + 1 : 0;
376
- return (
377
- <React.Fragment key={index}>
378
- {segment.text}
379
- {citation ? (
380
- <span
381
- role="button"
382
- tabIndex={0}
383
- className={`citation-chip ${activeId === citation.id ? "is-active" : ""}`}
384
- data-citation-id={citation.id}
385
- aria-pressed={activeId === citation.id}
386
- aria-label={t(language, "brain.citation.chip.long", { num, title: citation.title })}
387
- onClick={() => toggle(citation)}
388
- onKeyDown={(event) => onChipKey(event, citation)}
389
- >
390
- {t(language, "brain.citation.chip", { num })}
391
- </span>
392
- ) : null}
393
- </React.Fragment>
394
- );
395
- })}
396
- </div>
397
-
398
- <div className="brain-answer-proof" aria-label={t(language, "brain.citation.region")}>
399
- <div className="brain-answer-proof-head">
400
- <span>{t(language, "brain.answerProof.title")}</span>
401
- <strong>{t(language, "brain.citation.count", { count: citations.length })}</strong>
402
- </div>
403
- <ol ref={listRef}>
404
- {citations.map((citation, index) => {
405
- const isActive = activeId === citation.id;
406
- return (
407
- <li
408
- key={citation.id}
409
- data-citation-target={citation.id}
410
- className={isActive ? "is-cited" : ""}
411
- tabIndex={-1}
412
- aria-current={isActive ? "true" : undefined}
413
- >
414
- <span>{t(language, "brain.citation.preview.from")}{citation.source}</span>
415
- <strong>{t(language, "brain.citation.chip", { num: index + 1 })} · {citation.title}</strong>
416
- {citation.snippet ? (
417
- <small>{t(language, "brain.citation.preview.snippet")}{citation.snippet}</small>
418
- ) : null}
419
- {citation.score != null ? (
420
- <small className="brain-citation-score">{t(language, "brain.citation.score", { score: citation.score })}</small>
421
- ) : null}
422
- </li>
423
- );
424
- })}
425
- </ol>
426
- <small className="brain-citation-tip">{t(language, "brain.citation.keyboard.tip")}</small>
427
- </div>
428
-
429
- <div className="sr-only" role="status" aria-live="polite">{announce}</div>
430
- </div>
431
- );
432
- }
433
-
434
- function RecentConversations({
435
- conversations,
436
- result,
437
- activeId,
438
- onNew,
439
- onSelect,
440
- onDelete,
441
- }: {
442
- conversations: Array<Record<string, unknown>>;
443
- result?: { source?: string; ok?: boolean; status?: number; error?: string };
444
- activeId: string | null;
445
- onNew: () => void;
446
- onSelect: (id: string) => void;
447
- onDelete: (id: string) => void;
448
- }) {
449
- const language = useAppStore((state) => state.language);
450
- return (
451
- <section className="brain-side-panel">
452
- <div className="brain-side-head">
453
- <div>
454
- <h3>Recent conversations</h3>
455
- <SourceBadge result={result as Parameters<typeof SourceBadge>[0]["result"]} />
456
- </div>
457
- <Button variant="outline" size="sm" onClick={onNew}><Plus className="h-4 w-4" /> New</Button>
458
- </div>
459
- <div className="brain-conversation-list soft-scrollbar">
460
- {!result?.ok && result?.source === "unavailable" ? (
461
- <FeedbackState
462
- tone="error"
463
- language={language}
464
- title={t(language, "feedback.error.title")}
465
- body={result.error || t(language, "feedback.error.body")}
466
- onAction={() => window.location.reload()}
467
- />
468
- ) : conversations.length ? conversations.slice(0, 8).map((item) => {
469
- const id = String(item.id || item.conversation_id || "");
470
- return (
471
- <div key={id} className={`brain-conversation-item ${activeId === id ? "is-active" : ""}`}>
472
- <button onClick={() => onSelect(id)} className="min-w-0 text-left">
473
- <span>{String(item.title || id || "Conversation")}</span>
474
- <small>{String(item.updated_at || item.started_at || "")}</small>
475
- </button>
476
- <button className="brain-delete-button" onClick={() => onDelete(id)} aria-label="Delete conversation">
477
- <Trash2 className="h-3.5 w-3.5" />
478
- </button>
479
- </div>
480
- );
481
- }) : (
482
- <FeedbackState
483
- tone="empty"
484
- language={language}
485
- title="No conversations yet"
486
- body="New exchanges will appear here."
487
- actionLabel="Start a conversation"
488
- onAction={onNew}
489
- />
490
- )}
491
- </div>
492
- </section>
493
- );
494
- }
495
-
496
- function MemoryNearby({ question, trace }: { question: string; trace: unknown }) {
497
- const language = useAppStore((state) => state.language);
498
- const hybrid = useQuery({
499
- queryKey: ["brainNearbyMemory", question],
500
- queryFn: () => latticeApi.hybridSearch(question),
501
- enabled: question.trim().length > 2,
502
- });
503
- return (
504
- <section className="brain-side-panel">
505
- <div className="brain-side-head">
506
- <div>
507
- <h3>Memory nearby</h3>
508
- <SourceBadge result={hybrid.data} />
509
- </div>
510
- </div>
511
- {hybrid.data?.ok ? (
512
- <EntityList items={(hybrid.data.data as Record<string, unknown>).matches || hybrid.data.data} titleKey="title" metaKey="type" limit={4} />
513
- ) : hybrid.data?.source === "unavailable" ? (
514
- <FeedbackState
515
- tone="error"
516
- language={language}
517
- title={t(language, "feedback.error.title")}
518
- body={hybrid.data.error || t(language, "feedback.error.body")}
519
- onAction={() => void hybrid.refetch()}
520
- />
521
- ) : trace ? (
522
- <StructuredView value={trace} limit={4} />
523
- ) : (
524
- <FeedbackState
525
- tone="empty"
526
- language={language}
527
- title="Quiet for now"
528
- body="Relevant memory wakes up as the conversation gets specific."
529
- />
530
- )}
531
- </section>
532
- );
533
- }
@@ -1,99 +0,0 @@
1
- import * as React from "react";
2
- import { useQuery } from "@tanstack/react-query";
3
- import { ArrowRight, CheckCircle2, Cpu, Download, Layers3, Library, PlayCircle, SlidersHorizontal, UserCircle, Users } from "lucide-react";
4
- import { latticeApi } from "@/api/client";
5
- import { Badge } from "@/components/ui/badge";
6
- import { Button } from "@/components/ui/button";
7
- import { useAppStore } from "@/store/appStore";
8
- import { go } from "@/routes";
9
- import { asArray } from "@/lib/utils";
10
-
11
- function readDismissed() {
12
- try {
13
- return localStorage.getItem("lattice.onboarding.dismissed") === "true";
14
- } catch {}
15
- return false;
16
- }
17
-
18
- export function FirstRunGuide() {
19
- const [dismissed, setDismissed] = React.useState(readDismissed);
20
- const mode = useAppStore((state) => state.mode);
21
- const profile = useQuery({ queryKey: ["profile"], queryFn: latticeApi.profile });
22
- const workspace = useQuery({ queryKey: ["workspaceOs"], queryFn: latticeApi.workspaceOs });
23
- const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
24
- const recs = useQuery({ queryKey: ["modelRecommendations", "local_mlx"], queryFn: () => latticeApi.modelRecommendations("local_mlx") });
25
- if (dismissed) return null;
26
-
27
- const profileData = (profile.data?.data || {}) as Record<string, unknown>;
28
- const workspaceData = (workspace.data?.data || {}) as Record<string, unknown>;
29
- const registry = (workspaceData.workspace_registry || {}) as Record<string, unknown>;
30
- const modelData = (models.data?.data || {}) as Record<string, unknown>;
31
- const recommendationData = ((recs.data?.data as Record<string, unknown> | undefined)?.recommendations || {}) as Record<string, unknown>;
32
- const currentModel = String(modelData.current || "");
33
- const loadedModels = asArray(modelData.loaded);
34
- const topPick = recommendationData.top_pick as Record<string, unknown> | undefined;
35
- const compatProfiles = asArray<Record<string, unknown>>(modelData.compat_profiles);
36
- const readyProfile = compatProfiles.some((item) => item.chat_compatible || item.quality_status === "ok" || item.quality_status === "degraded");
37
-
38
- const steps = [
39
- { label: "Make it yours", done: Boolean(profileData.email), icon: UserCircle, action: "account", detail: "Choose the owner of this local AI Brain." },
40
- { label: "Choose a space", done: Boolean(registry.active_workspace || workspaceData.active_workspace), icon: Users, action: "workspace-admin", detail: "Decide where memories belong." },
41
- { label: "Meet your Mac", done: recs.isSuccess, icon: Cpu, action: "models", detail: "See what local Brain experience this computer can support." },
42
- { label: "Pick a voice", done: Boolean(topPick || currentModel), icon: Library, action: "models", detail: "Use the recommended model without rebuilding memory later." },
43
- { label: "Install with consent", done: Boolean(currentModel || loadedModels.length), icon: Download, action: "models", detail: "Download only after an explicit click." },
44
- { label: "Talk to Brain", done: Boolean(readyProfile || currentModel || loadedModels.length), icon: PlayCircle, action: "chat", detail: "Confirm the model can answer." },
45
- { label: "Set the pace", done: Boolean(mode), icon: SlidersHorizontal, action: "settings", detail: "Stay Calm or switch deeper." },
46
- { label: "Explore deeply", done: true, icon: Layers3, action: "knowledge-graph", detail: "Open advanced relationships." },
47
- ];
48
- const completed = steps.filter((step) => step.done).length;
49
- const nextStep = steps.find((step) => !step.done) || steps[steps.length - 1];
50
- const progress = Math.round((completed / steps.length) * 100);
51
-
52
- return (
53
- <section className="arrival-panel" aria-label="First 10 minutes">
54
- <div className="arrival-copy">
55
- <div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
56
- <h2>Start locally, with clear consent at each step.</h2>
57
- <p>
58
- Create the local Brain first, choose when to download a model, then add durable knowledge when you are ready.
59
- Nothing needs cloud access unless you explicitly choose it.
60
- </p>
61
- <div className="arrival-actions">
62
- <Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open relationships" : `Continue: ${nextStep.label}`}</Button>
63
- <Button variant="outline" onClick={() => go("models")}>Set up model</Button>
64
- <Button variant="ghost" onClick={() => {
65
- try { localStorage.setItem("lattice.onboarding.dismissed", "true"); } catch {}
66
- setDismissed(true);
67
- }}>
68
- Hide
69
- </Button>
70
- </div>
71
- </div>
72
- <div className="journey-panel">
73
- <div className="journey-head">
74
- <div>
75
- <div className="text-sm font-semibold">{completed} of {steps.length} ready</div>
76
- <div className="text-xs text-muted-foreground">{mode === "basic" ? "Calm mode" : `${mode} mode`}</div>
77
- </div>
78
- <Badge variant={progress === 100 ? "success" : "warning"}>{progress}%</Badge>
79
- </div>
80
- <div className="journey-progress"><span style={{ width: `${progress}%` }} /></div>
81
- <div className="journey-steps">
82
- {steps.map((step) => {
83
- const Icon = step.icon;
84
- return (
85
- <button key={step.label} onClick={() => go(step.action)} className="journey-step">
86
- <span className="journey-icon"><Icon className="h-4 w-4" /></span>
87
- <span className="min-w-0">
88
- <span className="block truncate text-sm font-semibold">{step.label}</span>
89
- <span className="block truncate text-xs text-muted-foreground">{step.detail}</span>
90
- </span>
91
- <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
92
- </button>
93
- );
94
- })}
95
- </div>
96
- </div>
97
- </section>
98
- );
99
- }