ltcai 4.7.0 → 5.0.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 +49 -38
- package/docs/CHANGELOG.md +119 -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 +16 -14
- package/frontend/src/App.tsx +275 -64
- package/frontend/src/api/client.ts +9 -1
- package/frontend/src/components/ProductFlow.tsx +150 -85
- package/frontend/src/i18n.ts +245 -0
- package/frontend/src/pages/System.tsx +1 -1
- package/frontend/src/store/appStore.ts +18 -0
- package/frontend/src/styles.css +241 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +112 -3
- package/latticeai/api/chat.py +11 -3
- package/latticeai/app_factory.py +0 -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-DFmuiJ6t.css → index-DuYYT2oh.css} +1 -1
- package/static/app/assets/index-FR1UZkCD.js +16 -0
- package/static/app/assets/index-FR1UZkCD.js.map +1 -0
- package/static/app/index.html +2 -2
- package/scripts/launch-pts-grok.sh +0 -56
- package/static/app/assets/index-DwX3rNfA.js +0 -16
- package/static/app/assets/index-DwX3rNfA.js.map +0 -1
package/frontend/src/App.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Download,
|
|
10
10
|
Eye,
|
|
11
11
|
ImagePlus,
|
|
12
|
+
ListFilter,
|
|
12
13
|
RotateCcw,
|
|
13
14
|
Search,
|
|
14
15
|
Send,
|
|
@@ -16,15 +17,17 @@ import {
|
|
|
16
17
|
ShieldCheck,
|
|
17
18
|
Users,
|
|
18
19
|
} from "lucide-react";
|
|
19
|
-
import { latticeApi, type ApiResult } from "@/api/client";
|
|
20
|
+
import { latticeApi, type AdminAuditFilters, type ApiResult } from "@/api/client";
|
|
20
21
|
import { Button } from "@/components/ui/button";
|
|
21
22
|
import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
|
|
22
23
|
import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
|
|
23
24
|
import { useAppStore } from "@/store/appStore";
|
|
24
25
|
import { asArray } from "@/lib/utils";
|
|
26
|
+
import { LANGUAGE_LABELS, t, type Language } from "@/i18n";
|
|
25
27
|
|
|
26
28
|
type ApiRecord = Record<string, unknown>;
|
|
27
29
|
type BrainDepth = 1 | 2 | 3 | 4 | 5;
|
|
30
|
+
type AdminFilterState = Required<Pick<AdminAuditFilters, "q" | "actor" | "action" | "severity">> & { limit: number };
|
|
28
31
|
|
|
29
32
|
type Message = {
|
|
30
33
|
role: "user" | "assistant";
|
|
@@ -66,21 +69,17 @@ const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
|
|
|
66
69
|
{ level: 5, label: "Knowledge Graph", state: "synthesizing" },
|
|
67
70
|
];
|
|
68
71
|
|
|
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
|
-
|
|
75
72
|
export default function App() {
|
|
76
73
|
const theme = useAppStore((state) => state.theme);
|
|
74
|
+
const language = useAppStore((state) => state.language);
|
|
77
75
|
const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
|
|
78
76
|
const route = useHashRoute();
|
|
79
77
|
const { state: brainState, intensity, setBrain } = useBrainState();
|
|
80
78
|
|
|
81
79
|
React.useEffect(() => {
|
|
82
80
|
document.documentElement.dataset.theme = theme;
|
|
83
|
-
|
|
81
|
+
document.documentElement.lang = language === "ko" ? "ko" : "en";
|
|
82
|
+
}, [theme, language]);
|
|
84
83
|
|
|
85
84
|
React.useEffect(() => {
|
|
86
85
|
const onKey = (event: KeyboardEvent) => {
|
|
@@ -151,6 +150,7 @@ function BrainHome({
|
|
|
151
150
|
onBrainChange: (state: BrainState, intensity?: number) => void;
|
|
152
151
|
}) {
|
|
153
152
|
const qc = useQueryClient();
|
|
153
|
+
const language = useAppStore((state) => state.language);
|
|
154
154
|
const [messages, setMessages] = React.useState<Message[]>([]);
|
|
155
155
|
const [draft, setDraft] = React.useState("");
|
|
156
156
|
const [imageData, setImageData] = React.useState<string | null>(null);
|
|
@@ -159,6 +159,7 @@ function BrainHome({
|
|
|
159
159
|
const [explorationDepth, setExplorationDepth] = React.useState<BrainDepth>(1);
|
|
160
160
|
const [graphSearch, setGraphSearch] = React.useState("");
|
|
161
161
|
const [selectedGraphId, setSelectedGraphId] = React.useState<string | null>(null);
|
|
162
|
+
const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
|
|
162
163
|
const streamRef = React.useRef<HTMLDivElement>(null);
|
|
163
164
|
const recallTimerRef = React.useRef<number | null>(null);
|
|
164
165
|
|
|
@@ -182,6 +183,14 @@ function BrainHome({
|
|
|
182
183
|
);
|
|
183
184
|
const modelName = React.useMemo(() => currentModelName(modelsQ.data?.data), [modelsQ.data]);
|
|
184
185
|
const currentDepth = DEPTHS[explorationDepth - 1];
|
|
186
|
+
const starterPrompts = React.useMemo(
|
|
187
|
+
() => [
|
|
188
|
+
t(language, "brain.prompt.remember"),
|
|
189
|
+
t(language, "brain.prompt.know"),
|
|
190
|
+
t(language, "brain.prompt.plan"),
|
|
191
|
+
],
|
|
192
|
+
[language],
|
|
193
|
+
);
|
|
185
194
|
|
|
186
195
|
React.useEffect(() => {
|
|
187
196
|
if (streaming) onBrainChange("thinking", 0.94);
|
|
@@ -210,6 +219,7 @@ function BrainHome({
|
|
|
210
219
|
setDraft("");
|
|
211
220
|
setImageData(null);
|
|
212
221
|
setStreaming(true);
|
|
222
|
+
setMemoryFeedback(null);
|
|
213
223
|
onBrainChange("thinking", 0.96);
|
|
214
224
|
|
|
215
225
|
try {
|
|
@@ -238,6 +248,8 @@ function BrainHome({
|
|
|
238
248
|
next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
|
|
239
249
|
return next;
|
|
240
250
|
});
|
|
251
|
+
} else {
|
|
252
|
+
setMemoryFeedback(t(language, "brain.saved", { topics: knowledgeConcepts.length, memories: memoryFragments.length }));
|
|
241
253
|
}
|
|
242
254
|
} finally {
|
|
243
255
|
setStreaming(false);
|
|
@@ -257,6 +269,13 @@ function BrainHome({
|
|
|
257
269
|
});
|
|
258
270
|
}
|
|
259
271
|
|
|
272
|
+
function jumpToDepth(next: BrainDepth) {
|
|
273
|
+
setExplorationDepth(next);
|
|
274
|
+
const nextDepth = DEPTHS[next - 1];
|
|
275
|
+
onBrainChange(nextDepth.state, next === 1 ? 0.58 : 0.66 + next * 0.06);
|
|
276
|
+
if (next >= 2) triggerBrainRecall();
|
|
277
|
+
}
|
|
278
|
+
|
|
260
279
|
function surface() {
|
|
261
280
|
setExplorationDepth(1);
|
|
262
281
|
setSelectedGraphId(null);
|
|
@@ -269,7 +288,7 @@ function BrainHome({
|
|
|
269
288
|
setExplorationDepth((depth) => Math.max(depth, 2) as BrainDepth);
|
|
270
289
|
setMessages((items) => [
|
|
271
290
|
...items,
|
|
272
|
-
{ role: "assistant", content:
|
|
291
|
+
{ role: "assistant", content: t(language, "brain.recalled", { title: fragment.title }) },
|
|
273
292
|
]);
|
|
274
293
|
}
|
|
275
294
|
|
|
@@ -287,8 +306,15 @@ function BrainHome({
|
|
|
287
306
|
/>
|
|
288
307
|
|
|
289
308
|
<div className="brain-depth-badge" aria-live="polite">
|
|
290
|
-
<span>
|
|
291
|
-
<strong>{
|
|
309
|
+
<span>{t(language, "brain.level")} {explorationDepth}</span>
|
|
310
|
+
<strong>{t(language, `brain.depth.${explorationDepth}`)}</strong>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div className="brain-depth-actions" aria-label="Brain quick views">
|
|
314
|
+
<button type="button" className={explorationDepth === 2 ? "is-active" : ""} onClick={() => jumpToDepth(2)}>{t(language, "brain.view.memories")}</button>
|
|
315
|
+
<button type="button" className={explorationDepth === 3 ? "is-active" : ""} onClick={() => jumpToDepth(3)}>{t(language, "brain.view.topics")}</button>
|
|
316
|
+
<button type="button" className={explorationDepth === 4 ? "is-active" : ""} onClick={() => jumpToDepth(4)}>{t(language, "brain.view.relationships")}</button>
|
|
317
|
+
<button type="button" className={explorationDepth === 5 ? "is-active" : ""} onClick={() => jumpToDepth(5)}>{t(language, "brain.view.graph")}</button>
|
|
292
318
|
</div>
|
|
293
319
|
|
|
294
320
|
<div className="brain-field-layer" aria-hidden={explorationDepth < 2}>
|
|
@@ -308,7 +334,7 @@ function BrainHome({
|
|
|
308
334
|
|
|
309
335
|
{explorationDepth > 1 ? (
|
|
310
336
|
<button className="brain-surface-control" type="button" onClick={surface}>
|
|
311
|
-
|
|
337
|
+
{t(language, "brain.surface")}
|
|
312
338
|
</button>
|
|
313
339
|
) : null}
|
|
314
340
|
</div>
|
|
@@ -317,31 +343,35 @@ function BrainHome({
|
|
|
317
343
|
<section className="brain-conversation" aria-label="Conversation">
|
|
318
344
|
<div className="brain-conversation-header">
|
|
319
345
|
<div>
|
|
320
|
-
<h1>
|
|
321
|
-
<span>{
|
|
346
|
+
<h1>{t(language, "brain.title")}</h1>
|
|
347
|
+
<span>{t(language, `brain.depth.${explorationDepth}`)}</span>
|
|
322
348
|
</div>
|
|
349
|
+
<LanguageSwitcher compact />
|
|
323
350
|
<div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
|
|
324
|
-
<span>
|
|
325
|
-
<span>
|
|
326
|
-
<span>
|
|
351
|
+
<span>{t(language, "brain.local")}</span>
|
|
352
|
+
<span>{t(language, "brain.portable")}</span>
|
|
353
|
+
<span>{t(language, "brain.private")}</span>
|
|
327
354
|
</div>
|
|
328
355
|
<div>{modelName}</div>
|
|
329
356
|
<button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
|
|
330
357
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|
331
|
-
|
|
358
|
+
{t(language, "brain.admin")}
|
|
332
359
|
</button>
|
|
333
360
|
</div>
|
|
334
361
|
|
|
335
362
|
<div ref={streamRef} className="brain-stream">
|
|
363
|
+
<BrainOverviewPanel
|
|
364
|
+
memories={memoryFragments}
|
|
365
|
+
concepts={knowledgeConcepts}
|
|
366
|
+
onOpenDepth={jumpToDepth}
|
|
367
|
+
/>
|
|
336
368
|
{messages.length === 0 ? (
|
|
337
369
|
<div className="mind-empty">
|
|
338
|
-
<div className="mind-empty-kicker">
|
|
339
|
-
<div className="mind-empty-title">
|
|
340
|
-
<p>
|
|
341
|
-
Lattice keeps your documents, conversations, projects, and decisions available while models can change around them.
|
|
342
|
-
</p>
|
|
370
|
+
<div className="mind-empty-kicker">{t(language, "brain.empty.kicker")}</div>
|
|
371
|
+
<div className="mind-empty-title">{t(language, "brain.empty.title")}</div>
|
|
372
|
+
<p>{t(language, "brain.empty.body")}</p>
|
|
343
373
|
<div className="mind-empty-prompts" aria-label="Starter prompts">
|
|
344
|
-
{
|
|
374
|
+
{starterPrompts.map((prompt) => (
|
|
345
375
|
<button key={prompt} type="button" onClick={() => setDraft(prompt)}>
|
|
346
376
|
{prompt}
|
|
347
377
|
</button>
|
|
@@ -357,7 +387,9 @@ function BrainHome({
|
|
|
357
387
|
)}
|
|
358
388
|
</div>
|
|
359
389
|
|
|
360
|
-
<
|
|
390
|
+
{memoryFeedback ? <div className="brain-save-feedback" role="status">{memoryFeedback}</div> : null}
|
|
391
|
+
|
|
392
|
+
<BrainCarePanel language={language} />
|
|
361
393
|
|
|
362
394
|
<div className="brain-composer">
|
|
363
395
|
<textarea
|
|
@@ -369,12 +401,12 @@ function BrainHome({
|
|
|
369
401
|
void send();
|
|
370
402
|
}
|
|
371
403
|
}}
|
|
372
|
-
placeholder=
|
|
404
|
+
placeholder={t(language, "brain.placeholder")}
|
|
373
405
|
/>
|
|
374
406
|
<div className="brain-composer-actions">
|
|
375
407
|
<label className="brain-image-input">
|
|
376
408
|
<ImagePlus className="h-3.5 w-3.5" />
|
|
377
|
-
<span>
|
|
409
|
+
<span>{t(language, "brain.image")}</span>
|
|
378
410
|
<input
|
|
379
411
|
type="file"
|
|
380
412
|
accept="image/*"
|
|
@@ -385,9 +417,9 @@ function BrainHome({
|
|
|
385
417
|
}}
|
|
386
418
|
/>
|
|
387
419
|
</label>
|
|
388
|
-
{imageData ? <span className="brain-quiet-success">
|
|
420
|
+
{imageData ? <span className="brain-quiet-success">{t(language, "brain.imageAttached")}</span> : null}
|
|
389
421
|
<Button onClick={() => void send()} disabled={!draft.trim() || streaming} className="rounded-full px-5">
|
|
390
|
-
<Send className="h-4 w-4" />
|
|
422
|
+
<Send className="h-4 w-4" /> {t(language, "brain.send")}
|
|
391
423
|
</Button>
|
|
392
424
|
</div>
|
|
393
425
|
</div>
|
|
@@ -396,16 +428,32 @@ function BrainHome({
|
|
|
396
428
|
);
|
|
397
429
|
}
|
|
398
430
|
|
|
431
|
+
function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|
432
|
+
const language = useAppStore((state) => state.language);
|
|
433
|
+
const setLanguage = useAppStore((state) => state.setLanguage);
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div className={compact ? "language-switcher compact" : "language-switcher"} aria-label={t(language, "language.label")}>
|
|
437
|
+
{(["ko", "en"] as Language[]).map((item) => (
|
|
438
|
+
<button
|
|
439
|
+
key={item}
|
|
440
|
+
type="button"
|
|
441
|
+
className={language === item ? "is-active" : ""}
|
|
442
|
+
onClick={() => setLanguage(item)}
|
|
443
|
+
aria-pressed={language === item}
|
|
444
|
+
>
|
|
445
|
+
{LANGUAGE_LABELS[item]}
|
|
446
|
+
</button>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
399
452
|
function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
400
453
|
const qc = useQueryClient();
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
const usersQ
|
|
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 });
|
|
454
|
+
const language = useAppStore((state) => state.language);
|
|
455
|
+
const [filters, setFilters] = React.useState<AdminFilterState>({ q: "", actor: "", action: "", severity: "", limit: 50 });
|
|
456
|
+
const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ } = useAdminConsoleData(filters);
|
|
409
457
|
const rebuildIndex = useMutation({
|
|
410
458
|
mutationFn: latticeApi.rebuildIndex,
|
|
411
459
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
|
|
@@ -415,19 +463,22 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
|
415
463
|
const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
|
|
416
464
|
const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
|
|
417
465
|
const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
|
|
466
|
+
const roles = asArray((rolesQ.data?.data as ApiRecord | undefined)?.roles);
|
|
467
|
+
const retention = (retentionQ.data?.data || {}) as ApiRecord;
|
|
418
468
|
|
|
419
469
|
return (
|
|
420
470
|
<main className="admin-console" aria-label="Lattice Admin">
|
|
421
471
|
<header className="admin-console-header">
|
|
422
472
|
<button className="admin-back-button" type="button" onClick={onBack}>
|
|
423
473
|
<ArrowLeft className="h-4 w-4" />
|
|
424
|
-
|
|
474
|
+
{t(language, "admin.back")}
|
|
425
475
|
</button>
|
|
426
476
|
<div>
|
|
427
|
-
<span>
|
|
428
|
-
<h1>
|
|
429
|
-
<p>
|
|
477
|
+
<span>{t(language, "admin.kicker")}</span>
|
|
478
|
+
<h1>{t(language, "admin.title")}</h1>
|
|
479
|
+
<p>{t(language, "admin.body")}</p>
|
|
430
480
|
</div>
|
|
481
|
+
<LanguageSwitcher compact />
|
|
431
482
|
</header>
|
|
432
483
|
|
|
433
484
|
<section className="admin-metrics" aria-label="Admin overview">
|
|
@@ -469,7 +520,24 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
|
469
520
|
/>
|
|
470
521
|
</AdminPanel>
|
|
471
522
|
|
|
523
|
+
<AdminPanel title="Role Permissions" eyebrow="Access">
|
|
524
|
+
<AdminList
|
|
525
|
+
items={roles.slice(0, 6)}
|
|
526
|
+
empty="No role matrix reported."
|
|
527
|
+
render={(item) => {
|
|
528
|
+
const role = item as ApiRecord;
|
|
529
|
+
return (
|
|
530
|
+
<>
|
|
531
|
+
<strong>{stringValue(role.role, "role")} · {stringValue(role.members, "0")} users</strong>
|
|
532
|
+
<span>{asArray(role.caps).slice(0, 4).map((cap) => stringValue(cap, "")).filter(Boolean).join(", ") || "No caps"}</span>
|
|
533
|
+
</>
|
|
534
|
+
);
|
|
535
|
+
}}
|
|
536
|
+
/>
|
|
537
|
+
</AdminPanel>
|
|
538
|
+
|
|
472
539
|
<AdminPanel title="Activity Logs" eyebrow="Audit">
|
|
540
|
+
<AdminLogFilters filters={filters} onChange={setFilters} matched={(auditQ.data?.data as ApiRecord | undefined)?.filters as ApiRecord | undefined} />
|
|
473
541
|
<AdminList
|
|
474
542
|
items={auditEvents.slice(0, 8)}
|
|
475
543
|
empty="No recent audit events."
|
|
@@ -499,16 +567,82 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
|
499
567
|
<div className="admin-policy-strip">
|
|
500
568
|
{policies.slice(0, 5).map((item, index) => {
|
|
501
569
|
const policy = item as ApiRecord;
|
|
502
|
-
return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.name || policy.id, "Policy")}</span>;
|
|
570
|
+
return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.label || policy.name || policy.id, "Policy")}</span>;
|
|
503
571
|
})}
|
|
504
572
|
{!policies.length ? <span>Policy API quiet</span> : null}
|
|
505
573
|
</div>
|
|
574
|
+
<div className="admin-retention">
|
|
575
|
+
<strong>{stringValue(retention.retention_days, "90")} day retention</strong>
|
|
576
|
+
<span>{stringValue(retention.retained_events, "0")} retained · {stringValue(retention.prune_candidates, "0")} ready for export/prune review</span>
|
|
577
|
+
</div>
|
|
506
578
|
</AdminPanel>
|
|
507
579
|
</section>
|
|
508
580
|
</main>
|
|
509
581
|
);
|
|
510
582
|
}
|
|
511
583
|
|
|
584
|
+
function useAdminConsoleData(filters: AdminFilterState) {
|
|
585
|
+
const auditFilters = React.useMemo<AdminAuditFilters>(() => ({
|
|
586
|
+
q: filters.q || undefined,
|
|
587
|
+
actor: filters.actor || undefined,
|
|
588
|
+
action: filters.action || undefined,
|
|
589
|
+
severity: filters.severity || undefined,
|
|
590
|
+
limit: filters.limit,
|
|
591
|
+
}), [filters]);
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
summaryQ: useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary }),
|
|
595
|
+
statsQ: useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats }),
|
|
596
|
+
usersQ: useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers }),
|
|
597
|
+
auditQ: useQuery({ queryKey: ["adminAudit", auditFilters], queryFn: () => latticeApi.adminAudit(auditFilters) }),
|
|
598
|
+
securityQ: useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity }),
|
|
599
|
+
securityEventsQ: useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) }),
|
|
600
|
+
policiesQ: useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies }),
|
|
601
|
+
rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
|
|
602
|
+
retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
|
|
603
|
+
indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function AdminLogFilters({
|
|
608
|
+
filters,
|
|
609
|
+
matched,
|
|
610
|
+
onChange,
|
|
611
|
+
}: {
|
|
612
|
+
filters: AdminFilterState;
|
|
613
|
+
matched?: ApiRecord;
|
|
614
|
+
onChange: React.Dispatch<React.SetStateAction<AdminFilterState>>;
|
|
615
|
+
}) {
|
|
616
|
+
return (
|
|
617
|
+
<div className="admin-log-filters" aria-label="Audit log filters">
|
|
618
|
+
<label>
|
|
619
|
+
<Search className="h-3.5 w-3.5" />
|
|
620
|
+
<input
|
|
621
|
+
value={filters.q}
|
|
622
|
+
onChange={(event) => onChange((current) => ({ ...current, q: event.target.value }))}
|
|
623
|
+
placeholder="Search logs"
|
|
624
|
+
aria-label="Search audit logs"
|
|
625
|
+
/>
|
|
626
|
+
</label>
|
|
627
|
+
<label>
|
|
628
|
+
<ListFilter className="h-3.5 w-3.5" />
|
|
629
|
+
<select
|
|
630
|
+
value={filters.severity}
|
|
631
|
+
onChange={(event) => onChange((current) => ({ ...current, severity: event.target.value }))}
|
|
632
|
+
aria-label="Filter by severity"
|
|
633
|
+
>
|
|
634
|
+
<option value="">All severities</option>
|
|
635
|
+
<option value="informational">Informational</option>
|
|
636
|
+
<option value="notice">Notice</option>
|
|
637
|
+
<option value="warning">Warning</option>
|
|
638
|
+
<option value="high">High</option>
|
|
639
|
+
</select>
|
|
640
|
+
</label>
|
|
641
|
+
<span>{stringValue(matched?.matched_events, "0")} matched</span>
|
|
642
|
+
</div>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
512
646
|
function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
|
513
647
|
return (
|
|
514
648
|
<div className="admin-metric">
|
|
@@ -537,7 +671,7 @@ function AdminList({ items, empty, render }: { items: unknown[]; empty: string;
|
|
|
537
671
|
return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
|
|
538
672
|
}
|
|
539
673
|
|
|
540
|
-
function BrainCarePanel() {
|
|
674
|
+
function BrainCarePanel({ language }: { language: Language }) {
|
|
541
675
|
const qc = useQueryClient();
|
|
542
676
|
const [expanded, setExpanded] = React.useState(false);
|
|
543
677
|
const [archivePath, setArchivePath] = React.useState("");
|
|
@@ -572,7 +706,7 @@ function BrainCarePanel() {
|
|
|
572
706
|
const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
|
|
573
707
|
|
|
574
708
|
return (
|
|
575
|
-
<section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label=
|
|
709
|
+
<section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label={t(language, "care.title")}>
|
|
576
710
|
<button
|
|
577
711
|
className="brain-care-summary"
|
|
578
712
|
type="button"
|
|
@@ -581,11 +715,11 @@ function BrainCarePanel() {
|
|
|
581
715
|
onClick={() => setExpanded((value) => !value)}
|
|
582
716
|
>
|
|
583
717
|
<span className="brain-care-summary-main">
|
|
584
|
-
<span><ShieldCheck className="h-3.5 w-3.5" />
|
|
585
|
-
<strong>
|
|
718
|
+
<span><ShieldCheck className="h-3.5 w-3.5" /> {t(language, "care.title")}</span>
|
|
719
|
+
<strong>{t(language, "care.subtitle")}</strong>
|
|
586
720
|
</span>
|
|
587
721
|
<div className="brain-care-proof" aria-label="Ownership model">
|
|
588
|
-
<span>
|
|
722
|
+
<span>{t(language, "care.private")}</span>
|
|
589
723
|
<span>{portableFormat}</span>
|
|
590
724
|
<span>{backupStatus}</span>
|
|
591
725
|
</div>
|
|
@@ -597,22 +731,25 @@ function BrainCarePanel() {
|
|
|
597
731
|
<div className="brain-care-actions">
|
|
598
732
|
<CareButton
|
|
599
733
|
icon={<Download className="h-3.5 w-3.5" />}
|
|
600
|
-
label="
|
|
601
|
-
detail=
|
|
734
|
+
label={t(language, "care.export")}
|
|
735
|
+
detail={t(language, "care.export.detail")}
|
|
736
|
+
pendingLabel={t(language, "care.working")}
|
|
602
737
|
pending={exportGraph.isPending}
|
|
603
738
|
onClick={() => exportGraph.mutate()}
|
|
604
739
|
/>
|
|
605
740
|
<CareButton
|
|
606
741
|
icon={<DatabaseBackup className="h-3.5 w-3.5" />}
|
|
607
|
-
label="
|
|
608
|
-
detail=
|
|
742
|
+
label={t(language, "care.backup")}
|
|
743
|
+
detail={t(language, "care.backup.detail")}
|
|
744
|
+
pendingLabel={t(language, "care.working")}
|
|
609
745
|
pending={backupGraph.isPending}
|
|
610
746
|
onClick={() => backupGraph.mutate()}
|
|
611
747
|
/>
|
|
612
748
|
<CareButton
|
|
613
749
|
icon={<Archive className="h-3.5 w-3.5" />}
|
|
614
|
-
label="
|
|
615
|
-
detail=
|
|
750
|
+
label={t(language, "care.archive")}
|
|
751
|
+
detail={t(language, "care.archive.detail")}
|
|
752
|
+
pendingLabel={t(language, "care.working")}
|
|
616
753
|
pending={archiveBrain.isPending}
|
|
617
754
|
disabled={!passphrase.trim()}
|
|
618
755
|
onClick={() => archiveBrain.mutate()}
|
|
@@ -623,15 +760,15 @@ function BrainCarePanel() {
|
|
|
623
760
|
<input
|
|
624
761
|
value={archivePath}
|
|
625
762
|
onChange={(event) => setArchivePath(event.target.value)}
|
|
626
|
-
placeholder="
|
|
627
|
-
aria-label="
|
|
763
|
+
placeholder={t(language, "care.path.placeholder")}
|
|
764
|
+
aria-label={t(language, "care.path.label")}
|
|
628
765
|
/>
|
|
629
766
|
<input
|
|
630
767
|
type="password"
|
|
631
768
|
value={passphrase}
|
|
632
769
|
onChange={(event) => setPassphrase(event.target.value)}
|
|
633
|
-
placeholder="
|
|
634
|
-
aria-label="
|
|
770
|
+
placeholder={t(language, "care.passphrase.placeholder")}
|
|
771
|
+
aria-label={t(language, "care.passphrase.label")}
|
|
635
772
|
/>
|
|
636
773
|
<div className="brain-care-archive-actions">
|
|
637
774
|
<Button
|
|
@@ -640,7 +777,7 @@ function BrainCarePanel() {
|
|
|
640
777
|
disabled={!archivePath.trim() || inspectArchive.isPending}
|
|
641
778
|
onClick={() => inspectArchive.mutate()}
|
|
642
779
|
>
|
|
643
|
-
<Eye className="h-3.5 w-3.5" />
|
|
780
|
+
<Eye className="h-3.5 w-3.5" /> {t(language, "care.inspect")}
|
|
644
781
|
</Button>
|
|
645
782
|
<Button
|
|
646
783
|
variant="outline"
|
|
@@ -648,7 +785,7 @@ function BrainCarePanel() {
|
|
|
648
785
|
disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
|
|
649
786
|
onClick={() => restorePreview.mutate()}
|
|
650
787
|
>
|
|
651
|
-
<RotateCcw className="h-3.5 w-3.5" />
|
|
788
|
+
<RotateCcw className="h-3.5 w-3.5" /> {t(language, "care.restorePreview")}
|
|
652
789
|
</Button>
|
|
653
790
|
</div>
|
|
654
791
|
</div>
|
|
@@ -659,7 +796,7 @@ function BrainCarePanel() {
|
|
|
659
796
|
</div>
|
|
660
797
|
) : (
|
|
661
798
|
<p className="brain-care-note">
|
|
662
|
-
|
|
799
|
+
{t(language, "care.note")}
|
|
663
800
|
</p>
|
|
664
801
|
)}
|
|
665
802
|
</div>
|
|
@@ -686,6 +823,7 @@ function CareButton({
|
|
|
686
823
|
icon,
|
|
687
824
|
label,
|
|
688
825
|
detail,
|
|
826
|
+
pendingLabel,
|
|
689
827
|
pending,
|
|
690
828
|
disabled,
|
|
691
829
|
onClick,
|
|
@@ -693,6 +831,7 @@ function CareButton({
|
|
|
693
831
|
icon: React.ReactNode;
|
|
694
832
|
label: string;
|
|
695
833
|
detail: string;
|
|
834
|
+
pendingLabel: string;
|
|
696
835
|
pending?: boolean;
|
|
697
836
|
disabled?: boolean;
|
|
698
837
|
onClick: () => void;
|
|
@@ -701,7 +840,7 @@ function CareButton({
|
|
|
701
840
|
<button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
|
|
702
841
|
{icon}
|
|
703
842
|
<span>
|
|
704
|
-
<strong>{pending ?
|
|
843
|
+
<strong>{pending ? pendingLabel : label}</strong>
|
|
705
844
|
<small>{detail}</small>
|
|
706
845
|
</span>
|
|
707
846
|
</button>
|
|
@@ -757,6 +896,76 @@ function DepthEmergence({
|
|
|
757
896
|
);
|
|
758
897
|
}
|
|
759
898
|
|
|
899
|
+
function BrainOverviewPanel({
|
|
900
|
+
memories,
|
|
901
|
+
concepts,
|
|
902
|
+
onOpenDepth,
|
|
903
|
+
}: {
|
|
904
|
+
memories: MemoryFragment[];
|
|
905
|
+
concepts: KnowledgeConcept[];
|
|
906
|
+
onOpenDepth: (depth: BrainDepth) => void;
|
|
907
|
+
}) {
|
|
908
|
+
const language = useAppStore((state) => state.language);
|
|
909
|
+
const recent = memories.slice(0, 3);
|
|
910
|
+
const older = memories.slice(3, 6);
|
|
911
|
+
const topics = concepts.slice(0, 4);
|
|
912
|
+
|
|
913
|
+
return (
|
|
914
|
+
<section className="brain-overview-panel" aria-label="Brain overview">
|
|
915
|
+
<div className="brain-overview-head">
|
|
916
|
+
<div>
|
|
917
|
+
<span>{t(language, "brain.overview.kicker")}</span>
|
|
918
|
+
<strong>{t(language, "brain.overview.title")}</strong>
|
|
919
|
+
</div>
|
|
920
|
+
<button type="button" onClick={() => onOpenDepth(5)}>{t(language, "brain.overview.graph")}</button>
|
|
921
|
+
</div>
|
|
922
|
+
<div className="brain-overview-grid">
|
|
923
|
+
<BrainOverviewColumn
|
|
924
|
+
title={t(language, "brain.overview.recent")}
|
|
925
|
+
empty={t(language, "brain.overview.recentEmpty")}
|
|
926
|
+
items={recent.map((memory) => memory.title)}
|
|
927
|
+
onOpen={() => onOpenDepth(2)}
|
|
928
|
+
/>
|
|
929
|
+
<BrainOverviewColumn
|
|
930
|
+
title={t(language, "brain.overview.older")}
|
|
931
|
+
empty={t(language, "brain.overview.olderEmpty")}
|
|
932
|
+
items={older.map((memory) => memory.title)}
|
|
933
|
+
onOpen={() => onOpenDepth(2)}
|
|
934
|
+
/>
|
|
935
|
+
<BrainOverviewColumn
|
|
936
|
+
title={t(language, "brain.overview.topics")}
|
|
937
|
+
empty={t(language, "brain.overview.topicsEmpty")}
|
|
938
|
+
items={topics.map((concept) => concept.label)}
|
|
939
|
+
onOpen={() => onOpenDepth(3)}
|
|
940
|
+
/>
|
|
941
|
+
</div>
|
|
942
|
+
</section>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function BrainOverviewColumn({
|
|
947
|
+
title,
|
|
948
|
+
empty,
|
|
949
|
+
items,
|
|
950
|
+
onOpen,
|
|
951
|
+
}: {
|
|
952
|
+
title: string;
|
|
953
|
+
empty: string;
|
|
954
|
+
items: string[];
|
|
955
|
+
onOpen: () => void;
|
|
956
|
+
}) {
|
|
957
|
+
return (
|
|
958
|
+
<button type="button" className="brain-overview-column" onClick={onOpen}>
|
|
959
|
+
<span>{title}</span>
|
|
960
|
+
{items.length ? (
|
|
961
|
+
items.slice(0, 3).map((item) => <strong key={item}>{item}</strong>)
|
|
962
|
+
) : (
|
|
963
|
+
<em>{empty}</em>
|
|
964
|
+
)}
|
|
965
|
+
</button>
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
760
969
|
function MemoryLayer({
|
|
761
970
|
memories,
|
|
762
971
|
depth,
|
|
@@ -869,6 +1078,7 @@ function EmergentKnowledgeGraph({
|
|
|
869
1078
|
onSearch: (value: string) => void;
|
|
870
1079
|
onSelect: (id: string | null) => void;
|
|
871
1080
|
}) {
|
|
1081
|
+
const language = useAppStore((state) => state.language);
|
|
872
1082
|
const query = search.trim().toLowerCase();
|
|
873
1083
|
const visibleNodes = React.useMemo(() => {
|
|
874
1084
|
const filtered = model.nodes.filter((node) => {
|
|
@@ -936,7 +1146,7 @@ function EmergentKnowledgeGraph({
|
|
|
936
1146
|
))}
|
|
937
1147
|
</div>
|
|
938
1148
|
) : (
|
|
939
|
-
<div className="brain-graph-empty">
|
|
1149
|
+
<div className="brain-graph-empty">{t(language, "brain.graph.empty")}</div>
|
|
940
1150
|
)}
|
|
941
1151
|
|
|
942
1152
|
<div className="brain-graph-focus">
|
|
@@ -944,10 +1154,11 @@ function EmergentKnowledgeGraph({
|
|
|
944
1154
|
<>
|
|
945
1155
|
<span>{selected.type}</span>
|
|
946
1156
|
<strong>{selected.label}</strong>
|
|
947
|
-
<p>{selected.summary || "
|
|
1157
|
+
<p>{selected.summary || t(language, "brain.graph.summaryFallback")}</p>
|
|
1158
|
+
<p>{t(language, "brain.graph.focused")}</p>
|
|
948
1159
|
</>
|
|
949
1160
|
) : (
|
|
950
|
-
<p>
|
|
1161
|
+
<p>{t(language, "brain.graph.emptyFocus")}</p>
|
|
951
1162
|
)}
|
|
952
1163
|
</div>
|
|
953
1164
|
</section>
|
|
@@ -12,6 +12,13 @@ export type ApiResult<T = unknown> = {
|
|
|
12
12
|
|
|
13
13
|
type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
|
14
14
|
type Query = Record<string, string | number | boolean | null | undefined>;
|
|
15
|
+
export type AdminAuditFilters = {
|
|
16
|
+
q?: string;
|
|
17
|
+
actor?: string;
|
|
18
|
+
action?: string;
|
|
19
|
+
severity?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
};
|
|
15
22
|
|
|
16
23
|
const TIMEOUT_MS = 10_000;
|
|
17
24
|
const clients = new Map<string, ReturnType<typeof createClient<paths>>>();
|
|
@@ -419,9 +426,10 @@ export const latticeApi = {
|
|
|
419
426
|
adminSummary: () => get("/admin/summary", {}),
|
|
420
427
|
adminStats: () => get("/admin/stats", {}),
|
|
421
428
|
adminUsers: () => get("/admin/users", []),
|
|
422
|
-
adminAudit: () => get("/admin/audit", { recent_events: [] }),
|
|
429
|
+
adminAudit: (filters?: AdminAuditFilters) => get("/admin/audit", { recent_events: [], filters: {} }, filters),
|
|
423
430
|
adminRoles: () => get("/admin/roles", { roles: [] }),
|
|
424
431
|
adminPolicies: () => get("/admin/policies", { policies: [] }),
|
|
432
|
+
adminLogRetention: () => get("/admin/log-retention", {}),
|
|
425
433
|
adminProductHardening: () => get("/admin/product-hardening", {}),
|
|
426
434
|
adminSecurity: () => get("/admin/security/overview", {}),
|
|
427
435
|
adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),
|