ltcai 4.6.1 → 4.7.2

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 (34) hide show
  1. package/README.md +74 -40
  2. package/docs/CHANGELOG.md +141 -0
  3. package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
  4. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
  5. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
  6. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
  8. package/frontend/src/App.tsx +627 -8
  9. package/frontend/src/api/client.ts +11 -1
  10. package/frontend/src/components/ProductFlow.tsx +106 -51
  11. package/frontend/src/pages/System.tsx +1 -1
  12. package/frontend/src/styles.css +905 -81
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/archive.py +86 -13
  15. package/lattice_brain/portability.py +82 -14
  16. package/lattice_brain/runtime/multi_agent.py +1 -1
  17. package/latticeai/__init__.py +1 -1
  18. package/latticeai/api/admin.py +141 -6
  19. package/latticeai/api/chat.py +35 -13
  20. package/latticeai/app_factory.py +8 -4
  21. package/latticeai/core/audit.py +3 -2
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/package.json +2 -1
  25. package/src-tauri/Cargo.lock +1 -1
  26. package/src-tauri/Cargo.toml +1 -1
  27. package/src-tauri/tauri.conf.json +1 -1
  28. package/static/app/asset-manifest.json +5 -5
  29. package/static/app/assets/index-DdAB4yfa.js +16 -0
  30. package/static/app/assets/index-DdAB4yfa.js.map +1 -0
  31. package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
  32. package/static/app/index.html +2 -2
  33. package/static/app/assets/index-D1jAPQws.js +0 -16
  34. package/static/app/assets/index-D1jAPQws.js.map +0 -1
@@ -1,7 +1,23 @@
1
1
  import * as React from "react";
2
- import { useQuery, useQueryClient } from "@tanstack/react-query";
3
- import { ImagePlus, Search, Send } from "lucide-react";
4
- import { latticeApi } from "@/api/client";
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
+ ListFilter,
13
+ RotateCcw,
14
+ Search,
15
+ Send,
16
+ ServerCog,
17
+ ShieldCheck,
18
+ Users,
19
+ } from "lucide-react";
20
+ import { latticeApi, type AdminAuditFilters, type ApiResult } from "@/api/client";
5
21
  import { Button } from "@/components/ui/button";
6
22
  import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
7
23
  import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
@@ -10,6 +26,7 @@ import { asArray } from "@/lib/utils";
10
26
 
11
27
  type ApiRecord = Record<string, unknown>;
12
28
  type BrainDepth = 1 | 2 | 3 | 4 | 5;
29
+ type AdminFilterState = Required<Pick<AdminAuditFilters, "q" | "actor" | "action" | "severity">> & { limit: number };
13
30
 
14
31
  type Message = {
15
32
  role: "user" | "assistant";
@@ -51,9 +68,16 @@ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
51
68
  { level: 5, label: "Knowledge Graph", state: "synthesizing" },
52
69
  ];
53
70
 
