ltcai 6.7.0 → 7.1.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 +29 -31
- package/docs/CHANGELOG.md +48 -0
- package/frontend/src/App.tsx +80 -0
- package/frontend/src/api/client.ts +11 -0
- package/frontend/src/components/AdminAccessGate.tsx +70 -0
- package/frontend/src/components/BrainConversation.tsx +232 -8
- package/frontend/src/components/FeedbackState.tsx +45 -0
- package/frontend/src/components/WorkspaceProfileSwitcher.tsx +176 -0
- package/frontend/src/components/onboarding/AnalysisScreen.tsx +20 -12
- package/frontend/src/components/onboarding/InstallScreen.tsx +55 -0
- package/frontend/src/components/onboarding/RecommendationScreen.tsx +55 -5
- package/frontend/src/components/onboarding/recommendationModel.ts +59 -2
- package/frontend/src/features/brain/BrainConversation.tsx +382 -7
- package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
- package/frontend/src/features/brain/BrainHome.tsx +300 -1
- package/frontend/src/features/brain/brainData.ts +25 -1
- package/frontend/src/features/brain/graphLayout.ts +12 -1
- package/frontend/src/features/brain/types.ts +54 -0
- package/frontend/src/i18n.ts +374 -0
- package/frontend/src/store/appStore.ts +15 -0
- package/frontend/src/styles.css +1383 -41
- 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/workspace.py +59 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +1 -1
- package/scripts/brain_quality_eval.py +118 -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 +28 -28
- package/static/app/assets/Act-BHEb8bAM.js +2 -0
- package/static/app/assets/{Act-DGN37eR4.js.map → Act-BHEb8bAM.js.map} +1 -1
- package/static/app/assets/Brain-yvR7xkrV.js +322 -0
- package/static/app/assets/Brain-yvR7xkrV.js.map +1 -0
- package/static/app/assets/Capture-BrAmsSEH.js +2 -0
- package/static/app/assets/Capture-BrAmsSEH.js.map +1 -0
- package/static/app/assets/Library-BOVzYfxI.js +2 -0
- package/static/app/assets/{Library-BbagRFyd.js.map → Library-BOVzYfxI.js.map} +1 -1
- package/static/app/assets/System-D4WaN4kj.js +2 -0
- package/static/app/assets/System-D4WaN4kj.js.map +1 -0
- package/static/app/assets/index-BOB-W1FZ.js +17 -0
- package/static/app/assets/index-BOB-W1FZ.js.map +1 -0
- package/static/app/assets/index-DvLsGy-z.css +2 -0
- package/static/app/assets/primitives-C3_BfUb8.js +2 -0
- package/static/app/assets/primitives-C3_BfUb8.js.map +1 -0
- package/static/app/assets/textarea-CbvhHvzg.js +2 -0
- package/static/app/assets/{textarea-C2PLu6_O.js.map → textarea-CbvhHvzg.js.map} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/Act-DGN37eR4.js +0 -2
- package/static/app/assets/Brain-HDcIwPHW.js +0 -322
- package/static/app/assets/Brain-HDcIwPHW.js.map +0 -1
- package/static/app/assets/Capture--eZfb_Ex.js +0 -2
- package/static/app/assets/Capture--eZfb_Ex.js.map +0 -1
- package/static/app/assets/Library-BbagRFyd.js +0 -2
- package/static/app/assets/System-C6pd3Mp_.js +0 -2
- package/static/app/assets/System-C6pd3Mp_.js.map +0 -1
- package/static/app/assets/index-C950aES_.js +0 -17
- package/static/app/assets/index-C950aES_.js.map +0 -1
- package/static/app/assets/index-ztppMg0c.css +0 -2
- package/static/app/assets/primitives-Bcm5lZRu.js +0 -2
- package/static/app/assets/primitives-Bcm5lZRu.js.map +0 -1
- package/static/app/assets/textarea-C2PLu6_O.js +0 -2
|
@@ -3,13 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
3
3
|
import { ImagePlus, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
|
|
4
4
|
import { latticeApi } from "@/api/client";
|
|
5
5
|
import { EmptyState, EntityList, SourceBadge, StructuredView } from "@/components/primitives";
|
|
6
|
+
import { FeedbackState } from "@/components/FeedbackState";
|
|
6
7
|
import { type BrainState, LivingBrain } from "@/components/LivingBrain";
|
|
7
8
|
import { Badge } from "@/components/ui/badge";
|
|
8
9
|
import { Button } from "@/components/ui/button";
|
|
9
10
|
import { Textarea } from "@/components/ui/textarea";
|
|
10
11
|
import { asArray } from "@/lib/utils";
|
|
12
|
+
import { t, type Language } from "@/i18n";
|
|
13
|
+
import { useAppStore } from "@/store/appStore";
|
|
11
14
|
|
|
12
|
-
type
|
|
15
|
+
type Citation = { id: string; source: string; title: string; snippet: string; score: number | null };
|
|
16
|
+
type Msg = { role?: string; content?: string; timestamp?: string; citations?: Citation[] };
|
|
13
17
|
type BrainActivity = BrainState;
|
|
14
18
|
type BrainVitals = {
|
|
15
19
|
connected: boolean;
|
|
@@ -49,7 +53,58 @@ function usageNumber(data: unknown, key: string) {
|
|
|
49
53
|
return Number.isFinite(value) ? value : null;
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
// Pull human-readable citation records out of the heterogeneous streaming trace.
|
|
57
|
+
// The backend trace shape varies, so we defensively scan the common containers
|
|
58
|
+
// (matches / sources / retrieved / context / chunks) for source-like records.
|
|
59
|
+
function extractCitations(trace: unknown): Citation[] {
|
|
60
|
+
if (!trace || typeof trace !== "object") return [];
|
|
61
|
+
const record = trace as Record<string, unknown>;
|
|
62
|
+
const containerKeys = ["matches", "sources", "retrieved", "context", "chunks", "citations", "evidence"];
|
|
63
|
+
let rows: Array<Record<string, unknown>> = [];
|
|
64
|
+
for (const key of containerKeys) {
|
|
65
|
+
const candidate = asArray<Record<string, unknown>>(record[key]);
|
|
66
|
+
if (candidate.length) {
|
|
67
|
+
rows = candidate;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!rows.length && Array.isArray(trace)) rows = trace as Array<Record<string, unknown>>;
|
|
72
|
+
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
const citations: Citation[] = [];
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
if (!row || typeof row !== "object") continue;
|
|
77
|
+
const title = String(row.title || row.name || row.heading || row.label || row.id || "").trim();
|
|
78
|
+
const source = String(row.source || row.type || row.origin || row.kind || row.provider || "memory").trim();
|
|
79
|
+
const snippet = String(row.snippet || row.text || row.content || row.summary || row.excerpt || "").trim();
|
|
80
|
+
if (!title && !snippet) continue;
|
|
81
|
+
const scoreRaw = Number(row.score ?? row.relevance ?? row.similarity);
|
|
82
|
+
const score = Number.isFinite(scoreRaw) ? Math.round((scoreRaw <= 1 ? scoreRaw * 100 : scoreRaw)) : null;
|
|
83
|
+
const id = String(row.id || `${source}:${title}:${snippet.slice(0, 24)}`);
|
|
84
|
+
if (seen.has(id)) continue;
|
|
85
|
+
seen.add(id);
|
|
86
|
+
citations.push({ id, source: source || "memory", title: title || snippet.slice(0, 48), snippet, score });
|
|
87
|
+
if (citations.length >= 6) break;
|
|
88
|
+
}
|
|
89
|
+
return citations;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Anchor citation chips after the first N sentences (conservative heuristic).
|
|
93
|
+
// Each segment carries an optional citation index so chips stay tied to text.
|
|
94
|
+
function splitWithCitations(content: string, citationCount: number): Array<{ text: string; citation: number | null }> {
|
|
95
|
+
if (!citationCount || !content) return [{ text: content, citation: null }];
|
|
96
|
+
const sentences = content.match(/[^.!?。!?\n]+[.!?。!?]?\s*/g);
|
|
97
|
+
if (!sentences || sentences.length <= 1) return [{ text: content, citation: 0 }];
|
|
98
|
+
const segments: Array<{ text: string; citation: number | null }> = [];
|
|
99
|
+
sentences.forEach((sentence, index) => {
|
|
100
|
+
const citation = index < citationCount ? index : null;
|
|
101
|
+
segments.push({ text: sentence, citation });
|
|
102
|
+
});
|
|
103
|
+
return segments;
|
|
104
|
+
}
|
|
105
|
+
|
|
52
106
|
export function BrainConversation({ className }: { className?: string }) {
|
|
107
|
+
const language = useAppStore((state) => state.language);
|
|
53
108
|
const qc = useQueryClient();
|
|
54
109
|
const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
|
|
55
110
|
const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
|
|
@@ -94,7 +149,20 @@ export function BrainConversation({ className }: { className?: string }) {
|
|
|
94
149
|
return next;
|
|
95
150
|
});
|
|
96
151
|
},
|
|
97
|
-
onTrace:
|
|
152
|
+
onTrace: (incoming) => {
|
|
153
|
+
setTrace(incoming);
|
|
154
|
+
const citations = extractCitations(incoming);
|
|
155
|
+
if (citations.length) {
|
|
156
|
+
setMessages((items) => {
|
|
157
|
+
const next = [...items];
|
|
158
|
+
const last = next[next.length - 1];
|
|
159
|
+
if (last && last.role === "assistant") {
|
|
160
|
+
next[next.length - 1] = { ...last, citations };
|
|
161
|
+
}
|
|
162
|
+
return next;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
},
|
|
98
166
|
},
|
|
99
167
|
);
|
|
100
168
|
if (result.error) {
|
|
@@ -167,7 +235,15 @@ export function BrainConversation({ className }: { className?: string }) {
|
|
|
167
235
|
<div key={`${msg.role || "message"}-${index}`} className={`brain-message-row ${msg.role === "user" ? "from-user" : "from-brain"}`}>
|
|
168
236
|
<div className="brain-message-bubble">
|
|
169
237
|
<div className="brain-message-role">{msg.role === "user" ? "You" : "Brain"}</div>
|
|
170
|
-
|
|
238
|
+
{msg.role === "user" ? (
|
|
239
|
+
<div className="whitespace-pre-wrap">{msg.content}</div>
|
|
240
|
+
) : (
|
|
241
|
+
<MessageWithCitations
|
|
242
|
+
language={language}
|
|
243
|
+
content={msg.content || ""}
|
|
244
|
+
citations={msg.citations || []}
|
|
245
|
+
/>
|
|
246
|
+
)}
|
|
171
247
|
</div>
|
|
172
248
|
</div>
|
|
173
249
|
)) : (
|
|
@@ -239,6 +315,122 @@ export function BrainConversation({ className }: { className?: string }) {
|
|
|
239
315
|
);
|
|
240
316
|
}
|
|
241
317
|
|
|
318
|
+
// Renders assistant text with inline, keyboard-accessible citation chips that are
|
|
319
|
+
// linked to a proof list below. Selecting a chip highlights and reveals its source.
|
|
320
|
+
function MessageWithCitations({
|
|
321
|
+
language,
|
|
322
|
+
content,
|
|
323
|
+
citations,
|
|
324
|
+
}: {
|
|
325
|
+
language: Language;
|
|
326
|
+
content: string;
|
|
327
|
+
citations: Citation[];
|
|
328
|
+
}) {
|
|
329
|
+
const [activeId, setActiveId] = React.useState<string | null>(null);
|
|
330
|
+
const [announce, setAnnounce] = React.useState("");
|
|
331
|
+
const listRef = React.useRef<HTMLOListElement | null>(null);
|
|
332
|
+
|
|
333
|
+
if (!citations.length) {
|
|
334
|
+
return <div className="whitespace-pre-wrap">{content}</div>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const segments = splitWithCitations(content, citations.length);
|
|
338
|
+
|
|
339
|
+
const focusListItem = (id: string) => {
|
|
340
|
+
const node = listRef.current?.querySelector<HTMLLIElement>(`[data-citation-target="${id}"]`);
|
|
341
|
+
node?.focus?.();
|
|
342
|
+
node?.scrollIntoView?.({ block: "nearest" });
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const toggle = (citation: Citation) => {
|
|
346
|
+
setActiveId((prev) => {
|
|
347
|
+
const next = prev === citation.id ? null : citation.id;
|
|
348
|
+
if (next) {
|
|
349
|
+
const idx = citations.findIndex((c) => c.id === citation.id) + 1;
|
|
350
|
+
setAnnounce(t(language, "brain.citation.announce.open", { num: idx, title: citation.title }));
|
|
351
|
+
focusListItem(citation.id);
|
|
352
|
+
} else {
|
|
353
|
+
setAnnounce(t(language, "brain.citation.announce.close"));
|
|
354
|
+
}
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const onChipKey = (event: React.KeyboardEvent<HTMLSpanElement>, citation: Citation) => {
|
|
360
|
+
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
toggle(citation);
|
|
363
|
+
} else if (event.key === "Escape" && activeId) {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
setActiveId(null);
|
|
366
|
+
setAnnounce(t(language, "brain.citation.announce.close"));
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<div className="brain-message-cited">
|
|
372
|
+
<div className="whitespace-pre-wrap brain-message-text">
|
|
373
|
+
{segments.map((segment, index) => {
|
|
374
|
+
const citation = segment.citation != null ? citations[segment.citation] : null;
|
|
375
|
+
const num = segment.citation != null ? segment.citation + 1 : 0;
|
|
376
|
+
return (
|
|
377
|
+
<React.Fragment key={index}>
|
|
378
|
+
{segment.text}
|
|
379
|
+
{citation ? (
|
|
380
|
+
<span
|
|
381
|
+
role="button"
|
|
382
|
+
tabIndex={0}
|
|
383
|
+
className={`citation-chip ${activeId === citation.id ? "is-active" : ""}`}
|
|
384
|
+
data-citation-id={citation.id}
|
|
385
|
+
aria-pressed={activeId === citation.id}
|
|
386
|
+
aria-label={t(language, "brain.citation.chip.long", { num, title: citation.title })}
|
|
387
|
+
onClick={() => toggle(citation)}
|
|
388
|
+
onKeyDown={(event) => onChipKey(event, citation)}
|
|
389
|
+
>
|
|
390
|
+
{t(language, "brain.citation.chip", { num })}
|
|
391
|
+
</span>
|
|
392
|
+
) : null}
|
|
393
|
+
</React.Fragment>
|
|
394
|
+
);
|
|
395
|
+
})}
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div className="brain-answer-proof" aria-label={t(language, "brain.citation.region")}>
|
|
399
|
+
<div className="brain-answer-proof-head">
|
|
400
|
+
<span>{t(language, "brain.answerProof.title")}</span>
|
|
401
|
+
<strong>{t(language, "brain.citation.count", { count: citations.length })}</strong>
|
|
402
|
+
</div>
|
|
403
|
+
<ol ref={listRef}>
|
|
404
|
+
{citations.map((citation, index) => {
|
|
405
|
+
const isActive = activeId === citation.id;
|
|
406
|
+
return (
|
|
407
|
+
<li
|
|
408
|
+
key={citation.id}
|
|
409
|
+
data-citation-target={citation.id}
|
|
410
|
+
className={isActive ? "is-cited" : ""}
|
|
411
|
+
tabIndex={-1}
|
|
412
|
+
aria-current={isActive ? "true" : undefined}
|
|
413
|
+
>
|
|
414
|
+
<span>{t(language, "brain.citation.preview.from")}{citation.source}</span>
|
|
415
|
+
<strong>{t(language, "brain.citation.chip", { num: index + 1 })} · {citation.title}</strong>
|
|
416
|
+
{citation.snippet ? (
|
|
417
|
+
<small>{t(language, "brain.citation.preview.snippet")}{citation.snippet}</small>
|
|
418
|
+
) : null}
|
|
419
|
+
{citation.score != null ? (
|
|
420
|
+
<small className="brain-citation-score">{t(language, "brain.citation.score", { score: citation.score })}</small>
|
|
421
|
+
) : null}
|
|
422
|
+
</li>
|
|
423
|
+
);
|
|
424
|
+
})}
|
|
425
|
+
</ol>
|
|
426
|
+
<small className="brain-citation-tip">{t(language, "brain.citation.keyboard.tip")}</small>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div className="sr-only" role="status" aria-live="polite">{announce}</div>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
242
434
|
function RecentConversations({
|
|
243
435
|
conversations,
|
|
244
436
|
result,
|
|
@@ -248,23 +440,32 @@ function RecentConversations({
|
|
|
248
440
|
onDelete,
|
|
249
441
|
}: {
|
|
250
442
|
conversations: Array<Record<string, unknown>>;
|
|
251
|
-
result?:
|
|
443
|
+
result?: { source?: string; ok?: boolean; status?: number; error?: string };
|
|
252
444
|
activeId: string | null;
|
|
253
445
|
onNew: () => void;
|
|
254
446
|
onSelect: (id: string) => void;
|
|
255
447
|
onDelete: (id: string) => void;
|
|
256
448
|
}) {
|
|
449
|
+
const language = useAppStore((state) => state.language);
|
|
257
450
|
return (
|
|
258
451
|
<section className="brain-side-panel">
|
|
259
452
|
<div className="brain-side-head">
|
|
260
453
|
<div>
|
|
261
454
|
<h3>Recent conversations</h3>
|
|
262
|
-
<SourceBadge result={result} />
|
|
455
|
+
<SourceBadge result={result as Parameters<typeof SourceBadge>[0]["result"]} />
|
|
263
456
|
</div>
|
|
264
457
|
<Button variant="outline" size="sm" onClick={onNew}><Plus className="h-4 w-4" /> New</Button>
|
|
265
458
|
</div>
|
|
266
459
|
<div className="brain-conversation-list soft-scrollbar">
|
|
267
|
-
{
|
|
460
|
+
{!result?.ok && result?.source === "unavailable" ? (
|
|
461
|
+
<FeedbackState
|
|
462
|
+
tone="error"
|
|
463
|
+
language={language}
|
|
464
|
+
title={t(language, "feedback.error.title")}
|
|
465
|
+
body={result.error || t(language, "feedback.error.body")}
|
|
466
|
+
onAction={() => window.location.reload()}
|
|
467
|
+
/>
|
|
468
|
+
) : conversations.length ? conversations.slice(0, 8).map((item) => {
|
|
268
469
|
const id = String(item.id || item.conversation_id || "");
|
|
269
470
|
return (
|
|
270
471
|
<div key={id} className={`brain-conversation-item ${activeId === id ? "is-active" : ""}`}>
|
|
@@ -277,13 +478,23 @@ function RecentConversations({
|
|
|
277
478
|
</button>
|
|
278
479
|
</div>
|
|
279
480
|
);
|
|
280
|
-
}) :
|
|
481
|
+
}) : (
|
|
482
|
+
<FeedbackState
|
|
483
|
+
tone="empty"
|
|
484
|
+
language={language}
|
|
485
|
+
title="No conversations yet"
|
|
486
|
+
body="New exchanges will appear here."
|
|
487
|
+
actionLabel="Start a conversation"
|
|
488
|
+
onAction={onNew}
|
|
489
|
+
/>
|
|
490
|
+
)}
|
|
281
491
|
</div>
|
|
282
492
|
</section>
|
|
283
493
|
);
|
|
284
494
|
}
|
|
285
495
|
|
|
286
496
|
function MemoryNearby({ question, trace }: { question: string; trace: unknown }) {
|
|
497
|
+
const language = useAppStore((state) => state.language);
|
|
287
498
|
const hybrid = useQuery({
|
|
288
499
|
queryKey: ["brainNearbyMemory", question],
|
|
289
500
|
queryFn: () => latticeApi.hybridSearch(question),
|
|
@@ -299,10 +510,23 @@ function MemoryNearby({ question, trace }: { question: string; trace: unknown })
|
|
|
299
510
|
</div>
|
|
300
511
|
{hybrid.data?.ok ? (
|
|
301
512
|
<EntityList items={(hybrid.data.data as Record<string, unknown>).matches || hybrid.data.data} titleKey="title" metaKey="type" limit={4} />
|
|
513
|
+
) : hybrid.data?.source === "unavailable" ? (
|
|
514
|
+
<FeedbackState
|
|
515
|
+
tone="error"
|
|
516
|
+
language={language}
|
|
517
|
+
title={t(language, "feedback.error.title")}
|
|
518
|
+
body={hybrid.data.error || t(language, "feedback.error.body")}
|
|
519
|
+
onAction={() => void hybrid.refetch()}
|
|
520
|
+
/>
|
|
302
521
|
) : trace ? (
|
|
303
522
|
<StructuredView value={trace} limit={4} />
|
|
304
523
|
) : (
|
|
305
|
-
<
|
|
524
|
+
<FeedbackState
|
|
525
|
+
tone="empty"
|
|
526
|
+
language={language}
|
|
527
|
+
title="Quiet for now"
|
|
528
|
+
body="Relevant memory wakes up as the conversation gets specific."
|
|
529
|
+
/>
|
|
306
530
|
)}
|
|
307
531
|
</section>
|
|
308
532
|
);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { AlertTriangle, Inbox, RotateCcw } from "lucide-react";
|
|
3
|
+
import { t, type Language } from "@/i18n";
|
|
4
|
+
|
|
5
|
+
export type FeedbackTone = "empty" | "error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared empty / error feedback surface so every view tells the user what just
|
|
9
|
+
* happened and what to do next, instead of a blank or silent failure.
|
|
10
|
+
*/
|
|
11
|
+
export function FeedbackState({
|
|
12
|
+
tone,
|
|
13
|
+
language,
|
|
14
|
+
title,
|
|
15
|
+
body,
|
|
16
|
+
actionLabel,
|
|
17
|
+
onAction,
|
|
18
|
+
}: {
|
|
19
|
+
tone: FeedbackTone;
|
|
20
|
+
language: Language;
|
|
21
|
+
title: string;
|
|
22
|
+
body?: string;
|
|
23
|
+
actionLabel?: string;
|
|
24
|
+
onAction?: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
const isError = tone === "error";
|
|
27
|
+
const resolvedActionLabel = actionLabel || (isError ? t(language, "feedback.retry") : undefined);
|
|
28
|
+
return (
|
|
29
|
+
<div className={`feedback-state is-${tone}`} role={isError ? "alert" : "status"}>
|
|
30
|
+
<span className="feedback-state-icon" aria-hidden="true">
|
|
31
|
+
{isError ? <AlertTriangle className="h-4 w-4" /> : <Inbox className="h-4 w-4" />}
|
|
32
|
+
</span>
|
|
33
|
+
<div className="feedback-state-body">
|
|
34
|
+
<strong>{title}</strong>
|
|
35
|
+
{body ? <span>{body}</span> : null}
|
|
36
|
+
</div>
|
|
37
|
+
{resolvedActionLabel && onAction ? (
|
|
38
|
+
<button type="button" className="feedback-state-action" onClick={onAction}>
|
|
39
|
+
{isError ? <RotateCcw className="h-3.5 w-3.5" /> : null}
|
|
40
|
+
{resolvedActionLabel}
|
|
41
|
+
</button>
|
|
42
|
+
) : null}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Check, ChevronDown, UserCircle, Building2, ArrowRightLeft } from "lucide-react";
|
|
4
|
+
import { latticeApi } from "@/api/client";
|
|
5
|
+
import { t, type Language } from "@/i18n";
|
|
6
|
+
import { useAppStore } from "@/store/appStore";
|
|
7
|
+
import { asArray } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
type WorkspaceRow = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
function workspaceId(row: WorkspaceRow): string {
|
|
12
|
+
return String(row.workspace_id || row.id || "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function workspaceName(language: Language, row: WorkspaceRow): string {
|
|
16
|
+
const name = row.name;
|
|
17
|
+
if (typeof name === "string" && name.trim()) return name;
|
|
18
|
+
const id = workspaceId(row);
|
|
19
|
+
return id || t(language, "shell.workspace.personal");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ownerEmail(profile: unknown): string | null {
|
|
23
|
+
if (!profile || typeof profile !== "object") return null;
|
|
24
|
+
const record = profile as Record<string, unknown>;
|
|
25
|
+
const candidate = record.email || record.owner_email || record.username || record.name;
|
|
26
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discoverable workspace + profile switcher for the Brain shell.
|
|
31
|
+
* Surfaces the active workspace and signed-in owner, and lets the user
|
|
32
|
+
* switch workspace or jump to account/workspace settings without hunting
|
|
33
|
+
* through the Settings tabs.
|
|
34
|
+
*/
|
|
35
|
+
export function WorkspaceProfileSwitcher({ language }: { language: Language }) {
|
|
36
|
+
const qc = useQueryClient();
|
|
37
|
+
const workspaceIdState = useAppStore((state) => state.workspaceId);
|
|
38
|
+
const setWorkspaceId = useAppStore((state) => state.setWorkspaceId);
|
|
39
|
+
const [open, setOpen] = React.useState(false);
|
|
40
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
41
|
+
|
|
42
|
+
const profile = useQuery({ queryKey: ["profile"], queryFn: latticeApi.profile });
|
|
43
|
+
const registry = useQuery({ queryKey: ["workspaceRegistry"], queryFn: latticeApi.workspaceRegistry });
|
|
44
|
+
|
|
45
|
+
const workspaces = asArray<WorkspaceRow>(
|
|
46
|
+
(registry.data?.data as Record<string, unknown> | undefined)?.workspaces,
|
|
47
|
+
);
|
|
48
|
+
const owner = ownerEmail(profile.data?.data);
|
|
49
|
+
|
|
50
|
+
const activeWorkspace = workspaces.find((row) => workspaceId(row) === workspaceIdState);
|
|
51
|
+
const activeLabel = activeWorkspace
|
|
52
|
+
? workspaceName(language, activeWorkspace)
|
|
53
|
+
: workspaceIdState || t(language, "shell.workspace.personal");
|
|
54
|
+
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
if (!open) return;
|
|
57
|
+
const onPointer = (event: MouseEvent) => {
|
|
58
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
59
|
+
setOpen(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const onKey = (event: KeyboardEvent) => {
|
|
63
|
+
if (event.key === "Escape") setOpen(false);
|
|
64
|
+
};
|
|
65
|
+
window.addEventListener("mousedown", onPointer);
|
|
66
|
+
window.addEventListener("keydown", onKey);
|
|
67
|
+
return () => {
|
|
68
|
+
window.removeEventListener("mousedown", onPointer);
|
|
69
|
+
window.removeEventListener("keydown", onKey);
|
|
70
|
+
};
|
|
71
|
+
}, [open]);
|
|
72
|
+
|
|
73
|
+
const handleSwitch = (id: string) => {
|
|
74
|
+
if (id && id !== workspaceIdState) {
|
|
75
|
+
setWorkspaceId(id);
|
|
76
|
+
// Workspace scoping changes server responses; drop cached views.
|
|
77
|
+
qc.invalidateQueries();
|
|
78
|
+
}
|
|
79
|
+
setOpen(false);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="workspace-profile-switcher" ref={containerRef}>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className="workspace-profile-trigger"
|
|
87
|
+
aria-haspopup="dialog"
|
|
88
|
+
aria-expanded={open}
|
|
89
|
+
aria-label={t(language, "shell.workspace.label")}
|
|
90
|
+
onClick={() => setOpen((value) => !value)}
|
|
91
|
+
>
|
|
92
|
+
<Building2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
93
|
+
<span className="workspace-profile-trigger-text">
|
|
94
|
+
<span className="workspace-profile-trigger-name">{activeLabel}</span>
|
|
95
|
+
<span className="workspace-profile-trigger-owner">{owner || t(language, "shell.profile.signedOut")}</span>
|
|
96
|
+
</span>
|
|
97
|
+
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
{open ? (
|
|
101
|
+
<div className="workspace-profile-popover" role="dialog" aria-label={t(language, "shell.workspace.label")}>
|
|
102
|
+
<section className="workspace-profile-section" aria-label={t(language, "shell.profile.label")}>
|
|
103
|
+
<div className="workspace-profile-section-head">
|
|
104
|
+
<UserCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
|
105
|
+
<span>{t(language, "shell.profile.label")}</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="workspace-profile-owner-row">
|
|
108
|
+
<span className="workspace-profile-owner-label">{t(language, "shell.profile.owner")}</span>
|
|
109
|
+
<span className="workspace-profile-owner-value">{owner || t(language, "shell.profile.signedOut")}</span>
|
|
110
|
+
</div>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
className="workspace-profile-link"
|
|
114
|
+
onClick={() => {
|
|
115
|
+
setOpen(false);
|
|
116
|
+
window.location.hash = "/account";
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{t(language, "shell.profile.manage")}
|
|
120
|
+
</button>
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section className="workspace-profile-section" aria-label={t(language, "shell.workspace.current")}>
|
|
124
|
+
<div className="workspace-profile-section-head">
|
|
125
|
+
<Building2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
126
|
+
<span>{t(language, "shell.workspace.current")}</span>
|
|
127
|
+
</div>
|
|
128
|
+
{workspaces.length === 0 ? (
|
|
129
|
+
<p className="workspace-profile-empty">{t(language, "shell.workspace.empty")}</p>
|
|
130
|
+
) : (
|
|
131
|
+
<ul className="workspace-profile-list">
|
|
132
|
+
{workspaces.map((row) => {
|
|
133
|
+
const id = workspaceId(row);
|
|
134
|
+
const isActive = id === workspaceIdState;
|
|
135
|
+
return (
|
|
136
|
+
<li key={id}>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className={`workspace-profile-item ${isActive ? "is-active" : ""}`}
|
|
140
|
+
aria-current={isActive ? "true" : undefined}
|
|
141
|
+
onClick={() => handleSwitch(id)}
|
|
142
|
+
>
|
|
143
|
+
<span className="workspace-profile-item-name">{workspaceName(language, row)}</span>
|
|
144
|
+
{isActive ? (
|
|
145
|
+
<span className="workspace-profile-item-flag">
|
|
146
|
+
<Check className="h-3 w-3" aria-hidden="true" />
|
|
147
|
+
{t(language, "shell.workspace.active")}
|
|
148
|
+
</span>
|
|
149
|
+
) : (
|
|
150
|
+
<span className="workspace-profile-item-switch">
|
|
151
|
+
<ArrowRightLeft className="h-3 w-3" aria-hidden="true" />
|
|
152
|
+
{t(language, "shell.workspace.switch")}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
</button>
|
|
156
|
+
</li>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</ul>
|
|
160
|
+
)}
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
className="workspace-profile-link"
|
|
164
|
+
onClick={() => {
|
|
165
|
+
setOpen(false);
|
|
166
|
+
window.location.hash = "/workspace-admin";
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{t(language, "shell.workspace.manageSpaces")}
|
|
170
|
+
</button>
|
|
171
|
+
</section>
|
|
172
|
+
</div>
|
|
173
|
+
) : null}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -23,8 +23,11 @@ export function AnalysisScreen({
|
|
|
23
23
|
|
|
24
24
|
<div className="ritual-fact-grid">
|
|
25
25
|
{detected.map((item, idx) => (
|
|
26
|
-
<div key={idx} className="ritual-fact">
|
|
27
|
-
<div className="ritual-fact-
|
|
26
|
+
<div key={idx} className="ritual-fact ritual-fact--visual">
|
|
27
|
+
<div className="ritual-fact-head">
|
|
28
|
+
<span className="ritual-hardware-icon" aria-hidden="true">{item.icon}</span>
|
|
29
|
+
<div className="ritual-fact-label">{item.label}</div>
|
|
30
|
+
</div>
|
|
28
31
|
<div className="ritual-fact-value">{item.value}</div>
|
|
29
32
|
<div className="ritual-fact-detail">{item.detail}</div>
|
|
30
33
|
</div>
|
|
@@ -57,11 +60,11 @@ export function AnalysisScreen({
|
|
|
57
60
|
function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
|
|
58
61
|
if (!analysis) {
|
|
59
62
|
return [
|
|
60
|
-
{ label: t(language, "flow.analysis.
|
|
61
|
-
{ label: t(language, "flow.analysis.
|
|
62
|
-
{ label: t(language, "flow.analysis.
|
|
63
|
-
{ label: t(language, "flow.analysis.
|
|
64
|
-
{ label: t(language, "flow.analysis.
|
|
63
|
+
{ icon: t(language, "flow.analysis.hardware.icon.chip"), label: t(language, "flow.analysis.hardware.label.chip"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.computerDetail") },
|
|
64
|
+
{ icon: t(language, "flow.analysis.hardware.icon.ram"), label: t(language, "flow.analysis.hardware.label.ram"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.memoryDetail") },
|
|
65
|
+
{ icon: t(language, "flow.analysis.hardware.icon.gpu"), label: t(language, "flow.analysis.hardware.label.gpu"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.graphicsDetail") },
|
|
66
|
+
{ icon: t(language, "flow.analysis.hardware.icon.support"), label: t(language, "flow.analysis.hardware.label.support"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.supportDetail") },
|
|
67
|
+
{ icon: t(language, "flow.analysis.hardware.icon.models"), label: t(language, "flow.analysis.hardware.label.models"), value: t(language, "flow.analysis.checking"), detail: t(language, "flow.analysis.fact.modelsDetail") },
|
|
65
68
|
];
|
|
66
69
|
}
|
|
67
70
|
const setupEnv = asRecord(analysis.setup?.environment);
|
|
@@ -81,27 +84,32 @@ function buildDetectedFacts(analysis: FlowAnalysis | null, language: Language) {
|
|
|
81
84
|
];
|
|
82
85
|
return [
|
|
83
86
|
{
|
|
84
|
-
|
|
87
|
+
icon: t(language, "flow.analysis.hardware.icon.chip"),
|
|
88
|
+
label: t(language, "flow.analysis.hardware.label.chip"),
|
|
85
89
|
value: appleSilicon ? t(language, "flow.analysis.apple") : friendlyOs(profile.os, language),
|
|
86
90
|
detail: t(language, "flow.analysis.readyDetail"),
|
|
87
91
|
},
|
|
88
92
|
{
|
|
89
|
-
|
|
93
|
+
icon: t(language, "flow.analysis.hardware.icon.ram"),
|
|
94
|
+
label: t(language, "flow.analysis.hardware.label.ram"),
|
|
90
95
|
value: ramGb ? `${Math.round(ramGb)} GB` : t(language, "flow.analysis.detected"),
|
|
91
96
|
detail: t(language, "flow.analysis.memoryReadyDetail"),
|
|
92
97
|
},
|
|
93
98
|
{
|
|
94
|
-
|
|
99
|
+
icon: t(language, "flow.analysis.hardware.icon.gpu"),
|
|
100
|
+
label: t(language, "flow.analysis.hardware.label.gpu"),
|
|
95
101
|
value: gpu.vendor || sysinfo.gpu_mem_gb ? t(language, "flow.analysis.localReady") : t(language, "flow.analysis.standardLocal"),
|
|
96
102
|
detail: t(language, "flow.analysis.graphicsReadyDetail"),
|
|
97
103
|
},
|
|
98
104
|
{
|
|
99
|
-
|
|
105
|
+
icon: t(language, "flow.analysis.hardware.icon.support"),
|
|
106
|
+
label: t(language, "flow.analysis.hardware.label.support"),
|
|
100
107
|
value: installedRuntimes.length ? t(language, "flow.analysis.supportReady") : t(language, "flow.analysis.supportInstall"),
|
|
101
108
|
detail: installedRuntimes.length ? t(language, "flow.analysis.supportReadyDetail") : t(language, "flow.analysis.supportInstallDetail"),
|
|
102
109
|
},
|
|
103
110
|
{
|
|
104
|
-
|
|
111
|
+
icon: t(language, "flow.analysis.hardware.icon.models"),
|
|
112
|
+
label: t(language, "flow.analysis.hardware.label.models"),
|
|
105
113
|
value: loadedModels.length ? t(language, "flow.analysis.modelsInstalled", { count: loadedModels.length }) : t(language, "flow.analysis.noModels"),
|
|
106
114
|
detail: loadedModels.length ? t(language, "flow.analysis.modelsReadyDetail") : t(language, "flow.analysis.modelsInstallDetail"),
|
|
107
115
|
},
|