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.
- package/README.md +74 -40
- package/docs/CHANGELOG.md +141 -0
- package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
- package/frontend/src/App.tsx +627 -8
- package/frontend/src/api/client.ts +11 -1
- package/frontend/src/components/ProductFlow.tsx +106 -51
- package/frontend/src/pages/System.tsx +1 -1
- package/frontend/src/styles.css +905 -81
- 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 +141 -6
- package/latticeai/api/chat.py +35 -13
- package/latticeai/app_factory.py +8 -4
- 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 +2 -1
- 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-DdAB4yfa.js +16 -0
- package/static/app/assets/index-DdAB4yfa.js.map +1 -0
- package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
- 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,23 @@
|
|
|
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
|
+
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
|
-
|
|
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:
|
|
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"
|
|
284
|
-
<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
|
|
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();
|