71
+ const STARTER_PROMPTS = [
72
+ "Remember this decision: ",
73
+ "What do I already know about ",
74
+ "Help me turn this project context into a plan: ",
75
+ ];
76
+
54
77
  export default function App() {
55
78
  const theme = useAppStore((state) => state.theme);
56
79
  const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
80
+ const route = useHashRoute();
57
81
  const { state: brainState, intensity, setBrain } = useBrainState();
58
82
 
59
83
  React.useEffect(() => {
@@ -78,11 +102,35 @@ export default function App() {
78
102
  return (
79
103
  <div className="brain-space">
80
104
  <div className="brain-field" />
81
- <BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
105
+ {route.startsWith("/admin") ? (
106
+ <AdminConsole onBack={() => navigateHash("/brain")} />
107
+ ) : (
108
+ <BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
109
+ )}
82
110
  </div>
83
111
  );
84
112
  }
85
113
 
114
+ function useHashRoute() {
115
+ const read = React.useCallback(() => {
116
+ const hash = window.location.hash.replace(/^#/, "");
117
+ return hash.startsWith("/") ? hash : "/brain";
118
+ }, []);
119
+ const [route, setRoute] = React.useState(read);
120
+
121
+ React.useEffect(() => {
122
+ const onHashChange = () => setRoute(read());
123
+ window.addEventListener("hashchange", onHashChange);
124
+ return () => window.removeEventListener("hashchange", onHashChange);
125
+ }, [read]);
126
+
127
+ return route;
128
+ }
129
+
130
+ function navigateHash(route: string) {
131
+ window.location.hash = route;
132
+ }
133
+
86
134
  function useBrainState() {
87
135
  const [state, setState] = React.useState<BrainState>("idle");
88
136
  const [intensity, setIntensity] = React.useState(0.58);
@@ -113,6 +161,7 @@ function BrainHome({
113
161
  const [explorationDepth, setExplorationDepth] = React.useState<BrainDepth>(1);
114
162
  const [graphSearch, setGraphSearch] = React.useState("");
115
163
  const [selectedGraphId, setSelectedGraphId] = React.useState<string | null>(null);
164
+ const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
116
165
  const streamRef = React.useRef<HTMLDivElement>(null);
117
166
  const recallTimerRef = React.useRef<number | null>(null);
118
167
 
@@ -164,6 +213,7 @@ function BrainHome({
164
213
  setDraft("");
165
214
  setImageData(null);
166
215
  setStreaming(true);
216
+ setMemoryFeedback(null);
167
217
  onBrainChange("thinking", 0.96);
168
218
 
169
219
  try {
@@ -192,6 +242,8 @@ function BrainHome({
192
242
  next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
193
243
  return next;
194
244
  });
245
+ } else {
246
+ setMemoryFeedback(`기억에 저장됨 · 연결된 주제 ${knowledgeConcepts.length}개 · 관련 기억 ${memoryFragments.length}개`);
195
247
  }
196
248
  } finally {
197
249
  setStreaming(false);
@@ -211,6 +263,13 @@ function BrainHome({
211
263
  });
212
264
  }
213
265
 
266
+ function jumpToDepth(next: BrainDepth) {
267
+ setExplorationDepth(next);
268
+ const nextDepth = DEPTHS[next - 1];
269
+ onBrainChange(nextDepth.state, next === 1 ? 0.58 : 0.66 + next * 0.06);
270
+ if (next >= 2) triggerBrainRecall();
271
+ }
272
+
214
273
  function surface() {
215
274
  setExplorationDepth(1);
216
275
  setSelectedGraphId(null);
@@ -223,7 +282,7 @@ function BrainHome({
223
282
  setExplorationDepth((depth) => Math.max(depth, 2) as BrainDepth);
224
283
  setMessages((items) => [
225
284
  ...items,
226
- { role: "assistant", content: `I am recalling ${fragment.kind.toLowerCase()}: ${fragment.title}` },
285
+ { role: "assistant", content: `기억을 다시 꺼냈습니다: ${fragment.title}` },
227
286
  ]);
228
287
  }
229
288
 
@@ -245,6 +304,13 @@ function BrainHome({
245
304
  <strong>{currentDepth.label}</strong>
246
305
  </div>
247
306
 
307
+ <div className="brain-depth-actions" aria-label="Brain quick views">
308
+ <button type="button" className={explorationDepth === 2 ? "is-active" : ""} onClick={() => jumpToDepth(2)}>기억 보기</button>
309
+ <button type="button" className={explorationDepth === 3 ? "is-active" : ""} onClick={() => jumpToDepth(3)}>주제 보기</button>
310
+ <button type="button" className={explorationDepth === 4 ? "is-active" : ""} onClick={() => jumpToDepth(4)}>관계 보기</button>
311
+ <button type="button" className={explorationDepth === 5 ? "is-active" : ""} onClick={() => jumpToDepth(5)}>그래프로 보기</button>
312
+ </div>
313
+
248
314
  <div className="brain-field-layer" aria-hidden={explorationDepth < 2}>
249
315
  <DepthEmergence
250
316
  depth={explorationDepth}
@@ -274,14 +340,38 @@ function BrainHome({
274
340
  <h1>Lattice Brain</h1>
275
341
  <span>{currentDepth.label}</span>
276
342
  </div>
343
+ <div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
344
+ <span>Local-first</span>
345
+ <span>Portable</span>
346
+ <span>Private</span>
347
+ </div>
277
348
  <div>{modelName}</div>
349
+ <button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
350
+ <ShieldCheck className="h-3.5 w-3.5" />
351
+ Admin
352
+ </button>
278
353
  </div>
279
354
 
280
355
  <div ref={streamRef} className="brain-stream">
356
+ <BrainOverviewPanel
357
+ memories={memoryFragments}
358
+ concepts={knowledgeConcepts}
359
+ onOpenDepth={jumpToDepth}
360
+ />
281
361
  {messages.length === 0 ? (
282
362
  <div className="mind-empty">
283
- <div className="mind-empty-kicker">Begin</div>
284
- <div>What are you thinking about?</div>
363
+ <div className="mind-empty-kicker">내 오래가는 기억</div>
364
+ <div className="mind-empty-title">잊으면 되는 일부터 말해 주세요.</div>
365
+ <p>
366
+ 문서, 대화, 프로젝트, 결정이 Brain에 쌓이고 나중에 주제와 관계로 다시 보입니다.
367
+ </p>
368
+ <div className="mind-empty-prompts" aria-label="Starter prompts">
369
+ {STARTER_PROMPTS.map((prompt) => (
370
+ <button key={prompt} type="button" onClick={() => setDraft(prompt)}>
371
+ {prompt}
372
+ </button>
373
+ ))}
374
+ </div>
285
375
  </div>
286
376
  ) : (
287
377
  messages.map((message, index) => (
@@ -292,6 +382,10 @@ function BrainHome({
292
382
  )}
293
383
  </div>
294
384
 
385
+ {memoryFeedback ? <div className="brain-save-feedback" role="status">{memoryFeedback}</div> : null}
386
+
387
+ <BrainCarePanel />
388
+
295
389
  <div className="brain-composer">
296
390
  <textarea
297
391
  value={draft}
@@ -329,6 +423,397 @@ function BrainHome({
329
423
  );
330
424
  }
331
425
 
426
+ function AdminConsole({ onBack }: { onBack: () => void }) {
427
+ const qc = useQueryClient();
428
+ const [filters, setFilters] = React.useState<AdminFilterState>({ q: "", actor: "", action: "", severity: "", limit: 50 });
429
+ const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ } = useAdminConsoleData(filters);
430
+ const rebuildIndex = useMutation({
431
+ mutationFn: latticeApi.rebuildIndex,
432
+ onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
433
+ });
434
+
435
+ const users = asArray(usersQ.data?.data);
436
+ const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
437
+ const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
438
+ const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
439
+ const roles = asArray((rolesQ.data?.data as ApiRecord | undefined)?.roles);
440
+ const retention = (retentionQ.data?.data || {}) as ApiRecord;
441
+
442
+ return (
443
+ <main className="admin-console" aria-label="Lattice Admin">
444
+ <header className="admin-console-header">
445
+ <button className="admin-back-button" type="button" onClick={onBack}>
446
+ <ArrowLeft className="h-4 w-4" />
447
+ Brain
448
+ </button>
449
+ <div>
450
+ <span>Separate admin workspace</span>
451
+ <h1>Admin Console</h1>
452
+ <p>Users, logs, security, and Brain health stay out of the normal user experience.</p>
453
+ </div>
454
+ </header>
455
+
456
+ <section className="admin-metrics" aria-label="Admin overview">
457
+ <AdminMetric icon={<Users className="h-4 w-4" />} label="Users" value={String(users.length)} detail={sourceLabel(usersQ.data)} />
458
+ <AdminMetric
459
+ icon={<Activity className="h-4 w-4" />}
460
+ label="Recent logs"
461
+ value={String(auditEvents.length + securityEvents.length)}
462
+ detail={sourceLabel(auditQ.data)}
463
+ />
464
+ <AdminMetric
465
+ icon={<ShieldCheck className="h-4 w-4" />}
466
+ label="Security"
467
+ value={adminStatusLabel(securityQ.data?.data, "status") || (securityQ.data?.ok ? "Ready" : "Unavailable")}
468
+ detail={sourceLabel(securityQ.data)}
469
+ />
470
+ <AdminMetric
471
+ icon={<ServerCog className="h-4 w-4" />}
472
+ label="Brain index"
473
+ value={adminStatusLabel(indexQ.data?.data, "status") || (indexQ.data?.ok ? "Indexed" : "Unknown")}
474
+ detail={indexDetail(indexQ.data?.data)}
475
+ />
476
+ </section>
477
+
478
+ <section className="admin-grid">
479
+ <AdminPanel title="User Directory" eyebrow="People">
480
+ <AdminList
481
+ items={users.slice(0, 8)}
482
+ empty="No users reported by the admin API."
483
+ render={(item) => {
484
+ const user = item as ApiRecord;
485
+ return (
486
+ <>
487
+ <strong>{stringValue(user.name || user.email || user.id, "Local user")}</strong>
488
+ <span>{stringValue(user.role || user.status || user.workspace_id, "member")}</span>
489
+ </>
490
+ );
491
+ }}
492
+ />
493
+ </AdminPanel>
494
+
495
+ <AdminPanel title="Role Permissions" eyebrow="Access">
496
+ <AdminList
497
+ items={roles.slice(0, 6)}
498
+ empty="No role matrix reported."
499
+ render={(item) => {
500
+ const role = item as ApiRecord;
501
+ return (
502
+ <>
503
+ <strong>{stringValue(role.role, "role")} · {stringValue(role.members, "0")} users</strong>
504
+ <span>{asArray(role.caps).slice(0, 4).map((cap) => stringValue(cap, "")).filter(Boolean).join(", ") || "No caps"}</span>
505
+ </>
506
+ );
507
+ }}
508
+ />
509
+ </AdminPanel>
510
+
511
+ <AdminPanel title="Activity Logs" eyebrow="Audit">
512
+ <AdminLogFilters filters={filters} onChange={setFilters} matched={(auditQ.data?.data as ApiRecord | undefined)?.filters as ApiRecord | undefined} />
513
+ <AdminList
514
+ items={auditEvents.slice(0, 8)}
515
+ empty="No recent audit events."
516
+ render={(item) => renderLogRow(item as ApiRecord)}
517
+ />
518
+ </AdminPanel>
519
+
520
+ <AdminPanel title="Security Events" eyebrow="Protection">
521
+ <AdminList
522
+ items={securityEvents.slice(0, 8)}
523
+ empty="No security events reported."
524
+ render={(item) => renderLogRow(item as ApiRecord)}
525
+ />
526
+ </AdminPanel>
527
+
528
+ <AdminPanel title="Brain Operations" eyebrow="Maintenance">
529
+ <div className="admin-operation">
530
+ <div>
531
+ <strong>{indexDetail(indexQ.data?.data)}</strong>
532
+ <span>{summaryText(summaryQ.data?.data) || summaryText(statsQ.data?.data) || "Local Brain services are separated from user chat."}</span>
533
+ </div>
534
+ <Button variant="outline" size="sm" disabled={rebuildIndex.isPending} onClick={() => rebuildIndex.mutate()}>
535
+ <RotateCcw className="h-3.5 w-3.5" />
536
+ {rebuildIndex.isPending ? "Rebuilding" : "Rebuild index"}
537
+ </Button>
538
+ </div>
539
+ <div className="admin-policy-strip">
540
+ {policies.slice(0, 5).map((item, index) => {
541
+ const policy = item as ApiRecord;
542
+ return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.label || policy.name || policy.id, "Policy")}</span>;
543
+ })}
544
+ {!policies.length ? <span>Policy API quiet</span> : null}
545
+ </div>
546
+ <div className="admin-retention">
547
+ <strong>{stringValue(retention.retention_days, "90")} day retention</strong>
548
+ <span>{stringValue(retention.retained_events, "0")} retained · {stringValue(retention.prune_candidates, "0")} ready for export/prune review</span>
549
+ </div>
550
+ </AdminPanel>
551
+ </section>
552
+ </main>
553
+ );
554
+ }
555
+
556
+ function useAdminConsoleData(filters: AdminFilterState) {
557
+ const auditFilters = React.useMemo<AdminAuditFilters>(() => ({
558
+ q: filters.q || undefined,
559
+ actor: filters.actor || undefined,
560
+ action: filters.action || undefined,
561
+ severity: filters.severity || undefined,
562
+ limit: filters.limit,
563
+ }), [filters]);
564
+
565
+ return {
566
+ summaryQ: useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary }),
567
+ statsQ: useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats }),
568
+ usersQ: useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers }),
569
+ auditQ: useQuery({ queryKey: ["adminAudit", auditFilters], queryFn: () => latticeApi.adminAudit(auditFilters) }),
570
+ securityQ: useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity }),
571
+ securityEventsQ: useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) }),
572
+ policiesQ: useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies }),
573
+ rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
574
+ retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
575
+ indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
576
+ };
577
+ }
578
+
579
+ function AdminLogFilters({
580
+ filters,
581
+ matched,
582
+ onChange,
583
+ }: {
584
+ filters: AdminFilterState;
585
+ matched?: ApiRecord;
586
+ onChange: React.Dispatch<React.SetStateAction<AdminFilterState>>;
587
+ }) {
588
+ return (
589
+ <div className="admin-log-filters" aria-label="Audit log filters">
590
+ <label>
591
+ <Search className="h-3.5 w-3.5" />
592
+ <input
593
+ value={filters.q}
594
+ onChange={(event) => onChange((current) => ({ ...current, q: event.target.value }))}
595
+ placeholder="Search logs"
596
+ aria-label="Search audit logs"
597
+ />
598
+ </label>
599
+ <label>
600
+ <ListFilter className="h-3.5 w-3.5" />
601
+ <select
602
+ value={filters.severity}
603
+ onChange={(event) => onChange((current) => ({ ...current, severity: event.target.value }))}
604
+ aria-label="Filter by severity"
605
+ >
606
+ <option value="">All severities</option>
607
+ <option value="informational">Informational</option>
608
+ <option value="notice">Notice</option>
609
+ <option value="warning">Warning</option>
610
+ <option value="high">High</option>
611
+ </select>
612
+ </label>
613
+ <span>{stringValue(matched?.matched_events, "0")} matched</span>
614
+ </div>
615
+ );
616
+ }
617
+
618
+ function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
619
+ return (
620
+ <div className="admin-metric">
621
+ <div>{icon}</div>
622
+ <span>{label}</span>
623
+ <strong>{value}</strong>
624
+ <small>{detail}</small>
625
+ </div>
626
+ );
627
+ }
628
+
629
+ function AdminPanel({ eyebrow, title, children }: { eyebrow: string; title: string; children: React.ReactNode }) {
630
+ return (
631
+ <section className="admin-panel">
632
+ <div className="admin-panel-head">
633
+ <span>{eyebrow}</span>
634
+ <h2>{title}</h2>
635
+ </div>
636
+ {children}
637
+ </section>
638
+ );
639
+ }
640
+
641
+ function AdminList({ items, empty, render }: { items: unknown[]; empty: string; render: (item: unknown) => React.ReactNode }) {
642
+ if (!items.length) return <div className="admin-empty">{empty}</div>;
643
+ return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
644
+ }
645
+
646
+ function BrainCarePanel() {
647
+ const qc = useQueryClient();
648
+ const [expanded, setExpanded] = React.useState(false);
649
+ const [archivePath, setArchivePath] = React.useState("");
650
+ const [passphrase, setPassphrase] = React.useState("");
651
+ const [latestResult, setLatestResult] = React.useState<ApiResult | null>(null);
652
+ const portabilityQ = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
653
+ const backupHealthQ = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
654
+ const rememberResult = React.useCallback((result: ApiResult) => setLatestResult(result), []);
655
+
656
+ const exportGraph = useCareMutation(() => latticeApi.graphExport(), undefined, rememberResult);
657
+ const backupGraph = useCareMutation(() => latticeApi.graphBackup(), () => {
658
+ void qc.invalidateQueries({ queryKey: ["backupHealth"] });
659
+ void qc.invalidateQueries({ queryKey: ["portability"] });
660
+ }, rememberResult);
661
+ const archiveBrain = useCareMutation(
662
+ () => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase }),
663
+ () => void qc.invalidateQueries({ queryKey: ["backupHealth"] }),
664
+ rememberResult,
665
+ );
666
+ const inspectArchive = useCareMutation(() => latticeApi.brainArchiveInspect({
667
+ path: archivePath.trim(),
668
+ passphrase: passphrase || null,
669
+ }), undefined, rememberResult);
670
+ const restorePreview = useCareMutation(() => latticeApi.brainArchiveRestore({
671
+ path: archivePath.trim(),
672
+ passphrase,
673
+ dry_run: true,
674
+ confirm: false,
675
+ }), undefined, rememberResult);
676
+
677
+ const portableFormat = portabilityLabel(portabilityQ.data?.data);
678
+ const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
679
+
680
+ return (
681
+ <section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label="Care for my Brain">
682
+ <button
683
+ className="brain-care-summary"
684
+ type="button"
685
+ aria-expanded={expanded}
686
+ aria-controls="brain-care-details"
687
+ onClick={() => setExpanded((value) => !value)}
688
+ >
689
+ <span className="brain-care-summary-main">
690
+ <span><ShieldCheck className="h-3.5 w-3.5" /> Care for my Brain</span>
691
+ <strong>Own it locally. Keep it portable.</strong>
692
+ </span>
693
+ <div className="brain-care-proof" aria-label="Ownership model">
694
+ <span>Private</span>
695
+ <span>{portableFormat}</span>
696
+ <span>{backupStatus}</span>
697
+ </div>
698
+ <ChevronDown className="brain-care-toggle h-4 w-4" aria-hidden="true" />
699
+ </button>
700
+
701
+ {expanded ? (
702
+ <div id="brain-care-details" className="brain-care-details">
703
+ <div className="brain-care-actions">
704
+ <CareButton
705
+ icon={<Download className="h-3.5 w-3.5" />}
706
+ label="Export"
707
+ detail="Take it with you"
708
+ pending={exportGraph.isPending}
709
+ onClick={() => exportGraph.mutate()}
710
+ />
711
+ <CareButton
712
+ icon={<DatabaseBackup className="h-3.5 w-3.5" />}
713
+ label="Backup"
714
+ detail="Save a copy"
715
+ pending={backupGraph.isPending}
716
+ onClick={() => backupGraph.mutate()}
717
+ />
718
+ <CareButton
719
+ icon={<Archive className="h-3.5 w-3.5" />}
720
+ label="Archive"
721
+ detail="Encrypted Brain"
722
+ pending={archiveBrain.isPending}
723
+ disabled={!passphrase.trim()}
724
+ onClick={() => archiveBrain.mutate()}
725
+ />
726
+ </div>
727
+
728
+ <div className="brain-care-archive">
729
+ <input
730
+ value={archivePath}
731
+ onChange={(event) => setArchivePath(event.target.value)}
732
+ placeholder="Paste an archive path to inspect or preview"
733
+ aria-label="Brain archive path"
734
+ />
735
+ <input
736
+ type="password"
737
+ value={passphrase}
738
+ onChange={(event) => setPassphrase(event.target.value)}
739
+ placeholder="Archive passphrase"
740
+ aria-label="Brain archive passphrase"
741
+ />
742
+ <div className="brain-care-archive-actions">
743
+ <Button
744
+ variant="outline"
745
+ size="sm"
746
+ disabled={!archivePath.trim() || inspectArchive.isPending}
747
+ onClick={() => inspectArchive.mutate()}
748
+ >
749
+ <Eye className="h-3.5 w-3.5" /> Inspect
750
+ </Button>
751
+ <Button
752
+ variant="outline"
753
+ size="sm"
754
+ disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
755
+ onClick={() => restorePreview.mutate()}
756
+ >
757
+ <RotateCcw className="h-3.5 w-3.5" /> Restore preview
758
+ </Button>
759
+ </div>
760
+ </div>
761
+
762
+ {latestResult ? (
763
+ <div className={`brain-care-result ${latestResult.ok ? "is-ok" : "is-error"}`} role="status">
764
+ {summarizeCareResult(latestResult)}
765
+ </div>
766
+ ) : (
767
+ <p className="brain-care-note">
768
+ Restore preview checks an archive without changing your Brain. Confirmed restore stays in Settings.
769
+ </p>
770
+ )}
771
+ </div>
772
+ ) : null}
773
+ </section>
774
+ );
775
+ }
776
+
777
+ function useCareMutation<T extends ApiResult>(
778
+ mutationFn: () => Promise<T>,
779
+ onSuccess?: () => void,
780
+ onResult?: (result: T) => void,
781
+ ) {
782
+ return useMutation({
783
+ mutationFn,
784
+ onSuccess: (result) => {
785
+ onResult?.(result);
786
+ onSuccess?.();
787
+ },
788
+ });
789
+ }
790
+
791
+ function CareButton({
792
+ icon,
793
+ label,
794
+ detail,
795
+ pending,
796
+ disabled,
797
+ onClick,
798
+ }: {
799
+ icon: React.ReactNode;
800
+ label: string;
801
+ detail: string;
802
+ pending?: boolean;
803
+ disabled?: boolean;
804
+ onClick: () => void;
805
+ }) {
806
+ return (
807
+ <button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
808
+ {icon}
809
+ <span>
810
+ <strong>{pending ? "Working" : label}</strong>
811
+ <small>{detail}</small>
812
+ </span>
813
+ </button>
814
+ );
815
+ }
816
+
332
817
  function DepthEmergence({
333
818
  depth,
334
819
  memories,
@@ -378,6 +863,75 @@ function DepthEmergence({
378
863
  );
379
864
  }
380
865
 
866
+ function BrainOverviewPanel({
867
+ memories,
868
+ concepts,
869
+ onOpenDepth,
870
+ }: {
871
+ memories: MemoryFragment[];
872
+ concepts: KnowledgeConcept[];
873
+ onOpenDepth: (depth: BrainDepth) => void;
874
+ }) {
875
+ const recent = memories.slice(0, 3);
876
+ const older = memories.slice(3, 6);
877
+ const topics = concepts.slice(0, 4);
878
+
879
+ return (
880
+ <section className="brain-overview-panel" aria-label="Brain overview">
881
+ <div className="brain-overview-head">
882
+ <div>
883
+ <span>Brain 한눈에 보기</span>
884
+ <strong>기억과 주제를 바로 확인하세요.</strong>
885
+ </div>
886
+ <button type="button" onClick={() => onOpenDepth(5)}>전체 그래프</button>
887
+ </div>
888
+ <div className="brain-overview-grid">
889
+ <BrainOverviewColumn
890
+ title="최근 기억"
891
+ empty="아직 최근 기억이 없습니다."
892
+ items={recent.map((memory) => memory.title)}
893
+ onOpen={() => onOpenDepth(2)}
894
+ />
895
+ <BrainOverviewColumn
896
+ title="이전 기억"
897
+ empty="대화가 쌓이면 과거 기억이 보입니다."
898
+ items={older.map((memory) => memory.title)}
899
+ onOpen={() => onOpenDepth(2)}
900
+ />
901
+ <BrainOverviewColumn
902
+ title="주요 주제"
903
+ empty="주제가 형성되는 중입니다."
904
+ items={topics.map((concept) => concept.label)}
905
+ onOpen={() => onOpenDepth(3)}
906
+ />
907
+ </div>
908
+ </section>
909
+ );
910
+ }
911
+
912
+ function BrainOverviewColumn({
913
+ title,
914
+ empty,
915
+ items,
916
+ onOpen,
917
+ }: {
918
+ title: string;
919
+ empty: string;
920
+ items: string[];
921
+ onOpen: () => void;
922
+ }) {
923
+ return (
924
+ <button type="button" className="brain-overview-column" onClick={onOpen}>
925
+ <span>{title}</span>
926
+ {items.length ? (
927
+ items.slice(0, 3).map((item) => <strong key={item}>{item}</strong>)
928
+ ) : (
929
+ <em>{empty}</em>
930
+ )}
931
+ </button>
932
+ );
933
+ }
934
+
381
935
  function MemoryLayer({
382
936
  memories,
383
937
  depth,
@@ -566,9 +1120,10 @@ function EmergentKnowledgeGraph({
566
1120
  <span>{selected.type}</span>
567
1121
  <strong>{selected.label}</strong>
568
1122
  <p>{selected.summary || "This concept is part of the deepest knowledge layer."}</p>
1123
+ <p>대화와 문서에서 함께 나온 내용이 선으로 이어집니다.</p>
569
1124
  </>
570
1125
  ) : (
571
- <p>Capture documents, conversations, or projects to grow the graph.</p>
1126
+ <p>대화, 문서, 프로젝트를 쌓으면 Brain 그래프가 자랍니다.</p>
572
1127
  )}
573
1128
  </div>
574
1129
  </section>
@@ -633,6 +1188,70 @@ function currentModelName(data: unknown) {
633
1188
  return firstLoaded ? textValue(firstLoaded, ["name", "id", "model_id"], "local mind") : "local mind";
634
1189
  }
635
1190
 
1191
+ function portabilityLabel(data: unknown) {
1192
+ const record = isRecord(data) ? data : {};
1193
+ return textValue(record, ["archive_format", "format", "graph_schema_version", "schema_version"], ".latticebrain");
1194
+ }
1195
+
1196
+ function backupHealthLabel(data: unknown) {
1197
+ const record = isRecord(data) ? data : {};
1198
+ const count = record.count || record.backups || record.available;
1199
+ if (count !== undefined && count !== null && count !== "") return `${count} backups`;
1200
+ return "Backups ready";
1201
+ }
1202
+
1203
+ function summarizeCareResult(result: ApiResult) {
1204
+ if (!result.ok) return result.error || "Brain care action could not complete.";
1205
+ const data = isRecord(result.data) ? result.data : {};
1206
+ const directMessage = textValue(data, ["message", "status", "path", "archive_path", "backup_path", "export_path"]);
1207
+ if (directMessage) return directMessage;
1208
+ return "Brain care action completed.";
1209
+ }
1210
+
1211
+ function renderLogRow(event: ApiRecord) {
1212
+ const action = stringValue(event.action || event.event || event.type || event.name, "Event");
1213
+ const actor = stringValue(event.actor || event.user || event.user_id || event.workspace_id, "system");
1214
+ const when = stringValue(event.timestamp || event.time || event.created_at || event.ts, "recently");
1215
+ return (
1216
+ <>
1217
+ <strong>{action}</strong>
1218
+ <span>{actor} · {when}</span>
1219
+ </>
1220
+ );
1221
+ }
1222
+
1223
+ function sourceLabel(result?: ApiResult<unknown>) {
1224
+ if (!result) return "Loading";
1225
+ return result.ok ? "Live" : result.error || "Unavailable";
1226
+ }
1227
+
1228
+ function adminStatusLabel(data: unknown, key: string) {
1229
+ const record = isRecord(data) ? data : {};
1230
+ return textValue(record, [key, "health", "state", "overall_status"]);
1231
+ }
1232
+
1233
+ function indexDetail(data: unknown) {
1234
+ const record = isRecord(data) ? data : {};
1235
+ const docs = record.documents ?? record.document_count ?? record.docs;
1236
+ const chunks = record.chunks ?? record.chunk_count ?? record.vectors;
1237
+ if (docs !== undefined || chunks !== undefined) {
1238
+ return `${stringValue(docs, "0")} docs · ${stringValue(chunks, "0")} chunks`;
1239
+ }
1240
+ return textValue(record, ["message", "detail", "status"], "Index status ready");
1241
+ }
1242
+
1243
+ function summaryText(data: unknown) {
1244
+ const record = isRecord(data) ? data : {};
1245
+ return textValue(record, ["summary", "message", "status", "detail"]);
1246
+ }
1247
+
1248
+ function stringValue(value: unknown, fallback = "") {
1249
+ if (typeof value === "string" && value.trim()) return value;
1250
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1251
+ if (typeof value === "boolean") return value ? "true" : "false";
1252
+ return fallback;
1253
+ }
1254
+
636
1255
  function fileToDataUrl(file: File) {
637
1256
  return new Promise<string>((resolve, reject) => {
638
1257
  const reader = new FileReader();