ltcai 6.1.0 → 6.2.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 +33 -38
- package/docs/CHANGELOG.md +33 -1
- package/frontend/src/App.tsx +3 -1286
- package/frontend/src/components/LanguageSwitcher.tsx +23 -0
- package/frontend/src/components/ProductFlow.tsx +32 -669
- package/frontend/src/components/onboarding/ProductFlowScreens.tsx +688 -0
- package/frontend/src/features/admin/AdminConsole.tsx +294 -0
- package/frontend/src/features/brain/BrainHome.tsx +999 -0
- package/frontend/src/features/brain/brainData.ts +98 -0
- package/frontend/src/features/brain/graphLayout.ts +26 -0
- package/frontend/src/features/brain/types.ts +44 -0
- package/frontend/src/i18n.ts +198 -0
- package/frontend/src/styles.css +220 -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/tools.py +50 -23
- package/latticeai/app_factory.py +36 -45
- package/latticeai/cli/entrypoint.py +283 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/__init__.py +0 -0
- package/latticeai/integrations/telegram_bot.py +1009 -0
- package/latticeai/runtime/lifespan_runtime.py +1 -1
- package/latticeai/runtime/platform_runtime_wiring.py +87 -0
- package/latticeai/runtime/router_registration.py +49 -30
- package/latticeai/services/p_reinforce.py +258 -0
- package/latticeai/services/router_context.py +52 -0
- package/ltcai_cli.py +7 -279
- package/p_reinforce.py +4 -255
- package/package.json +2 -1
- package/scripts/release_smoke.py +133 -0
- package/scripts/wheel_smoke.py +4 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-B744yblP.css → index-B2-1Gm0q.css} +1 -1
- package/static/app/assets/index-D91Rz5--.js +16 -0
- package/static/app/assets/index-D91Rz5--.js.map +1 -0
- package/static/app/index.html +2 -2
- package/telegram_bot.py +9 -1002
- package/static/app/assets/index-DYaUKNfl.js +0 -16
- package/static/app/assets/index-DYaUKNfl.js.map +0 -1
package/frontend/src/App.tsx
CHANGED
|
@@ -1,75 +1,11 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
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";
|
|
21
|
-
import { Button } from "@/components/ui/button";
|
|
22
|
-
import { type BrainState, LivingBrain, triggerBrainRecall } from "@/components/LivingBrain";
|
|
2
|
+
import { type BrainState } from "@/components/LivingBrain";
|
|
23
3
|
import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
|
|
24
4
|
import { useAppStore } from "@/store/appStore";
|
|
25
|
-
import { asArray } from "@/lib/utils";
|
|
26
|
-
import { LANGUAGE_LABELS, t, type Language } from "@/i18n";
|
|
27
5
|
import { parseHash } from "@/routes";
|
|
28
6
|
import { ActPage } from "@/pages/Act";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type BrainDepth = 1 | 2 | 3 | 4 | 5;
|
|
32
|
-
type AdminFilterState = Required<Pick<AdminAuditFilters, "q" | "actor" | "action" | "severity">> & { limit: number };
|
|
33
|
-
|
|
34
|
-
type Message = {
|
|
35
|
-
role: "user" | "assistant";
|
|
36
|
-
content: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
type MemoryFragment = {
|
|
40
|
-
id: string;
|
|
41
|
-
title: string;
|
|
42
|
-
kind: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
type KnowledgeConcept = {
|
|
46
|
-
id: string;
|
|
47
|
-
label: string;
|
|
48
|
-
type: string;
|
|
49
|
-
summary: string;
|
|
50
|
-
importance: number;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type RelationshipThread = {
|
|
54
|
-
id: string;
|
|
55
|
-
source: string;
|
|
56
|
-
target: string;
|
|
57
|
-
label: string;
|
|
58
|
-
weight: number;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
type KnowledgeGraphModel = {
|
|
62
|
-
nodes: KnowledgeConcept[];
|
|
63
|
-
edges: RelationshipThread[];
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const DEPTHS: Array<{ level: BrainDepth; label: string; state: BrainState }> = [
|
|
67
|
-
{ level: 1, label: "Living Brain", state: "idle" },
|
|
68
|
-
{ level: 2, label: "Memory Layer", state: "recalling" },
|
|
69
|
-
{ level: 3, label: "Knowledge Layer", state: "synthesizing" },
|
|
70
|
-
{ level: 4, label: "Relationship Layer", state: "planning" },
|
|
71
|
-
{ level: 5, label: "Knowledge Graph", state: "synthesizing" },
|
|
72
|
-
];
|
|
7
|
+
import { BrainHome } from "@/features/brain/BrainHome";
|
|
8
|
+
import { AdminConsole } from "@/features/admin/AdminConsole";
|
|
73
9
|
|
|
74
10
|
export default function App() {
|
|
75
11
|
const theme = useAppStore((state) => state.theme);
|
|
@@ -145,1225 +81,6 @@ function useBrainState() {
|
|
|
145
81
|
return { state, intensity, setBrain };
|
|
146
82
|
}
|
|
147
83
|
|
|
148
|
-
function BrainHome({
|
|
149
|
-
brainState,
|
|
150
|
-
intensity,
|
|
151
|
-
onBrainChange,
|
|
152
|
-
}: {
|
|
153
|
-
brainState: BrainState;
|
|
154
|
-
intensity: number;
|
|
155
|
-
onBrainChange: (state: BrainState, intensity?: number) => void;
|
|
156
|
-
}) {
|
|
157
|
-
const qc = useQueryClient();
|
|
158
|
-
const language = useAppStore((state) => state.language);
|
|
159
|
-
const [messages, setMessages] = React.useState<Message[]>([]);
|
|
160
|
-
const [draft, setDraft] = React.useState("");
|
|
161
|
-
const [imageData, setImageData] = React.useState<string | null>(null);
|
|
162
|
-
const [streaming, setStreaming] = React.useState(false);
|
|
163
|
-
const [conversationId, setConversationId] = React.useState<string | null>(null);
|
|
164
|
-
const [explorationDepth, setExplorationDepth] = React.useState<BrainDepth>(1);
|
|
165
|
-
const [graphSearch, setGraphSearch] = React.useState("");
|
|
166
|
-
const [selectedGraphId, setSelectedGraphId] = React.useState<string | null>(null);
|
|
167
|
-
const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
|
|
168
|
-
const streamRef = React.useRef<HTMLDivElement>(null);
|
|
169
|
-
const recallTimerRef = React.useRef<number | null>(null);
|
|
170
|
-
|
|
171
|
-
const memoriesQ = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
|
|
172
|
-
const historyQ = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
|
|
173
|
-
const graphQ = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
|
|
174
|
-
const modelsQ = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
|
|
175
|
-
|
|
176
|
-
const memoryFragments = React.useMemo(
|
|
177
|
-
() => buildMemoryFragments(memoriesQ.data?.data, historyQ.data?.data),
|
|
178
|
-
[memoriesQ.data, historyQ.data],
|
|
179
|
-
);
|
|
180
|
-
const graphModel = React.useMemo(() => parseKnowledgeGraph(graphQ.data?.data), [graphQ.data]);
|
|
181
|
-
const knowledgeConcepts = React.useMemo(
|
|
182
|
-
() => graphModel.nodes.slice(0, 10),
|
|
183
|
-
[graphModel.nodes],
|
|
184
|
-
);
|
|
185
|
-
const relationshipThreads = React.useMemo(
|
|
186
|
-
() => graphModel.edges.slice(0, 10),
|
|
187
|
-
[graphModel.edges],
|
|
188
|
-
);
|
|
189
|
-
const modelName = React.useMemo(() => currentModelName(modelsQ.data?.data), [modelsQ.data]);
|
|
190
|
-
const currentDepth = DEPTHS[explorationDepth - 1];
|
|
191
|
-
const starterPrompts = React.useMemo(
|
|
192
|
-
() => [
|
|
193
|
-
t(language, "brain.prompt.remember"),
|
|
194
|
-
t(language, "brain.prompt.know"),
|
|
195
|
-
t(language, "brain.prompt.plan"),
|
|
196
|
-
],
|
|
197
|
-
[language],
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
React.useEffect(() => {
|
|
201
|
-
if (streaming) onBrainChange("thinking", 0.94);
|
|
202
|
-
else if (draft.trim().length > 4) onBrainChange("listening", 0.76);
|
|
203
|
-
else onBrainChange(currentDepth.state, explorationDepth === 1 ? 0.58 : 0.66 + explorationDepth * 0.06);
|
|
204
|
-
}, [streaming, draft, currentDepth.state, explorationDepth, onBrainChange]);
|
|
205
|
-
|
|
206
|
-
React.useEffect(() => {
|
|
207
|
-
const stream = streamRef.current;
|
|
208
|
-
if (stream) stream.scrollTop = stream.scrollHeight;
|
|
209
|
-
}, [messages]);
|
|
210
|
-
|
|
211
|
-
React.useEffect(() => {
|
|
212
|
-
return () => {
|
|
213
|
-
if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
|
|
214
|
-
};
|
|
215
|
-
}, []);
|
|
216
|
-
|
|
217
|
-
async function send() {
|
|
218
|
-
const text = draft.trim();
|
|
219
|
-
if (!text || streaming) return;
|
|
220
|
-
const activeConversationId = conversationId || `brain-${Date.now()}`;
|
|
221
|
-
if (!conversationId) setConversationId(activeConversationId);
|
|
222
|
-
|
|
223
|
-
setMessages((items) => [...items, { role: "user", content: text }, { role: "assistant", content: "" }]);
|
|
224
|
-
setDraft("");
|
|
225
|
-
setImageData(null);
|
|
226
|
-
setStreaming(true);
|
|
227
|
-
setMemoryFeedback(null);
|
|
228
|
-
onBrainChange("thinking", 0.96);
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
const result = await latticeApi.streamChat(
|
|
232
|
-
{ message: text, conversation_id: activeConversationId, image_data: imageData || undefined },
|
|
233
|
-
{
|
|
234
|
-
onChunk: (_delta, fullText) => {
|
|
235
|
-
setMessages((items) => {
|
|
236
|
-
const next = [...items];
|
|
237
|
-
next[next.length - 1] = { role: "assistant", content: fullText };
|
|
238
|
-
return next;
|
|
239
|
-
});
|
|
240
|
-
},
|
|
241
|
-
onTrace: (trace) => {
|
|
242
|
-
if (!trace) return;
|
|
243
|
-
onBrainChange("recalling", 0.9);
|
|
244
|
-
triggerBrainRecall();
|
|
245
|
-
if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
|
|
246
|
-
recallTimerRef.current = window.setTimeout(() => onBrainChange("thinking", 0.9), 900);
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
);
|
|
250
|
-
if (result.error) {
|
|
251
|
-
setMessages((items) => {
|
|
252
|
-
const next = [...items];
|
|
253
|
-
next[next.length - 1] = { role: "assistant", content: `${t(language, "brain.unavailable")}: ${result.error}` };
|
|
254
|
-
return next;
|
|
255
|
-
});
|
|
256
|
-
} else {
|
|
257
|
-
setMemoryFeedback(t(language, "brain.saved", { topics: knowledgeConcepts.length, memories: memoryFragments.length }));
|
|
258
|
-
}
|
|
259
|
-
} finally {
|
|
260
|
-
setStreaming(false);
|
|
261
|
-
void qc.invalidateQueries({ queryKey: ["chatHistory"] });
|
|
262
|
-
void qc.invalidateQueries({ queryKey: ["memoryManager"] });
|
|
263
|
-
void qc.invalidateQueries({ queryKey: ["graph"] });
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function deepen() {
|
|
268
|
-
setExplorationDepth((depth) => {
|
|
269
|
-
const next = Math.min(5, depth + 1) as BrainDepth;
|
|
270
|
-
const nextDepth = DEPTHS[next - 1];
|
|
271
|
-
onBrainChange(nextDepth.state, 0.66 + next * 0.06);
|
|
272
|
-
if (next >= 2) triggerBrainRecall();
|
|
273
|
-
return next;
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function jumpToDepth(next: BrainDepth) {
|
|
278
|
-
setExplorationDepth(next);
|
|
279
|
-
const nextDepth = DEPTHS[next - 1];
|
|
280
|
-
onBrainChange(nextDepth.state, next === 1 ? 0.58 : 0.66 + next * 0.06);
|
|
281
|
-
if (next >= 2) triggerBrainRecall();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function surface() {
|
|
285
|
-
setExplorationDepth(1);
|
|
286
|
-
setSelectedGraphId(null);
|
|
287
|
-
setGraphSearch("");
|
|
288
|
-
onBrainChange("idle", 0.58);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function recallMemory(fragment: MemoryFragment) {
|
|
292
|
-
triggerBrainRecall();
|
|
293
|
-
setExplorationDepth((depth) => Math.max(depth, 2) as BrainDepth);
|
|
294
|
-
setMessages((items) => [
|
|
295
|
-
...items,
|
|
296
|
-
{ role: "assistant", content: t(language, "brain.recalled", { title: fragment.title }) },
|
|
297
|
-
]);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return (
|
|
301
|
-
<main className="brain-home" aria-label="Lattice Brain">
|
|
302
|
-
<section className="brain-presence" aria-label="Brain exploration">
|
|
303
|
-
<div className="brain-exploration" data-depth={explorationDepth}>
|
|
304
|
-
<LivingBrain
|
|
305
|
-
state={brainState}
|
|
306
|
-
intensity={intensity + explorationDepth * 0.035}
|
|
307
|
-
size="large"
|
|
308
|
-
depth={explorationDepth}
|
|
309
|
-
showLabel={false}
|
|
310
|
-
onInteract={deepen}
|
|
311
|
-
/>
|
|
312
|
-
|
|
313
|
-
<div className="brain-depth-badge" aria-live="polite">
|
|
314
|
-
<span>{t(language, "brain.level")} {explorationDepth}</span>
|
|
315
|
-
<strong>{t(language, `brain.depth.${explorationDepth}`)}</strong>
|
|
316
|
-
</div>
|
|
317
|
-
|
|
318
|
-
<div className="brain-depth-actions" aria-label="Brain quick views">
|
|
319
|
-
<button type="button" className={explorationDepth === 2 ? "is-active" : ""} onClick={() => jumpToDepth(2)}>{t(language, "brain.view.memories")}</button>
|
|
320
|
-
<button type="button" className={explorationDepth === 3 ? "is-active" : ""} onClick={() => jumpToDepth(3)}>{t(language, "brain.view.topics")}</button>
|
|
321
|
-
<button type="button" className={explorationDepth === 4 ? "is-active" : ""} onClick={() => jumpToDepth(4)}>{t(language, "brain.view.relationships")}</button>
|
|
322
|
-
<button type="button" className={explorationDepth === 5 ? "is-active" : ""} onClick={() => jumpToDepth(5)}>{t(language, "brain.view.graph")}</button>
|
|
323
|
-
</div>
|
|
324
|
-
|
|
325
|
-
<div className="brain-field-layer" aria-hidden={explorationDepth < 2}>
|
|
326
|
-
<DepthEmergence
|
|
327
|
-
depth={explorationDepth}
|
|
328
|
-
memories={memoryFragments}
|
|
329
|
-
concepts={knowledgeConcepts}
|
|
330
|
-
relationships={relationshipThreads}
|
|
331
|
-
graphModel={graphModel}
|
|
332
|
-
graphSearch={graphSearch}
|
|
333
|
-
selectedGraphId={selectedGraphId}
|
|
334
|
-
onGraphSearch={setGraphSearch}
|
|
335
|
-
onSelectGraphNode={setSelectedGraphId}
|
|
336
|
-
onRecallMemory={recallMemory}
|
|
337
|
-
/>
|
|
338
|
-
</div>
|
|
339
|
-
|
|
340
|
-
{explorationDepth > 1 ? (
|
|
341
|
-
<button className="brain-surface-control" type="button" onClick={surface}>
|
|
342
|
-
{t(language, "brain.surface")}
|
|
343
|
-
</button>
|
|
344
|
-
) : null}
|
|
345
|
-
</div>
|
|
346
|
-
</section>
|
|
347
|
-
|
|
348
|
-
<section className="brain-conversation" aria-label="Conversation">
|
|
349
|
-
<div className="brain-conversation-header">
|
|
350
|
-
<div>
|
|
351
|
-
<h1>{t(language, "brain.title")}</h1>
|
|
352
|
-
<span>{t(language, `brain.depth.${explorationDepth}`)}</span>
|
|
353
|
-
</div>
|
|
354
|
-
<LanguageSwitcher compact />
|
|
355
|
-
<div className="brain-ownership-strip" aria-label="Brain ownership guarantees">
|
|
356
|
-
<span>{t(language, "brain.local")}</span>
|
|
357
|
-
<span>{t(language, "brain.portable")}</span>
|
|
358
|
-
<span>{t(language, "brain.private")}</span>
|
|
359
|
-
</div>
|
|
360
|
-
<div>{modelName}</div>
|
|
361
|
-
<button className="brain-admin-link" type="button" onClick={() => navigateHash("/admin")}>
|
|
362
|
-
<ShieldCheck className="h-3.5 w-3.5" />
|
|
363
|
-
{t(language, "brain.admin")}
|
|
364
|
-
</button>
|
|
365
|
-
</div>
|
|
366
|
-
|
|
367
|
-
<div ref={streamRef} className="brain-stream">
|
|
368
|
-
<BrainOverviewPanel
|
|
369
|
-
memories={memoryFragments}
|
|
370
|
-
concepts={knowledgeConcepts}
|
|
371
|
-
onOpenDepth={jumpToDepth}
|
|
372
|
-
/>
|
|
373
|
-
{messages.length === 0 ? (
|
|
374
|
-
<div className="mind-empty">
|
|
375
|
-
<div className="mind-empty-kicker">{t(language, "brain.empty.kicker")}</div>
|
|
376
|
-
<div className="mind-empty-title">{t(language, "brain.empty.title")}</div>
|
|
377
|
-
<p>{t(language, "brain.empty.body")}</p>
|
|
378
|
-
<div className="mind-empty-prompts" aria-label="Starter prompts">
|
|
379
|
-
{starterPrompts.map((prompt) => (
|
|
380
|
-
<button key={prompt} type="button" onClick={() => setDraft(prompt)}>
|
|
381
|
-
{prompt}
|
|
382
|
-
</button>
|
|
383
|
-
))}
|
|
384
|
-
</div>
|
|
385
|
-
<div className="mind-empty-trail" aria-label={t(language, "brain.empty.trail.label")}>
|
|
386
|
-
<span>{t(language, "brain.empty.trail.save")}</span>
|
|
387
|
-
<span>{t(language, "brain.empty.trail.recall")}</span>
|
|
388
|
-
<span>{t(language, "brain.empty.trail.backup")}</span>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
) : (
|
|
392
|
-
messages.map((message, index) => (
|
|
393
|
-
<div key={`${message.role}-${index}`} className={`brain-message ${message.role}`}>
|
|
394
|
-
<div className="brain-message-bubble">{message.content}</div>
|
|
395
|
-
</div>
|
|
396
|
-
))
|
|
397
|
-
)}
|
|
398
|
-
</div>
|
|
399
|
-
|
|
400
|
-
{memoryFeedback ? <div className="brain-save-feedback" role="status">{memoryFeedback}</div> : null}
|
|
401
|
-
|
|
402
|
-
<BrainCarePanel language={language} />
|
|
403
|
-
|
|
404
|
-
<div className="brain-composer">
|
|
405
|
-
<textarea
|
|
406
|
-
value={draft}
|
|
407
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
408
|
-
onKeyDown={(event) => {
|
|
409
|
-
if (event.key === "Enter" && !event.shiftKey) {
|
|
410
|
-
event.preventDefault();
|
|
411
|
-
void send();
|
|
412
|
-
}
|
|
413
|
-
}}
|
|
414
|
-
placeholder={t(language, "brain.placeholder")}
|
|
415
|
-
/>
|
|
416
|
-
<div className="brain-composer-actions">
|
|
417
|
-
<label className="brain-image-input">
|
|
418
|
-
<ImagePlus className="h-3.5 w-3.5" />
|
|
419
|
-
<span>{t(language, "brain.image")}</span>
|
|
420
|
-
<input
|
|
421
|
-
type="file"
|
|
422
|
-
accept="image/*"
|
|
423
|
-
className="sr-only"
|
|
424
|
-
onChange={async (event) => {
|
|
425
|
-
const file = event.target.files?.[0];
|
|
426
|
-
if (file) setImageData(await fileToDataUrl(file));
|
|
427
|
-
}}
|
|
428
|
-
/>
|
|
429
|
-
</label>
|
|
430
|
-
{imageData ? <span className="brain-quiet-success">{t(language, "brain.imageAttached")}</span> : null}
|
|
431
|
-
<Button onClick={() => void send()} disabled={!draft.trim() || streaming} className="rounded-full px-5">
|
|
432
|
-
<Send className="h-4 w-4" /> {t(language, "brain.send")}
|
|
433
|
-
</Button>
|
|
434
|
-
</div>
|
|
435
|
-
</div>
|
|
436
|
-
</section>
|
|
437
|
-
</main>
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|
442
|
-
const language = useAppStore((state) => state.language);
|
|
443
|
-
const setLanguage = useAppStore((state) => state.setLanguage);
|
|
444
|
-
|
|
445
|
-
return (
|
|
446
|
-
<div className={compact ? "language-switcher compact" : "language-switcher"} aria-label={t(language, "language.label")}>
|
|
447
|
-
{(["ko", "en"] as Language[]).map((item) => (
|
|
448
|
-
<button
|
|
449
|
-
key={item}
|
|
450
|
-
type="button"
|
|
451
|
-
className={language === item ? "is-active" : ""}
|
|
452
|
-
onClick={() => setLanguage(item)}
|
|
453
|
-
aria-pressed={language === item}
|
|
454
|
-
>
|
|
455
|
-
{LANGUAGE_LABELS[item]}
|
|
456
|
-
</button>
|
|
457
|
-
))}
|
|
458
|
-
</div>
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
463
|
-
const qc = useQueryClient();
|
|
464
|
-
const language = useAppStore((state) => state.language);
|
|
465
|
-
const [filters, setFilters] = React.useState<AdminFilterState>({ q: "", actor: "", action: "", severity: "", limit: 50 });
|
|
466
|
-
const { summaryQ, statsQ, usersQ, auditQ, securityQ, securityEventsQ, policiesQ, rolesQ, retentionQ, indexQ } = useAdminConsoleData(filters);
|
|
467
|
-
const rebuildIndex = useMutation({
|
|
468
|
-
mutationFn: latticeApi.rebuildIndex,
|
|
469
|
-
onSuccess: () => void qc.invalidateQueries({ queryKey: ["indexStatus"] }),
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
const users = asArray(usersQ.data?.data);
|
|
473
|
-
const auditEvents = asArray((auditQ.data?.data as ApiRecord | undefined)?.recent_events);
|
|
474
|
-
const securityEvents = asArray((securityEventsQ.data?.data as ApiRecord | undefined)?.events);
|
|
475
|
-
const policies = asArray((policiesQ.data?.data as ApiRecord | undefined)?.policies);
|
|
476
|
-
const roles = asArray((rolesQ.data?.data as ApiRecord | undefined)?.roles);
|
|
477
|
-
const retention = (retentionQ.data?.data || {}) as ApiRecord;
|
|
478
|
-
|
|
479
|
-
return (
|
|
480
|
-
<main className="admin-console" aria-label="Lattice Admin">
|
|
481
|
-
<header className="admin-console-header">
|
|
482
|
-
<button className="admin-back-button" type="button" onClick={onBack}>
|
|
483
|
-
<ArrowLeft className="h-4 w-4" />
|
|
484
|
-
{t(language, "admin.back")}
|
|
485
|
-
</button>
|
|
486
|
-
<div>
|
|
487
|
-
<span>{t(language, "admin.kicker")}</span>
|
|
488
|
-
<h1>{t(language, "admin.title")}</h1>
|
|
489
|
-
<p>{t(language, "admin.body")}</p>
|
|
490
|
-
</div>
|
|
491
|
-
<LanguageSwitcher compact />
|
|
492
|
-
</header>
|
|
493
|
-
|
|
494
|
-
<section className="admin-metrics" aria-label="Admin overview">
|
|
495
|
-
<AdminMetric icon={<Users className="h-4 w-4" />} label="Users" value={String(users.length)} detail={sourceLabel(usersQ.data)} />
|
|
496
|
-
<AdminMetric
|
|
497
|
-
icon={<Activity className="h-4 w-4" />}
|
|
498
|
-
label="Recent logs"
|
|
499
|
-
value={String(auditEvents.length + securityEvents.length)}
|
|
500
|
-
detail={sourceLabel(auditQ.data)}
|
|
501
|
-
/>
|
|
502
|
-
<AdminMetric
|
|
503
|
-
icon={<ShieldCheck className="h-4 w-4" />}
|
|
504
|
-
label="Security"
|
|
505
|
-
value={adminStatusLabel(securityQ.data?.data, "status") || (securityQ.data?.ok ? "Ready" : "Unavailable")}
|
|
506
|
-
detail={sourceLabel(securityQ.data)}
|
|
507
|
-
/>
|
|
508
|
-
<AdminMetric
|
|
509
|
-
icon={<ServerCog className="h-4 w-4" />}
|
|
510
|
-
label="Brain index"
|
|
511
|
-
value={adminStatusLabel(indexQ.data?.data, "status") || (indexQ.data?.ok ? "Indexed" : "Unknown")}
|
|
512
|
-
detail={indexDetail(indexQ.data?.data)}
|
|
513
|
-
/>
|
|
514
|
-
</section>
|
|
515
|
-
|
|
516
|
-
<section className="admin-grid">
|
|
517
|
-
<AdminPanel title="User Directory" eyebrow="People">
|
|
518
|
-
<AdminList
|
|
519
|
-
items={users.slice(0, 8)}
|
|
520
|
-
empty="No users reported by the admin API."
|
|
521
|
-
render={(item) => {
|
|
522
|
-
const user = item as ApiRecord;
|
|
523
|
-
return (
|
|
524
|
-
<>
|
|
525
|
-
<strong>{stringValue(user.name || user.email || user.id, "Local user")}</strong>
|
|
526
|
-
<span>{stringValue(user.role || user.status || user.workspace_id, "member")}</span>
|
|
527
|
-
</>
|
|
528
|
-
);
|
|
529
|
-
}}
|
|
530
|
-
/>
|
|
531
|
-
</AdminPanel>
|
|
532
|
-
|
|
533
|
-
<AdminPanel title="Role Permissions" eyebrow="Access">
|
|
534
|
-
<AdminList
|
|
535
|
-
items={roles.slice(0, 6)}
|
|
536
|
-
empty="No role matrix reported."
|
|
537
|
-
render={(item) => {
|
|
538
|
-
const role = item as ApiRecord;
|
|
539
|
-
return (
|
|
540
|
-
<>
|
|
541
|
-
<strong>{stringValue(role.role, "role")} · {stringValue(role.members, "0")} users</strong>
|
|
542
|
-
<span>{asArray(role.caps).slice(0, 4).map((cap) => stringValue(cap, "")).filter(Boolean).join(", ") || "No caps"}</span>
|
|
543
|
-
</>
|
|
544
|
-
);
|
|
545
|
-
}}
|
|
546
|
-
/>
|
|
547
|
-
</AdminPanel>
|
|
548
|
-
|
|
549
|
-
<AdminPanel title="Activity Logs" eyebrow="Audit">
|
|
550
|
-
<AdminLogFilters filters={filters} onChange={setFilters} matched={(auditQ.data?.data as ApiRecord | undefined)?.filters as ApiRecord | undefined} />
|
|
551
|
-
<AdminList
|
|
552
|
-
items={auditEvents.slice(0, 8)}
|
|
553
|
-
empty="No recent audit events."
|
|
554
|
-
render={(item) => renderLogRow(item as ApiRecord)}
|
|
555
|
-
/>
|
|
556
|
-
</AdminPanel>
|
|
557
|
-
|
|
558
|
-
<AdminPanel title="Security Events" eyebrow="Protection">
|
|
559
|
-
<AdminList
|
|
560
|
-
items={securityEvents.slice(0, 8)}
|
|
561
|
-
empty="No security events reported."
|
|
562
|
-
render={(item) => renderLogRow(item as ApiRecord)}
|
|
563
|
-
/>
|
|
564
|
-
</AdminPanel>
|
|
565
|
-
|
|
566
|
-
<AdminPanel title="Brain Operations" eyebrow="Maintenance">
|
|
567
|
-
<div className="admin-operation">
|
|
568
|
-
<div>
|
|
569
|
-
<strong>{indexDetail(indexQ.data?.data)}</strong>
|
|
570
|
-
<span>{summaryText(summaryQ.data?.data) || summaryText(statsQ.data?.data) || "Local Brain services are separated from user chat."}</span>
|
|
571
|
-
</div>
|
|
572
|
-
<Button variant="outline" size="sm" disabled={rebuildIndex.isPending} onClick={() => rebuildIndex.mutate()}>
|
|
573
|
-
<RotateCcw className="h-3.5 w-3.5" />
|
|
574
|
-
{rebuildIndex.isPending ? "Rebuilding" : "Rebuild index"}
|
|
575
|
-
</Button>
|
|
576
|
-
</div>
|
|
577
|
-
<div className="admin-policy-strip">
|
|
578
|
-
{policies.slice(0, 5).map((item, index) => {
|
|
579
|
-
const policy = item as ApiRecord;
|
|
580
|
-
return <span key={`${stringValue(policy.id || policy.name, "policy")}-${index}`}>{stringValue(policy.label || policy.name || policy.id, "Policy")}</span>;
|
|
581
|
-
})}
|
|
582
|
-
{!policies.length ? <span>Policy API quiet</span> : null}
|
|
583
|
-
</div>
|
|
584
|
-
<div className="admin-retention">
|
|
585
|
-
<strong>{stringValue(retention.retention_days, "90")} day retention</strong>
|
|
586
|
-
<span>{stringValue(retention.retained_events, "0")} retained · {stringValue(retention.prune_candidates, "0")} ready for export/prune review</span>
|
|
587
|
-
</div>
|
|
588
|
-
</AdminPanel>
|
|
589
|
-
|
|
590
|
-
</section>
|
|
591
|
-
</main>
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function useAdminConsoleData(filters: AdminFilterState) {
|
|
596
|
-
const auditFilters = React.useMemo<AdminAuditFilters>(() => ({
|
|
597
|
-
q: filters.q || undefined,
|
|
598
|
-
actor: filters.actor || undefined,
|
|
599
|
-
action: filters.action || undefined,
|
|
600
|
-
severity: filters.severity || undefined,
|
|
601
|
-
limit: filters.limit,
|
|
602
|
-
}), [filters]);
|
|
603
|
-
|
|
604
|
-
return {
|
|
605
|
-
summaryQ: useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary }),
|
|
606
|
-
statsQ: useQuery({ queryKey: ["adminStats"], queryFn: latticeApi.adminStats }),
|
|
607
|
-
usersQ: useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers }),
|
|
608
|
-
auditQ: useQuery({ queryKey: ["adminAudit", auditFilters], queryFn: () => latticeApi.adminAudit(auditFilters) }),
|
|
609
|
-
securityQ: useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity }),
|
|
610
|
-
securityEventsQ: useQuery({ queryKey: ["adminSecurityEvents"], queryFn: () => latticeApi.adminSecurityEvents(50) }),
|
|
611
|
-
policiesQ: useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies }),
|
|
612
|
-
rolesQ: useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles }),
|
|
613
|
-
retentionQ: useQuery({ queryKey: ["adminLogRetention"], queryFn: latticeApi.adminLogRetention }),
|
|
614
|
-
indexQ: useQuery({ queryKey: ["indexStatus"], queryFn: latticeApi.indexStatus }),
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function AdminLogFilters({
|
|
619
|
-
filters,
|
|
620
|
-
matched,
|
|
621
|
-
onChange,
|
|
622
|
-
}: {
|
|
623
|
-
filters: AdminFilterState;
|
|
624
|
-
matched?: ApiRecord;
|
|
625
|
-
onChange: React.Dispatch<React.SetStateAction<AdminFilterState>>;
|
|
626
|
-
}) {
|
|
627
|
-
return (
|
|
628
|
-
<div className="admin-log-filters" aria-label="Audit log filters">
|
|
629
|
-
<label>
|
|
630
|
-
<Search className="h-3.5 w-3.5" />
|
|
631
|
-
<input
|
|
632
|
-
value={filters.q}
|
|
633
|
-
onChange={(event) => onChange((current) => ({ ...current, q: event.target.value }))}
|
|
634
|
-
placeholder="Search logs"
|
|
635
|
-
aria-label="Search audit logs"
|
|
636
|
-
/>
|
|
637
|
-
</label>
|
|
638
|
-
<label>
|
|
639
|
-
<ListFilter className="h-3.5 w-3.5" />
|
|
640
|
-
<select
|
|
641
|
-
value={filters.severity}
|
|
642
|
-
onChange={(event) => onChange((current) => ({ ...current, severity: event.target.value }))}
|
|
643
|
-
aria-label="Filter by severity"
|
|
644
|
-
>
|
|
645
|
-
<option value="">All severities</option>
|
|
646
|
-
<option value="informational">Informational</option>
|
|
647
|
-
<option value="notice">Notice</option>
|
|
648
|
-
<option value="warning">Warning</option>
|
|
649
|
-
<option value="high">High</option>
|
|
650
|
-
</select>
|
|
651
|
-
</label>
|
|
652
|
-
<span>{stringValue(matched?.matched_events, "0")} matched</span>
|
|
653
|
-
</div>
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function AdminMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
|
658
|
-
return (
|
|
659
|
-
<div className="admin-metric">
|
|
660
|
-
<div>{icon}</div>
|
|
661
|
-
<span>{label}</span>
|
|
662
|
-
<strong>{value}</strong>
|
|
663
|
-
<small>{detail}</small>
|
|
664
|
-
</div>
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function AdminPanel({ eyebrow, title, children }: { eyebrow: string; title: string; children: React.ReactNode }) {
|
|
669
|
-
return (
|
|
670
|
-
<section className="admin-panel">
|
|
671
|
-
<div className="admin-panel-head">
|
|
672
|
-
<span>{eyebrow}</span>
|
|
673
|
-
<h2>{title}</h2>
|
|
674
|
-
</div>
|
|
675
|
-
{children}
|
|
676
|
-
</section>
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function AdminList({ items, empty, render }: { items: unknown[]; empty: string; render: (item: unknown) => React.ReactNode }) {
|
|
681
|
-
if (!items.length) return <div className="admin-empty">{empty}</div>;
|
|
682
|
-
return <div className="admin-list">{items.map((item, index) => <div key={index} className="admin-list-row">{render(item)}</div>)}</div>;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function BrainCarePanel({ language }: { language: Language }) {
|
|
686
|
-
const qc = useQueryClient();
|
|
687
|
-
const [expanded, setExpanded] = React.useState(false);
|
|
688
|
-
const [archivePath, setArchivePath] = React.useState("");
|
|
689
|
-
const [passphrase, setPassphrase] = React.useState("");
|
|
690
|
-
const [latestResult, setLatestResult] = React.useState<ApiResult | null>(null);
|
|
691
|
-
const portabilityQ = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
|
|
692
|
-
const backupHealthQ = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
|
|
693
|
-
const rememberResult = React.useCallback((result: ApiResult) => setLatestResult(result), []);
|
|
694
|
-
|
|
695
|
-
const exportGraph = useCareMutation(() => latticeApi.graphExport(), undefined, rememberResult);
|
|
696
|
-
const backupGraph = useCareMutation(() => latticeApi.graphBackup(), () => {
|
|
697
|
-
void qc.invalidateQueries({ queryKey: ["backupHealth"] });
|
|
698
|
-
void qc.invalidateQueries({ queryKey: ["portability"] });
|
|
699
|
-
}, rememberResult);
|
|
700
|
-
const archiveBrain = useCareMutation(
|
|
701
|
-
() => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase }),
|
|
702
|
-
() => void qc.invalidateQueries({ queryKey: ["backupHealth"] }),
|
|
703
|
-
rememberResult,
|
|
704
|
-
);
|
|
705
|
-
const inspectArchive = useCareMutation(() => latticeApi.brainArchiveInspect({
|
|
706
|
-
path: archivePath.trim(),
|
|
707
|
-
passphrase: passphrase || null,
|
|
708
|
-
}), undefined, rememberResult);
|
|
709
|
-
const restorePreview = useCareMutation(() => latticeApi.brainArchiveRestore({
|
|
710
|
-
path: archivePath.trim(),
|
|
711
|
-
passphrase,
|
|
712
|
-
dry_run: true,
|
|
713
|
-
confirm: false,
|
|
714
|
-
}), undefined, rememberResult);
|
|
715
|
-
|
|
716
|
-
const portableFormat = portabilityLabel(portabilityQ.data?.data);
|
|
717
|
-
const backupStatus = backupHealthLabel(backupHealthQ.data?.data);
|
|
718
|
-
|
|
719
|
-
return (
|
|
720
|
-
<section className={`brain-care-panel ${expanded ? "is-expanded" : "is-collapsed"}`} aria-label={t(language, "care.title")}>
|
|
721
|
-
<button
|
|
722
|
-
className="brain-care-summary"
|
|
723
|
-
type="button"
|
|
724
|
-
aria-expanded={expanded}
|
|
725
|
-
aria-controls="brain-care-details"
|
|
726
|
-
onClick={() => setExpanded((value) => !value)}
|
|
727
|
-
>
|
|
728
|
-
<span className="brain-care-summary-main">
|
|
729
|
-
<span><ShieldCheck className="h-3.5 w-3.5" /> {t(language, "care.title")}</span>
|
|
730
|
-
<strong>{t(language, "care.subtitle")}</strong>
|
|
731
|
-
</span>
|
|
732
|
-
<div className="brain-care-proof" aria-label="Ownership model">
|
|
733
|
-
<span>{t(language, "care.private")}</span>
|
|
734
|
-
<span>{portableFormat}</span>
|
|
735
|
-
<span>{backupStatus}</span>
|
|
736
|
-
</div>
|
|
737
|
-
<ChevronDown className="brain-care-toggle h-4 w-4" aria-hidden="true" />
|
|
738
|
-
</button>
|
|
739
|
-
|
|
740
|
-
{expanded ? (
|
|
741
|
-
<div id="brain-care-details" className="brain-care-details">
|
|
742
|
-
<div className="brain-care-actions">
|
|
743
|
-
<CareButton
|
|
744
|
-
icon={<Download className="h-3.5 w-3.5" />}
|
|
745
|
-
label={t(language, "care.export")}
|
|
746
|
-
detail={t(language, "care.export.detail")}
|
|
747
|
-
pendingLabel={t(language, "care.working")}
|
|
748
|
-
pending={exportGraph.isPending}
|
|
749
|
-
onClick={() => exportGraph.mutate()}
|
|
750
|
-
/>
|
|
751
|
-
<CareButton
|
|
752
|
-
icon={<DatabaseBackup className="h-3.5 w-3.5" />}
|
|
753
|
-
label={t(language, "care.backup")}
|
|
754
|
-
detail={t(language, "care.backup.detail")}
|
|
755
|
-
pendingLabel={t(language, "care.working")}
|
|
756
|
-
pending={backupGraph.isPending}
|
|
757
|
-
onClick={() => backupGraph.mutate()}
|
|
758
|
-
/>
|
|
759
|
-
<CareButton
|
|
760
|
-
icon={<Archive className="h-3.5 w-3.5" />}
|
|
761
|
-
label={t(language, "care.archive")}
|
|
762
|
-
detail={t(language, "care.archive.detail")}
|
|
763
|
-
pendingLabel={t(language, "care.working")}
|
|
764
|
-
pending={archiveBrain.isPending}
|
|
765
|
-
disabled={!passphrase.trim()}
|
|
766
|
-
onClick={() => archiveBrain.mutate()}
|
|
767
|
-
/>
|
|
768
|
-
</div>
|
|
769
|
-
|
|
770
|
-
<div className="brain-care-archive">
|
|
771
|
-
<input
|
|
772
|
-
value={archivePath}
|
|
773
|
-
onChange={(event) => setArchivePath(event.target.value)}
|
|
774
|
-
placeholder={t(language, "care.path.placeholder")}
|
|
775
|
-
aria-label={t(language, "care.path.label")}
|
|
776
|
-
/>
|
|
777
|
-
<input
|
|
778
|
-
type="password"
|
|
779
|
-
value={passphrase}
|
|
780
|
-
onChange={(event) => setPassphrase(event.target.value)}
|
|
781
|
-
placeholder={t(language, "care.passphrase.placeholder")}
|
|
782
|
-
aria-label={t(language, "care.passphrase.label")}
|
|
783
|
-
/>
|
|
784
|
-
<div className="brain-care-archive-actions">
|
|
785
|
-
<Button
|
|
786
|
-
variant="outline"
|
|
787
|
-
size="sm"
|
|
788
|
-
disabled={!archivePath.trim() || inspectArchive.isPending}
|
|
789
|
-
onClick={() => inspectArchive.mutate()}
|
|
790
|
-
>
|
|
791
|
-
<Eye className="h-3.5 w-3.5" /> {t(language, "care.inspect")}
|
|
792
|
-
</Button>
|
|
793
|
-
<Button
|
|
794
|
-
variant="outline"
|
|
795
|
-
size="sm"
|
|
796
|
-
disabled={!archivePath.trim() || !passphrase.trim() || restorePreview.isPending}
|
|
797
|
-
onClick={() => restorePreview.mutate()}
|
|
798
|
-
>
|
|
799
|
-
<RotateCcw className="h-3.5 w-3.5" /> {t(language, "care.restorePreview")}
|
|
800
|
-
</Button>
|
|
801
|
-
</div>
|
|
802
|
-
</div>
|
|
803
|
-
|
|
804
|
-
{latestResult ? (
|
|
805
|
-
<div className={`brain-care-result ${latestResult.ok ? "is-ok" : "is-error"}`} role="status">
|
|
806
|
-
{summarizeCareResult(latestResult)}
|
|
807
|
-
</div>
|
|
808
|
-
) : (
|
|
809
|
-
<p className="brain-care-note">
|
|
810
|
-
{t(language, "care.note")}
|
|
811
|
-
</p>
|
|
812
|
-
)}
|
|
813
|
-
</div>
|
|
814
|
-
) : null}
|
|
815
|
-
</section>
|
|
816
|
-
);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function useCareMutation<T extends ApiResult>(
|
|
820
|
-
mutationFn: () => Promise<T>,
|
|
821
|
-
onSuccess?: () => void,
|
|
822
|
-
onResult?: (result: T) => void,
|
|
823
|
-
) {
|
|
824
|
-
return useMutation({
|
|
825
|
-
mutationFn,
|
|
826
|
-
onSuccess: (result) => {
|
|
827
|
-
onResult?.(result);
|
|
828
|
-
onSuccess?.();
|
|
829
|
-
},
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
function CareButton({
|
|
834
|
-
icon,
|
|
835
|
-
label,
|
|
836
|
-
detail,
|
|
837
|
-
pendingLabel,
|
|
838
|
-
pending,
|
|
839
|
-
disabled,
|
|
840
|
-
onClick,
|
|
841
|
-
}: {
|
|
842
|
-
icon: React.ReactNode;
|
|
843
|
-
label: string;
|
|
844
|
-
detail: string;
|
|
845
|
-
pendingLabel: string;
|
|
846
|
-
pending?: boolean;
|
|
847
|
-
disabled?: boolean;
|
|
848
|
-
onClick: () => void;
|
|
849
|
-
}) {
|
|
850
|
-
return (
|
|
851
|
-
<button className="brain-care-button" type="button" disabled={disabled || pending} onClick={onClick}>
|
|
852
|
-
{icon}
|
|
853
|
-
<span>
|
|
854
|
-
<strong>{pending ? pendingLabel : label}</strong>
|
|
855
|
-
<small>{detail}</small>
|
|
856
|
-
</span>
|
|
857
|
-
</button>
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function DepthEmergence({
|
|
862
|
-
depth,
|
|
863
|
-
memories,
|
|
864
|
-
concepts,
|
|
865
|
-
relationships,
|
|
866
|
-
graphModel,
|
|
867
|
-
graphSearch,
|
|
868
|
-
selectedGraphId,
|
|
869
|
-
onGraphSearch,
|
|
870
|
-
onSelectGraphNode,
|
|
871
|
-
onRecallMemory,
|
|
872
|
-
}: {
|
|
873
|
-
depth: BrainDepth;
|
|
874
|
-
memories: MemoryFragment[];
|
|
875
|
-
concepts: KnowledgeConcept[];
|
|
876
|
-
relationships: RelationshipThread[];
|
|
877
|
-
graphModel: KnowledgeGraphModel;
|
|
878
|
-
graphSearch: string;
|
|
879
|
-
selectedGraphId: string | null;
|
|
880
|
-
onGraphSearch: (value: string) => void;
|
|
881
|
-
onSelectGraphNode: (id: string | null) => void;
|
|
882
|
-
onRecallMemory: (fragment: MemoryFragment) => void;
|
|
883
|
-
}) {
|
|
884
|
-
if (depth === 1) return null;
|
|
885
|
-
|
|
886
|
-
return (
|
|
887
|
-
<>
|
|
888
|
-
{depth >= 2 ? (
|
|
889
|
-
<MemoryLayer memories={memories} depth={depth} onRecallMemory={onRecallMemory} />
|
|
890
|
-
) : null}
|
|
891
|
-
{depth >= 3 && depth < 5 ? (
|
|
892
|
-
<KnowledgeLayer concepts={concepts} depth={depth} />
|
|
893
|
-
) : null}
|
|
894
|
-
{depth >= 4 && depth < 5 ? (
|
|
895
|
-
<RelationshipLayer concepts={concepts} relationships={relationships} />
|
|
896
|
-
) : null}
|
|
897
|
-
{depth >= 5 ? (
|
|
898
|
-
<EmergentKnowledgeGraph
|
|
899
|
-
model={graphModel}
|
|
900
|
-
search={graphSearch}
|
|
901
|
-
selectedId={selectedGraphId}
|
|
902
|
-
onSearch={onGraphSearch}
|
|
903
|
-
onSelect={onSelectGraphNode}
|
|
904
|
-
/>
|
|
905
|
-
) : null}
|
|
906
|
-
</>
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
function BrainOverviewPanel({
|
|
911
|
-
memories,
|
|
912
|
-
concepts,
|
|
913
|
-
onOpenDepth,
|
|
914
|
-
}: {
|
|
915
|
-
memories: MemoryFragment[];
|
|
916
|
-
concepts: KnowledgeConcept[];
|
|
917
|
-
onOpenDepth: (depth: BrainDepth) => void;
|
|
918
|
-
}) {
|
|
919
|
-
const language = useAppStore((state) => state.language);
|
|
920
|
-
const recent = memories.slice(0, 3);
|
|
921
|
-
const older = memories.slice(3, 6);
|
|
922
|
-
const topics = concepts.slice(0, 4);
|
|
923
|
-
|
|
924
|
-
return (
|
|
925
|
-
<section className="brain-overview-panel" aria-label="Brain overview">
|
|
926
|
-
<div className="brain-overview-head">
|
|
927
|
-
<div>
|
|
928
|
-
<span>{t(language, "brain.overview.kicker")}</span>
|
|
929
|
-
<strong>{t(language, "brain.overview.title")}</strong>
|
|
930
|
-
</div>
|
|
931
|
-
<button type="button" onClick={() => onOpenDepth(5)}>{t(language, "brain.overview.graph")}</button>
|
|
932
|
-
</div>
|
|
933
|
-
<div className="brain-overview-grid">
|
|
934
|
-
<BrainOverviewColumn
|
|
935
|
-
title={t(language, "brain.overview.recent")}
|
|
936
|
-
empty={t(language, "brain.overview.recentEmpty")}
|
|
937
|
-
items={recent.map((memory) => memory.title)}
|
|
938
|
-
onOpen={() => onOpenDepth(2)}
|
|
939
|
-
/>
|
|
940
|
-
<BrainOverviewColumn
|
|
941
|
-
title={t(language, "brain.overview.older")}
|
|
942
|
-
empty={t(language, "brain.overview.olderEmpty")}
|
|
943
|
-
items={older.map((memory) => memory.title)}
|
|
944
|
-
onOpen={() => onOpenDepth(2)}
|
|
945
|
-
/>
|
|
946
|
-
<BrainOverviewColumn
|
|
947
|
-
title={t(language, "brain.overview.topics")}
|
|
948
|
-
empty={t(language, "brain.overview.topicsEmpty")}
|
|
949
|
-
items={topics.map((concept) => concept.label)}
|
|
950
|
-
onOpen={() => onOpenDepth(3)}
|
|
951
|
-
/>
|
|
952
|
-
</div>
|
|
953
|
-
</section>
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function BrainOverviewColumn({
|
|
958
|
-
title,
|
|
959
|
-
empty,
|
|
960
|
-
items,
|
|
961
|
-
onOpen,
|
|
962
|
-
}: {
|
|
963
|
-
title: string;
|
|
964
|
-
empty: string;
|
|
965
|
-
items: string[];
|
|
966
|
-
onOpen: () => void;
|
|
967
|
-
}) {
|
|
968
|
-
return (
|
|
969
|
-
<button type="button" className="brain-overview-column" onClick={onOpen}>
|
|
970
|
-
<span>{title}</span>
|
|
971
|
-
{items.length ? (
|
|
972
|
-
items.slice(0, 3).map((item) => <strong key={item}>{item}</strong>)
|
|
973
|
-
) : (
|
|
974
|
-
<em>{empty}</em>
|
|
975
|
-
)}
|
|
976
|
-
</button>
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function MemoryLayer({
|
|
981
|
-
memories,
|
|
982
|
-
depth,
|
|
983
|
-
onRecallMemory,
|
|
984
|
-
}: {
|
|
985
|
-
memories: MemoryFragment[];
|
|
986
|
-
depth: BrainDepth;
|
|
987
|
-
onRecallMemory: (fragment: MemoryFragment) => void;
|
|
988
|
-
}) {
|
|
989
|
-
const visible = memories.slice(0, depth >= 3 ? 8 : 6);
|
|
990
|
-
if (!visible.length) return <div className="memory-fragment is-empty">Memory is quiet</div>;
|
|
991
|
-
|
|
992
|
-
return (
|
|
993
|
-
<>
|
|
994
|
-
{visible.map((memory, index) => {
|
|
995
|
-
const point = polarPoint(index, visible.length, depth >= 3 ? 39 : 31, depth >= 3 ? 24 : 18, -112);
|
|
996
|
-
return (
|
|
997
|
-
<button
|
|
998
|
-
key={memory.id}
|
|
999
|
-
type="button"
|
|
1000
|
-
className="memory-fragment"
|
|
1001
|
-
style={layerStyle({ "--x": `${point.x}%`, "--y": `${point.y}%`, "--delay": `${index * 55}ms` })}
|
|
1002
|
-
onClick={() => onRecallMemory(memory)}
|
|
1003
|
-
>
|
|
1004
|
-
<span>{memory.kind}</span>
|
|
1005
|
-
<strong>{memory.title}</strong>
|
|
1006
|
-
</button>
|
|
1007
|
-
);
|
|
1008
|
-
})}
|
|
1009
|
-
</>
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function KnowledgeLayer({ concepts, depth }: { concepts: KnowledgeConcept[]; depth: BrainDepth }) {
|
|
1014
|
-
const visible = concepts.slice(0, depth >= 4 ? 10 : 7);
|
|
1015
|
-
if (!visible.length) return <div className="concept-signal is-empty">Knowledge is forming</div>;
|
|
1016
|
-
|
|
1017
|
-
return (
|
|
1018
|
-
<>
|
|
1019
|
-
{visible.map((concept, index) => {
|
|
1020
|
-
const point = polarPoint(index, visible.length, 24, 15, -70);
|
|
1021
|
-
return (
|
|
1022
|
-
<button
|
|
1023
|
-
key={concept.id}
|
|
1024
|
-
type="button"
|
|
1025
|
-
className="concept-signal"
|
|
1026
|
-
style={layerStyle({ "--x": `${point.x}%`, "--y": `${point.y}%`, "--delay": `${index * 45}ms` })}
|
|
1027
|
-
title={concept.summary || concept.type}
|
|
1028
|
-
>
|
|
1029
|
-
<span>{concept.type}</span>
|
|
1030
|
-
{concept.label}
|
|
1031
|
-
</button>
|
|
1032
|
-
);
|
|
1033
|
-
})}
|
|
1034
|
-
</>
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function RelationshipLayer({
|
|
1039
|
-
concepts,
|
|
1040
|
-
relationships,
|
|
1041
|
-
}: {
|
|
1042
|
-
concepts: KnowledgeConcept[];
|
|
1043
|
-
relationships: RelationshipThread[];
|
|
1044
|
-
}) {
|
|
1045
|
-
const visibleConcepts = concepts.slice(0, 10);
|
|
1046
|
-
const layout = layoutGraphNodes(visibleConcepts, 30, 20);
|
|
1047
|
-
const positionById = new Map(layout.map((item) => [item.node.id, item]));
|
|
1048
|
-
const visibleRelationships = relationships
|
|
1049
|
-
.map((relationship, index) => {
|
|
1050
|
-
const source = positionById.get(relationship.source) || layout[index % Math.max(layout.length, 1)];
|
|
1051
|
-
const target = positionById.get(relationship.target) || layout[(index + 3) % Math.max(layout.length, 1)];
|
|
1052
|
-
return source && target && source.node.id !== target.node.id ? { relationship, source, target } : null;
|
|
1053
|
-
})
|
|
1054
|
-
.filter(Boolean)
|
|
1055
|
-
.slice(0, 8) as Array<{
|
|
1056
|
-
relationship: RelationshipThread;
|
|
1057
|
-
source: ReturnType<typeof layoutGraphNodes>[number];
|
|
1058
|
-
target: ReturnType<typeof layoutGraphNodes>[number];
|
|
1059
|
-
}>;
|
|
1060
|
-
|
|
1061
|
-
if (!visibleRelationships.length) return null;
|
|
1062
|
-
|
|
1063
|
-
return (
|
|
1064
|
-
<svg className="relationship-weave" viewBox="0 0 100 100" aria-hidden>
|
|
1065
|
-
{visibleRelationships.map(({ relationship, source, target }, index) => (
|
|
1066
|
-
<line
|
|
1067
|
-
key={`${relationship.id}-${index}`}
|
|
1068
|
-
x1={source.x}
|
|
1069
|
-
y1={source.y}
|
|
1070
|
-
x2={target.x}
|
|
1071
|
-
y2={target.y}
|
|
1072
|
-
style={{ animationDelay: `${index * 80}ms` }}
|
|
1073
|
-
/>
|
|
1074
|
-
))}
|
|
1075
|
-
</svg>
|
|
1076
|
-
);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
function EmergentKnowledgeGraph({
|
|
1080
|
-
model,
|
|
1081
|
-
search,
|
|
1082
|
-
selectedId,
|
|
1083
|
-
onSearch,
|
|
1084
|
-
onSelect,
|
|
1085
|
-
}: {
|
|
1086
|
-
model: KnowledgeGraphModel;
|
|
1087
|
-
search: string;
|
|
1088
|
-
selectedId: string | null;
|
|
1089
|
-
onSearch: (value: string) => void;
|
|
1090
|
-
onSelect: (id: string | null) => void;
|
|
1091
|
-
}) {
|
|
1092
|
-
const language = useAppStore((state) => state.language);
|
|
1093
|
-
const query = search.trim().toLowerCase();
|
|
1094
|
-
const visibleNodes = React.useMemo(() => {
|
|
1095
|
-
const filtered = model.nodes.filter((node) => {
|
|
1096
|
-
if (!query) return true;
|
|
1097
|
-
return `${node.label} ${node.type} ${node.summary}`.toLowerCase().includes(query);
|
|
1098
|
-
});
|
|
1099
|
-
return filtered.slice(0, 18);
|
|
1100
|
-
}, [model.nodes, query]);
|
|
1101
|
-
const layout = React.useMemo(() => layoutGraphNodes(visibleNodes, 38, 24), [visibleNodes]);
|
|
1102
|
-
const positionById = React.useMemo(() => new Map(layout.map((item) => [item.node.id, item])), [layout]);
|
|
1103
|
-
const visibleEdges = React.useMemo(
|
|
1104
|
-
() => model.edges.filter((edge) => positionById.has(edge.source) && positionById.has(edge.target)).slice(0, 36),
|
|
1105
|
-
[model.edges, positionById],
|
|
1106
|
-
);
|
|
1107
|
-
const selected = visibleNodes.find((node) => node.id === selectedId) || visibleNodes[0] || null;
|
|
1108
|
-
|
|
1109
|
-
return (
|
|
1110
|
-
<section className="mind-core-graph" data-testid="emergent-knowledge-graph" aria-label="Knowledge Graph">
|
|
1111
|
-
<div className="brain-graph-head">
|
|
1112
|
-
<div>
|
|
1113
|
-
<span>Level 5</span>
|
|
1114
|
-
<strong>Knowledge Graph</strong>
|
|
1115
|
-
</div>
|
|
1116
|
-
<label className="brain-graph-search">
|
|
1117
|
-
<Search className="h-3.5 w-3.5" />
|
|
1118
|
-
<input
|
|
1119
|
-
value={search}
|
|
1120
|
-
onChange={(event) => onSearch(event.target.value)}
|
|
1121
|
-
placeholder="Search"
|
|
1122
|
-
aria-label="Search knowledge graph"
|
|
1123
|
-
/>
|
|
1124
|
-
</label>
|
|
1125
|
-
</div>
|
|
1126
|
-
|
|
1127
|
-
{visibleNodes.length ? (
|
|
1128
|
-
<div className="brain-graph-canvas">
|
|
1129
|
-
<svg className="brain-graph-edges" viewBox="0 0 100 100" aria-hidden>
|
|
1130
|
-
{visibleEdges.map((edge, index) => {
|
|
1131
|
-
const source = positionById.get(edge.source);
|
|
1132
|
-
const target = positionById.get(edge.target);
|
|
1133
|
-
if (!source || !target) return null;
|
|
1134
|
-
return (
|
|
1135
|
-
<line
|
|
1136
|
-
key={`${edge.id}-${index}`}
|
|
1137
|
-
x1={source.x}
|
|
1138
|
-
y1={source.y}
|
|
1139
|
-
x2={target.x}
|
|
1140
|
-
y2={target.y}
|
|
1141
|
-
style={{ "--weight": String(clamp(edge.weight, 0.4, 2.8)) } as React.CSSProperties}
|
|
1142
|
-
/>
|
|
1143
|
-
);
|
|
1144
|
-
})}
|
|
1145
|
-
</svg>
|
|
1146
|
-
{layout.map(({ node, x, y }, index) => (
|
|
1147
|
-
<button
|
|
1148
|
-
key={node.id}
|
|
1149
|
-
type="button"
|
|
1150
|
-
className={`graph-node ${selected?.id === node.id ? "is-selected" : ""}`}
|
|
1151
|
-
style={layerStyle({ "--x": `${x}%`, "--y": `${y}%`, "--delay": `${index * 35}ms` })}
|
|
1152
|
-
onClick={() => onSelect(node.id)}
|
|
1153
|
-
>
|
|
1154
|
-
<span>{node.type}</span>
|
|
1155
|
-
{node.label}
|
|
1156
|
-
</button>
|
|
1157
|
-
))}
|
|
1158
|
-
</div>
|
|
1159
|
-
) : (
|
|
1160
|
-
<div className="brain-graph-empty">{t(language, "brain.graph.empty")}</div>
|
|
1161
|
-
)}
|
|
1162
|
-
|
|
1163
|
-
<div className="brain-graph-focus">
|
|
1164
|
-
{selected ? (
|
|
1165
|
-
<>
|
|
1166
|
-
<span>{selected.type}</span>
|
|
1167
|
-
<strong>{selected.label}</strong>
|
|
1168
|
-
<p>{selected.summary || t(language, "brain.graph.summaryFallback")}</p>
|
|
1169
|
-
<p>{t(language, "brain.graph.focused")}</p>
|
|
1170
|
-
</>
|
|
1171
|
-
) : (
|
|
1172
|
-
<p>{t(language, "brain.graph.emptyFocus")}</p>
|
|
1173
|
-
)}
|
|
1174
|
-
</div>
|
|
1175
|
-
</section>
|
|
1176
|
-
);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function buildMemoryFragments(memoryData: unknown, historyData: unknown): MemoryFragment[] {
|
|
1180
|
-
const memory = isRecord(memoryData) ? memoryData : {};
|
|
1181
|
-
const sourceRows = asArray<ApiRecord>(memory.sources).length
|
|
1182
|
-
? asArray<ApiRecord>(memory.sources)
|
|
1183
|
-
: asArray<ApiRecord>(memory.tiers);
|
|
1184
|
-
const sourceFragments = sourceRows.map((item, index) => ({
|
|
1185
|
-
id: textValue(item, ["id", "source", "label"], `memory-${index}`),
|
|
1186
|
-
title: textValue(item, ["title", "label", "source", "path", "name"], "Workspace memory"),
|
|
1187
|
-
kind: titleValue(item, ["type", "source_type", "kind", "health"], "Memory"),
|
|
1188
|
-
}));
|
|
1189
|
-
const conversationFragments = asArray<ApiRecord>(historyData).map((item, index) => ({
|
|
1190
|
-
id: textValue(item, ["id", "conversation_id"], `conversation-${index}`),
|
|
1191
|
-
title: textValue(item, ["title", "summary", "id"], "Conversation"),
|
|
1192
|
-
kind: "Conversation",
|
|
1193
|
-
}));
|
|
1194
|
-
|
|
1195
|
-
return uniqueById([...sourceFragments, ...conversationFragments]).slice(0, 10);
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function parseKnowledgeGraph(data: unknown): KnowledgeGraphModel {
|
|
1199
|
-
const graph = isRecord(data) ? data : {};
|
|
1200
|
-
const rawNodes = asArray<ApiRecord>(graph.nodes);
|
|
1201
|
-
const rawEdges = asArray<ApiRecord>(graph.edges);
|
|
1202
|
-
const nodes = rawNodes.flatMap((node): KnowledgeConcept[] => {
|
|
1203
|
-
const id = textValue(node, ["id", "node_id", "title", "label"]);
|
|
1204
|
-
if (!id) return [];
|
|
1205
|
-
const metadata = isRecord(node.metadata) ? node.metadata : {};
|
|
1206
|
-
const type = titleValue(node, ["type", "kind", "category"], "Concept");
|
|
1207
|
-
const label = textValue(node, ["title", "label", "name"], id.replace(/^[^:]+:/, ""));
|
|
1208
|
-
const summary = textValue(node, ["summary", "description", "snippet"]) || textValue(metadata, ["summary", "description", "relative_path", "filename"]);
|
|
1209
|
-
const importance = clamp(numberValue(node, ["importance_norm", "importance", "score"]) || 0.5, 0.08, 1);
|
|
1210
|
-
return [{ id, label, type, summary, importance }];
|
|
1211
|
-
}).sort((left, right) => right.importance - left.importance);
|
|
1212
|
-
const ids = new Set(nodes.map((node) => node.id));
|
|
1213
|
-
const edges = rawEdges.flatMap((edge, index): RelationshipThread[] => {
|
|
1214
|
-
const source = textValue(edge, ["from", "source", "source_id"]);
|
|
1215
|
-
const target = textValue(edge, ["to", "target", "target_id"]);
|
|
1216
|
-
if (!source || !target || !ids.has(source) || !ids.has(target)) return [];
|
|
1217
|
-
return [{
|
|
1218
|
-
id: textValue(edge, ["id"], `edge-${index}`),
|
|
1219
|
-
source,
|
|
1220
|
-
target,
|
|
1221
|
-
label: titleValue(edge, ["type", "label", "relationship"], "Relates"),
|
|
1222
|
-
weight: numberValue(edge, ["weight", "score", "confidence"]) || 1,
|
|
1223
|
-
}];
|
|
1224
|
-
});
|
|
1225
|
-
return { nodes, edges };
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
function currentModelName(data: unknown) {
|
|
1229
|
-
const record = isRecord(data) ? data : {};
|
|
1230
|
-
const current = textValue(record, ["current", "current_model", "local_model"]);
|
|
1231
|
-
if (current) return current;
|
|
1232
|
-
const loaded = asArray<ApiRecord>(record.loaded || record.loaded_models);
|
|
1233
|
-
const firstLoaded = loaded.find((item) => item.id || item.name || item.model_id);
|
|
1234
|
-
return firstLoaded ? textValue(firstLoaded, ["name", "id", "model_id"], "local mind") : "local mind";
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function portabilityLabel(data: unknown) {
|
|
1238
|
-
const record = isRecord(data) ? data : {};
|
|
1239
|
-
return textValue(record, ["archive_format", "format", "graph_schema_version", "schema_version"], ".latticebrain");
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
function backupHealthLabel(data: unknown) {
|
|
1243
|
-
const record = isRecord(data) ? data : {};
|
|
1244
|
-
const count = record.count || record.backups || record.available;
|
|
1245
|
-
if (count !== undefined && count !== null && count !== "") return `${count} backups`;
|
|
1246
|
-
return "Backups ready";
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
function summarizeCareResult(result: ApiResult) {
|
|
1250
|
-
if (!result.ok) return result.error || "Brain care action could not complete.";
|
|
1251
|
-
const data = isRecord(result.data) ? result.data : {};
|
|
1252
|
-
const directMessage = textValue(data, ["message", "status", "path", "archive_path", "backup_path", "export_path"]);
|
|
1253
|
-
if (directMessage) return directMessage;
|
|
1254
|
-
return "Brain care action completed.";
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function renderLogRow(event: ApiRecord) {
|
|
1258
|
-
const action = stringValue(event.action || event.event || event.type || event.name, "Event");
|
|
1259
|
-
const actor = stringValue(event.actor || event.user || event.user_id || event.workspace_id, "system");
|
|
1260
|
-
const when = stringValue(event.timestamp || event.time || event.created_at || event.ts, "recently");
|
|
1261
|
-
return (
|
|
1262
|
-
<>
|
|
1263
|
-
<strong>{action}</strong>
|
|
1264
|
-
<span>{actor} · {when}</span>
|
|
1265
|
-
</>
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
function sourceLabel(result?: ApiResult<unknown>) {
|
|
1270
|
-
if (!result) return "Loading";
|
|
1271
|
-
return result.ok ? "Live" : result.error || "Unavailable";
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
function adminStatusLabel(data: unknown, key: string) {
|
|
1275
|
-
const record = isRecord(data) ? data : {};
|
|
1276
|
-
return textValue(record, [key, "health", "state", "overall_status"]);
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
function indexDetail(data: unknown) {
|
|
1280
|
-
const record = isRecord(data) ? data : {};
|
|
1281
|
-
const docs = record.documents ?? record.document_count ?? record.docs;
|
|
1282
|
-
const chunks = record.chunks ?? record.chunk_count ?? record.vectors;
|
|
1283
|
-
if (docs !== undefined || chunks !== undefined) {
|
|
1284
|
-
return `${stringValue(docs, "0")} docs · ${stringValue(chunks, "0")} chunks`;
|
|
1285
|
-
}
|
|
1286
|
-
return textValue(record, ["message", "detail", "status"], "Index status ready");
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function summaryText(data: unknown) {
|
|
1290
|
-
const record = isRecord(data) ? data : {};
|
|
1291
|
-
return textValue(record, ["summary", "message", "status", "detail"]);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
function stringValue(value: unknown, fallback = "") {
|
|
1295
|
-
if (typeof value === "string" && value.trim()) return value;
|
|
1296
|
-
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1297
|
-
if (typeof value === "boolean") return value ? "true" : "false";
|
|
1298
|
-
return fallback;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function fileToDataUrl(file: File) {
|
|
1302
|
-
return new Promise<string>((resolve, reject) => {
|
|
1303
|
-
const reader = new FileReader();
|
|
1304
|
-
reader.onload = () => resolve(String(reader.result || ""));
|
|
1305
|
-
reader.onerror = () => reject(reader.error);
|
|
1306
|
-
reader.readAsDataURL(file);
|
|
1307
|
-
});
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function layoutGraphNodes(nodes: KnowledgeConcept[], radiusX: number, radiusY: number) {
|
|
1311
|
-
return nodes.map((node, index) => {
|
|
1312
|
-
const point = polarPoint(index, nodes.length, radiusX, radiusY, -88);
|
|
1313
|
-
return { node, x: point.x, y: point.y };
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
function polarPoint(index: number, total: number, radiusX: number, radiusY: number, offsetDegrees = -90) {
|
|
1318
|
-
const count = Math.max(total, 1);
|
|
1319
|
-
const angle = ((360 / count) * index + offsetDegrees) * Math.PI / 180;
|
|
1320
|
-
return {
|
|
1321
|
-
x: 50 + Math.cos(angle) * radiusX,
|
|
1322
|
-
y: 50 + Math.sin(angle) * radiusY,
|
|
1323
|
-
};
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
function layerStyle(values: Record<string, string>) {
|
|
1327
|
-
return values as React.CSSProperties;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function uniqueById<T extends { id: string }>(items: T[]) {
|
|
1331
|
-
const seen = new Set<string>();
|
|
1332
|
-
return items.filter((item) => {
|
|
1333
|
-
if (seen.has(item.id)) return false;
|
|
1334
|
-
seen.add(item.id);
|
|
1335
|
-
return true;
|
|
1336
|
-
});
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
function isRecord(value: unknown): value is ApiRecord {
|
|
1340
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
function textValue(record: ApiRecord, keys: string[], fallback = "") {
|
|
1344
|
-
for (const key of keys) {
|
|
1345
|
-
const value = record[key];
|
|
1346
|
-
if (typeof value === "string" && value.trim()) return value;
|
|
1347
|
-
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1348
|
-
}
|
|
1349
|
-
return fallback;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
function titleValue(record: ApiRecord, keys: string[], fallback = "") {
|
|
1353
|
-
const value = textValue(record, keys, fallback);
|
|
1354
|
-
return value
|
|
1355
|
-
.replace(/[_-]+/g, " ")
|
|
1356
|
-
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
function numberValue(record: ApiRecord, keys: string[]) {
|
|
1360
|
-
for (const key of keys) {
|
|
1361
|
-
const value = Number(record[key]);
|
|
1362
|
-
if (Number.isFinite(value)) return value;
|
|
1363
|
-
}
|
|
1364
|
-
return 0;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
84
|
function clamp(value: number, min: number, max: number) {
|
|
1368
85
|
return Math.max(min, Math.min(max, value));
|
|
1369
86
|
}
|