ltcai 4.6.1 → 4.7.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.
- package/README.md +60 -37
- package/docs/CHANGELOG.md +61 -0
- package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +20 -19
- package/frontend/src/App.tsx +449 -6
- package/frontend/src/api/client.ts +2 -0
- package/frontend/src/components/ProductFlow.tsx +28 -5
- package/frontend/src/styles.css +620 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +86 -13
- package/lattice_brain/portability.py +82 -14
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +30 -4
- package/latticeai/api/chat.py +25 -11
- package/latticeai/app_factory.py +8 -2
- package/latticeai/core/audit.py +3 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +1 -1
- package/scripts/launch-pts-grok.sh +56 -0
- 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 → index-DFmuiJ6t.css} +1 -1
- package/static/app/assets/index-DwX3rNfA.js +16 -0
- package/static/app/assets/index-DwX3rNfA.js.map +1 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-D1jAPQws.js +0 -16
- package/static/app/assets/index-D1jAPQws.js.map +0 -1
package/frontend/src/App.tsx
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
Activity,
|
|
5
|
+
Archive,
|
|
6
|
+
ArrowLeft,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
DatabaseBackup,
|
|
9
|
+
Download,
|
|
10
|
+
Eye,
|
|
11
|
+
ImagePlus,
|
|
12
|
+
RotateCcw,
|
|
13
|
+
Search,
|
|
14
|
+
Send,
|
|
15
|
+
ServerCog,
|
|
16
|
+
ShieldCheck,
|
|
17
|
+
Users,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import { latticeApi, type ApiResult } from "@/api/client";
|
|
5
20
|
import { Button } from "@/components/ui/button";
|
|
6
21
|
import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
|
|
7
22
|
import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
|
|
@@ -51,9 +66,16 @@ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
|
|
|
51
66
|
{ level: 5, label: "Knowledge Graph", state: "synthesizing" },
|
|
52
67
|
];
|
|
53
68
|
|
|
69
|
+
const STARTER_PROMPTS = [
|
|
70
|
+
"Remember this decision: ",
|
|
71
|
+
"What do I already know about ",
|
|
72
|
+
"Help me turn this project context into a plan: ",
|
|
73
|
+
];
|
|
74
|
+
|
|
54
75
|
export default function App() {
|
|
55
76
|
const theme = useAppStore((state) => state.theme);
|
|
56
77
|
const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
|
|
78
|
+
const route = useHashRoute();
|
|
57
79
|
const { state: brainState, intensity, setBrain } = useBrainState();
|
|
58
80
|
|
|
59
81
|
React.useEffect(() => {
|
|
@@ -78,11 +100,35 @@ export default function App() {
|
|
|
78
100
|
return (
|
|
79
101
|
<div className="brain-space">
|
|
80
102
|
<div className="brain-field" />
|
|
81
|
-
|
|
103
|
+
{route.startsWith("/admin") ? (
|
|
104
|
+
<AdminConsole onBack={() => navigateHash("/brain")} />
|
|
105
|
+
) : (
|
|
106
|
+
<BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
|
|
107
|
+
)}
|
|
82
108
|
</div>
|
|
83
109
|
);
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
function useHashRoute() {
|
|
113
|
+
const read = React.useCallback(() => {
|
|
114
|
+
const hash = window.location.hash.replace(/^#/, "");
|
|
115
|
+
return hash.startsWith("/") ? hash : "/brain";
|
|
116
|
+
}, []);
|
|
117
|
+
const [route, setRoute] = React.useState(read);
|
|
118
|
+
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
const onHashChange = () => setRoute(read());
|
|
121
|
+
window.addEventListener("hashchange", onHashChange);
|
|
122
|
+
return () => window.removeEventListener("hashchange", onHashChange);
|
|
123
|
+
}, [read]);
|
|
124
|
+
|
|
125
|
+
return route;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function navigateHash(route: string) {
|
|
129
|
+
window.location.hash = route;
|
|
130
|
+
}
|
|
131
|
+
|
|
86
132
|
function useBrainState() {
|
|
87
133
|
const [state, setState] = React.useState<BrainState>("idle");
|
|
88
134
|
const [intensity, setIntensity] = React.useState(0.58);
|
|
@@ -274,14 +320,33 @@ function BrainHome({
|
|
|
274
320
|
<h1>Lattice Brain</h1>
|
|
275
321
|
<span>{currentDepth.label}</span>
|
|
276
322
|
</div>
|
|
323
|
+
<div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
|
|
324
|
+
<span>Local-first</span>
|
|
325
|
+
<span>Portable</span>
|
|
326
|
+
<span>Private</span>
|
|
327
|
+
</div>
|
|
277
328
|
<div>{modelName}</div>
|
|
329
|
+
<button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
|
|
330
|
+
<ShieldCheck className="h-3.5 w-3.5" />
|
|
331
|
+
Admin
|
|
332
|
+
</button>
|
|
278
333
|
</div>
|
|
279
334
|
|
|
280
335
|
<div ref={streamRef} className="brain-stream">
|
|
281
336
|
{messages.length === 0 ? (
|
|
282
337
|
<div className="mind-empty">
|
|
283
|
-
<div className="mind-empty-kicker">
|
|
284
|
-
<div>
|
|
338
|
+
<div className="mind-empty-kicker">Your durable context</div>
|
|
339
|
+
<div className="mind-empty-title">Start with what should not be forgotten.</div>
|
|
340
|
+
<p>
|
|
341
|
+
Lattice keeps your documents, conversations, projects, and decisions available while models can change around them.
|
|
342
|
+
</p>
|
|
343
|
+
<div className="mind-empty-prompts" aria-label="Starter prompts">
|
|
344
|
+
{STARTER_PROMPTS.map((prompt) => (
|
|
345
|
+
<button key={prompt} type="button" onClick={() => setDraft(prompt)}>
|
|
346
|
+
{prompt}
|
|
347
|
+
</button>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
285
350
|
</div>
|
|
286
351
|
) : (
|
|
287
352
|
messages.map((message, index) => (
|
|
@@ -292,6 +357,8 @@ function BrainHome({
|
|
|
292
357
|
)}
|
|
293
358
|
</div>
|
|
294
359
|
|
|
360
|
+
<BrainCarePanel />
|
|
361
|
+
|
|
295
362
|
<div className="brain-composer">
|
|
296
363
|
<textarea
|
|
297
364
|
value={draft}
|
|
@@ -329,6 +396,318 @@ function BrainHome({
|
|
|
329
396
|
);
|
|
330
397
|
}
|
|
331
398
|
|
|
399
|
+
function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
400
|
+
const qc = useQueryClient();
|
|
401
|
+
const summaryQ = useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary });
|
|
402
|
+
const statsQ = useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats });
|
|
403
|
+
const usersQ = useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers });
|
|
404
|
+
const auditQ = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
|
|
405
|
+
const securityQ = useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity });
|
|
406
|
+
const securityEventsQ = useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) });
|
|
407
|
+
const policiesQ = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
|
|
408
|
+
const indexQ = useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus });
|
|
409
|
+
const rebuildIndex = useMutation({
|
|
410
|
+
mutationFn: latticeApi.rebuildIndex,
|
|
411
|
+
onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const users = asArray(usersQ.data?.data);
|
|
415
|
+
const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
|
|
416
|
+
const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
|
|
417
|
+
const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<main className="admin-console" aria-label="Lattice Admin">
|
|
421
|
+
<header className="admin-console-header">
|
|
422
|
+
<button className="admin-back-button" type="button" onClick={onBack}>
|
|
423
|
+
<ArrowLeft className="h-4 w-4" />
|
|
424
|
+
Brain
|
|
425
|
+
</button>
|
|
426
|
+
<div>
|
|
427
|
+
<span>Separate admin workspace</span>
|
|
428
|
+
<h1>Admin Console</h1>
|
|
429
|
+
<p>Users, logs, security, and Brain health stay out of the normal user experience.</p>
|
|
430
|
+
</div>
|
|
431
|
+
</header>
|
|
432
|
+
|
|
433
|
+
<section className="admin-metrics" aria-label="Admin overview">
|
|
434
|
+
<AdminMetric icon={<Users className="h-4 w-4" />} label="Users" value={String(users.length)} detail={sourceLabel(usersQ.data)} />
|
|
435
|
+
<AdminMetric
|
|
436
|
+
icon={<Activity className="h-4 w-4" />}
|
|
437
|
+
label="Recent logs"
|
|
438
|
+
value={String(auditEvents.length + securityEvents.length)}
|
|
439
|
+
detail={sourceLabel(auditQ.data)}
|
|
440
|
+
/>
|
|
441
|
+
<AdminMetric
|
|
442
|
+
icon={<ShieldCheck className="h-4 w-4" />}
|
|
443
|
+
label="Security"
|
|
444
|
+
value={adminStatusLabel(securityQ.data?.data, "status") || (securityQ.data?.ok ? "Ready" : "Unavailable")}
|
|
445
|
+
detail={sourceLabel(securityQ.data)}
|
|
446
|
+
/>
|
|
447
|
+
<AdminMetric
|
|
448
|
+
icon={<ServerCog className="h-4 w-4" />}
|
|
449
|
+
label="Brain index"
|
|
450
|
+
value={adminStatusLabel(indexQ.data?.data, "status") || (indexQ.data?.ok ? "Indexed" : "Unknown")}
|
|
451
|
+
detail={indexDetail(indexQ.data?.data)}
|
|
452
|
+
/>
|
|
453
|
+
</section>
|
|
454
|
+
|
|
455
|
+
<section className="admin-grid">
|
|
456
|
+
<AdminPanel title="User Directory" eyebrow="People">
|
|
457
|
+
<AdminList
|
|
458
|
+
items={users.slice(0, 8)}
|
|
459
|
+
empty="No users reported by the admin API."
|
|
460
|
+
render={(item) => {
|
|
461
|
+
const user = item as ApiRecord;
|
|
462
|
+
return (
|
|
463
|
+
<>
|
|
464
|
+
<strong>{stringValue(user.name || user.email || user.id, "Local user")}</strong>
|
|
465
|
+
<span>{stringValue(user.role || user.status || user.workspace_id, "member")}</span>
|
|
466
|
+
</>
|
|
467
|
+
);
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
</AdminPanel>
|
|
471
|
+
|
|
472
|
+
<AdminPanel title="Activity Logs" eyebrow="Audit">
|
|
473
|
+
<AdminList
|
|
474
|
+
items={auditEvents.slice(0, 8)}
|
|
475
|
+
empty="No recent audit events."
|
|
476
|
+
render={(item) => renderLogRow(item as ApiRecord)}
|
|
477
|
+
/>
|
|
478
|
+
</AdminPanel>
|
|
479
|
+
|
|
480
|
+
<AdminPanel title="Security Events" eyebrow="Protection">
|
|
481
|
+
<AdminList
|
|
482
|
+
items={securityEvents.slice(0, 8)}
|
|
483
|
+
empty="No security events reported."
|
|
484
|
+
render={(item) => renderLogRow(item as ApiRecord)}
|
|
485
|
+
/>
|
|
486
|
+
</AdminPanel>
|
|
487
|
+
|
|
488
|
+
<AdminPanel title="Brain Operations" eyebrow="Maintenance">
|
|
489
|
+
<div className="admin-operation">
|
|
490
|
+
<div>
|
|
491
|
+
<strong>{indexDetail(indexQ.data?.data)}</strong>
|
|
492
|
+
<span>{summaryText(summaryQ.data?.data) || summaryText(statsQ.data?.data) || "Local Brain services are separated from user chat."}</span>
|
|
493
|
+
</div>
|
|
494
|
+
<Button variant="outline" size="sm" disabled={rebuildIndex.isPending} onClick={() => rebuildIndex.mutate()}>
|
|
495
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
496
|
+
{rebuildIndex.isPending ? "Rebuilding" : "Rebuild index"}
|
|
497
|
+
</Button>
|
|
498
|
+
</div>
|
|
499
|
+
<div className="admin-policy-strip">
|
|
500
|
+
{policies.slice(0, 5).map((item, index) => {
|
|
501
|
+
const policy = item as ApiRecord;
|
|
502
|
+
return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.name || policy.id, "Policy")}</span>;
|
|
503
|
+
})}
|
|
504
|
+
{!policies.length ? <span>Policy API quiet</span> : null}
|
|
505
|
+
</div>
|
|
506
|
+
</AdminPanel>
|
|
507
|
+
</section>
|
|
508
|
+
</main>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
|
513
|
+
return (
|
|
514
|
+
<div className="admin-metric">
|
|
515
|
+
<div>{icon}</div>
|
|
516
|
+
<span>{label}</span>
|
|
517
|
+
<strong>{value}</strong>
|
|
518
|
+
<small>{detail}</small>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function AdminPanel({ eyebrow, title, children }: { eyebrow: string; title: string; children: React.ReactNode }) {
|
|
524
|
+
return (
|
|
525
|
+
<section className="admin-panel">
|
|
526
|
+
<div className="admin-panel-head">
|
|
527
|
+
<span>{eyebrow}</span>
|
|
528
|
+
<h2>{title}</h2>
|
|
529
|
+
</div>
|
|
530
|
+
{children}
|
|
531
|
+
</section>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function AdminList({ items, empty, render }: { items: unknown[]; empty: string; render: (item: unknown) => React.ReactNode }) {
|
|
536
|
+
if (!items.length) return <div className="admin-empty">{empty}</div>;
|
|
537
|
+
return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function BrainCarePanel() {
|
|
541
|
+
const qc = useQueryClient();
|
|
542
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
543
|
+
const [archivePath, setArchivePath] = React.useState("");
|
|
544
|
+
const [passphrase, setPassphrase] = React.useState("");
|
|
545
|
+
const [latestResult, setLatestResult] = React.useState<ApiResult | null>(null);
|
|
546
|
+
const portabilityQ = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
|
|
547
|
+
const backupHealthQ = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
|
|
548
|
+
const rememberResult = React.useCallback((result: ApiResult) => setLatestResult(result), []);
|
|
549
|
+
|
|
550
|
+
const exportGraph = useCareMutation(() => latticeApi.graphExport(), undefined, rememberResult);
|
|
551
|
+
const backupGraph = useCareMutation(() => latticeApi.graphBackup(), () => {
|
|
552
|
+
void qc.invalidateQueries({ queryKey: ["backupHealth"] });
|
|
553
|
+
void qc.invalidateQueries({ queryKey: ["portability"] });
|
|
554
|
+
}, rememberResult);
|
|
555
|
+
const archiveBrain = useCareMutation(
|
|
556
|
+
() => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase }),
|
|
557
|
+
() => void qc.invalidateQueries({ queryKey: ["backupHealth"] }),
|
|
558
|
+
rememberResult,
|
|
559
|
+
);
|
|
560
|
+
const inspectArchive = useCareMutation(() => latticeApi.brainArchiveInspect({
|
|
561
|
+
path: archivePath.trim(),
|
|
562
|
+
passphrase: passphrase || null,
|
|
563
|
+
}), undefined, rememberResult);
|
|
564
|
+
const restorePreview = useCareMutation(() => latticeApi.brainArchiveRestore({
|
|
565
|
+
path: archivePath.trim(),
|
|
566
|
+
passphrase,
|
|
567
|
+
dry_run: true,
|
|
568
|
+
confirm: false,
|
|
569
|
+
}), undefined, rememberResult);
|
|
570
|
+
|
|
571
|
+
const portableFormat = portabilityLabel(portabilityQ.data?.data);
|
|
572
|
+
const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
|
|
573
|
+
|
|
574
|
+
return (
|
|
575
|
+
<section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label="Care for my Brain">
|
|
576
|
+
<button
|
|
577
|
+
className="brain-care-summary"
|
|
578
|
+
type="button"
|
|
579
|
+
aria-expanded={expanded}
|
|
580
|
+
aria-controls="brain-care-details"
|
|
581
|
+
onClick={() => setExpanded((value) => !value)}
|
|
582
|
+
>
|
|
583
|
+
<span className="brain-care-summary-main">
|
|
584
|
+
<span><ShieldCheck className="h-3.5 w-3.5" /> Care for my Brain</span>
|
|
585
|
+
<strong>Own it locally. Keep it portable.</strong>
|
|
586
|
+
</span>
|
|
587
|
+
<div className="brain-care-proof" aria-label="Ownership model">
|
|
588
|
+
<span>Private</span>
|
|
589
|
+
<span>{portableFormat}</span>
|
|
590
|
+
<span>{backupStatus}</span>
|
|
591
|
+
</div>
|
|
592
|
+
<ChevronDown className="brain-care-toggle h-4 w-4" aria-hidden="true" />
|
|
593
|
+
</button>
|
|
594
|
+
|
|
595
|
+
{expanded ? (
|
|
596
|
+
<div id="brain-care-details" className="brain-care-details">
|
|
597
|
+
<div className="brain-care-actions">
|
|
598
|
+
<CareButton
|
|
599
|
+
icon={<Download className="h-3.5 w-3.5" />}
|
|
600
|
+
label="Export"
|
|
601
|
+
detail="Take it with you"
|
|
602
|
+
pending={exportGraph.isPending}
|
|
603
|
+
onClick={() => exportGraph.mutate()}
|
|
604
|
+
/>
|
|
605
|
+
<CareButton
|
|
606
|
+
icon={<DatabaseBackup className="h-3.5 w-3.5" />}
|
|
607
|
+
label="Backup"
|
|
608
|
+
detail="Save a copy"
|
|
609
|
+
pending={backupGraph.isPending}
|
|
610
|
+
onClick={() => backupGraph.mutate()}
|
|
611
|
+
/>
|
|
612
|
+
<CareButton
|
|
613
|
+
icon={<Archive className="h-3.5 w-3.5" />}
|
|
614
|
+
label="Archive"
|
|
615
|
+
detail="Encrypted Brain"
|
|
616
|
+
pending={archiveBrain.isPending}
|
|
617
|
+
disabled={!passphrase.trim()}
|
|
618
|
+
onClick={() => archiveBrain.mutate()}
|
|
619
|
+
/>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<div className="brain-care-archive">
|
|
623
|
+
<input
|
|
624
|
+
value={archivePath}
|
|
625
|
+
onChange={(event) => setArchivePath(event.target.value)}
|
|
626
|
+
placeholder="Paste an archive path to inspect or preview"
|
|
627
|
+
aria-label="Brain archive path"
|
|
628
|
+
/>
|
|
629
|
+
<input
|
|
630
|
+
type="password"
|
|
631
|
+
value={passphrase}
|
|
632
|
+
onChange={(event) => setPassphrase(event.target.value)}
|
|
633
|
+
placeholder="Archive passphrase"
|
|
634
|
+
aria-label="Brain archive passphrase"
|
|
635
|
+
/>
|
|
636
|
+
<div className="brain-care-archive-actions">
|
|
637
|
+
<Button
|
|
638
|
+
variant="outline"
|
|
639
|
+
size="sm"
|
|
640
|
+
disabled={!archivePath.trim() || inspectArchive.isPending}
|
|
641
|
+
onClick={() => inspectArchive.mutate()}
|
|
642
|
+
>
|
|
643
|
+
<Eye className="h-3.5 w-3.5" /> Inspect
|
|
644
|
+
</Button>
|
|
645
|
+
<Button
|
|
646
|
+
variant="outline"
|
|
647
|
+
size="sm"
|
|
648
|
+
disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
|
|
649
|
+
onClick={() => restorePreview.mutate()}
|
|
650
|
+
>
|
|
651
|
+
<RotateCcw className="h-3.5 w-3.5" /> Restore preview
|
|
652
|
+
</Button>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{latestResult ? (
|
|
657
|
+
<div className={`brain-care-result ${latestResult.ok ? "is-ok" : "is-error"}`} role="status">
|
|
658
|
+
{summarizeCareResult(latestResult)}
|
|
659
|
+
</div>
|
|
660
|
+
) : (
|
|
661
|
+
<p className="brain-care-note">
|
|
662
|
+
Restore preview checks an archive without changing your Brain. Confirmed restore stays in Settings.
|
|
663
|
+
</p>
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
) : null}
|
|
667
|
+
</section>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function useCareMutation<T extends ApiResult>(
|
|
672
|
+
mutationFn: () => Promise<T>,
|
|
673
|
+
onSuccess?: () => void,
|
|
674
|
+
onResult?: (result: T) => void,
|
|
675
|
+
) {
|
|
676
|
+
return useMutation({
|
|
677
|
+
mutationFn,
|
|
678
|
+
onSuccess: (result) => {
|
|
679
|
+
onResult?.(result);
|
|
680
|
+
onSuccess?.();
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function CareButton({
|
|
686
|
+
icon,
|
|
687
|
+
label,
|
|
688
|
+
detail,
|
|
689
|
+
pending,
|
|
690
|
+
disabled,
|
|
691
|
+
onClick,
|
|
692
|
+
}: {
|
|
693
|
+
icon: React.ReactNode;
|
|
694
|
+
label: string;
|
|
695
|
+
detail: string;
|
|
696
|
+
pending?: boolean;
|
|
697
|
+
disabled?: boolean;
|
|
698
|
+
onClick: () => void;
|
|
699
|
+
}) {
|
|
700
|
+
return (
|
|
701
|
+
<button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
|
|
702
|
+
{icon}
|
|
703
|
+
<span>
|
|
704
|
+
<strong>{pending ? "Working" : label}</strong>
|
|
705
|
+
<small>{detail}</small>
|
|
706
|
+
</span>
|
|
707
|
+
</button>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
332
711
|
function DepthEmergence({
|
|
333
712
|
depth,
|
|
334
713
|
memories,
|
|
@@ -633,6 +1012,70 @@ function currentModelName(data: unknown) {
|
|
|
633
1012
|
return firstLoaded ? textValue(firstLoaded, ["name", "id", "model_id"], "local mind") : "local mind";
|
|
634
1013
|
}
|
|
635
1014
|
|
|
1015
|
+
function portabilityLabel(data: unknown) {
|
|
1016
|
+
const record = isRecord(data) ? data : {};
|
|
1017
|
+
return textValue(record, ["archive_format", "format", "graph_schema_version", "schema_version"], ".latticebrain");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function backupHealthLabel(data: unknown) {
|
|
1021
|
+
const record = isRecord(data) ? data : {};
|
|
1022
|
+
const count = record.count || record.backups || record.available;
|
|
1023
|
+
if (count !== undefined && count !== null && count !== "") return `${count} backups`;
|
|
1024
|
+
return "Backups ready";
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function summarizeCareResult(result: ApiResult) {
|
|
1028
|
+
if (!result.ok) return result.error || "Brain care action could not complete.";
|
|
1029
|
+
const data = isRecord(result.data) ? result.data : {};
|
|
1030
|
+
const directMessage = textValue(data, ["message", "status", "path", "archive_path", "backup_path", "export_path"]);
|
|
1031
|
+
if (directMessage) return directMessage;
|
|
1032
|
+
return "Brain care action completed.";
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function renderLogRow(event: ApiRecord) {
|
|
1036
|
+
const action = stringValue(event.action || event.event || event.type || event.name, "Event");
|
|
1037
|
+
const actor = stringValue(event.actor || event.user || event.user_id || event.workspace_id, "system");
|
|
1038
|
+
const when = stringValue(event.timestamp || event.time || event.created_at || event.ts, "recently");
|
|
1039
|
+
return (
|
|
1040
|
+
<>
|
|
1041
|
+
<strong>{action}</strong>
|
|
1042
|
+
<span>{actor} · {when}</span>
|
|
1043
|
+
</>
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function sourceLabel(result?: ApiResult<unknown>) {
|
|
1048
|
+
if (!result) return "Loading";
|
|
1049
|
+
return result.ok ? "Live" : result.error || "Unavailable";
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function adminStatusLabel(data: unknown, key: string) {
|
|
1053
|
+
const record = isRecord(data) ? data : {};
|
|
1054
|
+
return textValue(record, [key, "health", "state", "overall_status"]);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function indexDetail(data: unknown) {
|
|
1058
|
+
const record = isRecord(data) ? data : {};
|
|
1059
|
+
const docs = record.documents ?? record.document_count ?? record.docs;
|
|
1060
|
+
const chunks = record.chunks ?? record.chunk_count ?? record.vectors;
|
|
1061
|
+
if (docs !== undefined || chunks !== undefined) {
|
|
1062
|
+
return `${stringValue(docs, "0")} docs · ${stringValue(chunks, "0")} chunks`;
|
|
1063
|
+
}
|
|
1064
|
+
return textValue(record, ["message", "detail", "status"], "Index status ready");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function summaryText(data: unknown) {
|
|
1068
|
+
const record = isRecord(data) ? data : {};
|
|
1069
|
+
return textValue(record, ["summary", "message", "status", "detail"]);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function stringValue(value: unknown, fallback = "") {
|
|
1073
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
1074
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1075
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1076
|
+
return fallback;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
636
1079
|
function fileToDataUrl(file: File) {
|
|
637
1080
|
return new Promise<string>((resolve, reject) => {
|
|
638
1081
|
const reader = new FileReader();
|
|
@@ -417,12 +417,14 @@ export const latticeApi = {
|
|
|
417
417
|
computerMemory: () => get("/workspace/computer-memory", {}),
|
|
418
418
|
setComputerMemory: (enabled: boolean) => post("/workspace/computer-memory", { enabled, consent: { approved: enabled } }, {}),
|
|
419
419
|
adminSummary: () => get("/admin/summary", {}),
|
|
420
|
+
adminStats: () => get("/admin/stats", {}),
|
|
420
421
|
adminUsers: () => get("/admin/users", []),
|
|
421
422
|
adminAudit: () => get("/admin/audit", { recent_events: [] }),
|
|
422
423
|
adminRoles: () => get("/admin/roles", { roles: [] }),
|
|
423
424
|
adminPolicies: () => get("/admin/policies", { policies: [] }),
|
|
424
425
|
adminProductHardening: () => get("/admin/product-hardening", {}),
|
|
425
426
|
adminSecurity: () => get("/admin/security/overview", {}),
|
|
427
|
+
adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),
|
|
426
428
|
vpcStatus: () => get("/vpc/status", {}),
|
|
427
429
|
toolPermissions: () => get("/tools/permissions", { permissions: [] }),
|
|
428
430
|
};
|
|
@@ -178,7 +178,11 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
|
|
|
178
178
|
return (
|
|
179
179
|
<div>
|
|
180
180
|
<div className="ritual-title">Welcome to your mind.</div>
|
|
181
|
-
<div className="ritual-subtitle">
|
|
181
|
+
<div className="ritual-subtitle">
|
|
182
|
+
Models will change. Your knowledge should not. Lattice keeps your documents, conversations, decisions, and context together as a private Brain you own.
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<ProductPromise />
|
|
182
186
|
|
|
183
187
|
<form onSubmit={submit} className="ritual-card" style={{ maxWidth: 420, margin: "0 auto" }}>
|
|
184
188
|
<div style={{ display: "grid", gap: "0.85rem" }}>
|
|
@@ -202,13 +206,32 @@ function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
|
|
|
202
206
|
{busy ? "Opening the Brain..." : "Open my Brain"}
|
|
203
207
|
</Button>
|
|
204
208
|
<div style={{ fontSize: "0.75rem", color: "hsl(var(--fg-muted))", marginTop: "0.6rem" }}>
|
|
205
|
-
|
|
209
|
+
Local profile first. Model choice comes later.
|
|
206
210
|
</div>
|
|
207
211
|
</form>
|
|
208
212
|
</div>
|
|
209
213
|
);
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
function ProductPromise() {
|
|
217
|
+
return (
|
|
218
|
+
<div className="ritual-promise" aria-label="Lattice AI product promise">
|
|
219
|
+
<div>
|
|
220
|
+
<span>Durable knowledge</span>
|
|
221
|
+
<strong>Your work becomes long-lived context.</strong>
|
|
222
|
+
</div>
|
|
223
|
+
<div>
|
|
224
|
+
<span>Replaceable models</span>
|
|
225
|
+
<strong>The model is a voice, not the asset.</strong>
|
|
226
|
+
</div>
|
|
227
|
+
<div>
|
|
228
|
+
<span>User ownership</span>
|
|
229
|
+
<strong>Back up, restore, and move your Brain.</strong>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
212
235
|
function AnalysisScreen({
|
|
213
236
|
analysis,
|
|
214
237
|
error,
|
|
@@ -223,7 +246,7 @@ function AnalysisScreen({
|
|
|
223
246
|
<div>
|
|
224
247
|
<div className="ritual-title">Understanding your home.</div>
|
|
225
248
|
<div className="ritual-subtitle">
|
|
226
|
-
|
|
249
|
+
Lattice is checking what this computer can support so your Brain can run locally instead of turning your memories into a cloud dependency.
|
|
227
250
|
</div>
|
|
228
251
|
|
|
229
252
|
<div className="ritual-fact-grid">
|
|
@@ -273,7 +296,7 @@ function RecommendationScreen({
|
|
|
273
296
|
<div>
|
|
274
297
|
<div className="ritual-title">How shall your mind think today?</div>
|
|
275
298
|
<div className="ritual-subtitle">
|
|
276
|
-
|
|
299
|
+
The model is the current voice of your Brain. You can replace it later; your knowledge stays.
|
|
277
300
|
</div>
|
|
278
301
|
|
|
279
302
|
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
|
@@ -365,7 +388,7 @@ function InstallScreen({
|
|
|
365
388
|
<div className="ritual-title">Bring this mind home.</div>
|
|
366
389
|
<div className="ritual-subtitle">
|
|
367
390
|
<strong>{model.shortName}</strong> — {model.reason}.<br />
|
|
368
|
-
|
|
391
|
+
This gives your Brain a local voice. Download, validation, and loading happen only with your consent.
|
|
369
392
|
</div>
|
|
370
393
|
|
|
371
394
|
{/* Living Brain reacts to the ceremony of installation */}
|