ltcai 7.0.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 +20 -20
- package/docs/CHANGELOG.md +23 -0
- package/frontend/src/App.tsx +80 -0
- package/frontend/src/api/client.ts +6 -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 +232 -19
- package/frontend/src/features/brain/BrainGraphLayer.tsx +258 -25
- package/frontend/src/features/brain/BrainHome.tsx +193 -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 +41 -0
- package/frontend/src/i18n.ts +304 -0
- package/frontend/src/store/appStore.ts +15 -0
- package/frontend/src/styles.css +1130 -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/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/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-DhLaHnrT.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-CAKouj52.js → Capture-BrAmsSEH.js} +2 -2
- package/static/app/assets/{Capture-CAKouj52.js.map → Capture-BrAmsSEH.js.map} +1 -1
- package/static/app/assets/Library-BOVzYfxI.js +2 -0
- package/static/app/assets/{Library-B_hW4AwR.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-DKFpn5Kn.css → index-DvLsGy-z.css} +1 -1
- 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-DR2Tyy_6.js.map → textarea-CbvhHvzg.js.map} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/Act-DhLaHnrT.js +0 -2
- package/static/app/assets/Brain-DS7NULyY.js +0 -322
- package/static/app/assets/Brain-DS7NULyY.js.map +0 -1
- package/static/app/assets/Library-B_hW4AwR.js +0 -2
- package/static/app/assets/System-9OAoXWPz.js +0 -2
- package/static/app/assets/System-9OAoXWPz.js.map +0 -1
- package/static/app/assets/index-C-7aHabu.js +0 -17
- package/static/app/assets/index-C-7aHabu.js.map +0 -1
- package/static/app/assets/primitives-DcO2H3yh.js +0 -2
- package/static/app/assets/primitives-DcO2H3yh.js.map +0 -1
- package/static/app/assets/textarea-DR2Tyy_6.js +0 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { Search } from "lucide-react";
|
|
2
|
+
import { Filter, RotateCcw, Search, X } from "lucide-react";
|
|
3
3
|
import { t } from "@/i18n";
|
|
4
4
|
import { useAppStore } from "@/store/appStore";
|
|
5
5
|
import type { KnowledgeConcept, KnowledgeGraphModel } from "./types";
|
|
6
|
-
import { clamp, layerStyle, layoutGraphNodes, polarPoint } from "./graphLayout";
|
|
6
|
+
import { clamp, computeGraphNeighbors, layerStyle, layoutGraphNodes, polarPoint } from "./graphLayout";
|
|
7
7
|
|
|
8
8
|
export function BrainKnowledgeLayer({ concepts, depth }: { concepts: KnowledgeConcept[]; depth: number }) {
|
|
9
9
|
const language = useAppStore((state) => state.language);
|
|
@@ -31,6 +31,31 @@ export function BrainKnowledgeLayer({ concepts, depth }: { concepts: KnowledgeCo
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
// Time-exploration presets (days back). null == all time.
|
|
36
|
+
const TIME_WINDOWS: Array<{ days: number | null }> = [{ days: 7 }, { days: 30 }, { days: 90 }, { days: null }];
|
|
37
|
+
// A node added within this window of "now" is treated as recent for highlighting.
|
|
38
|
+
const RECENT_WINDOW_MS = 7 * DAY_MS;
|
|
39
|
+
|
|
40
|
+
// Highlight occurrences of `query` inside `label` by wrapping matches in <mark>.
|
|
41
|
+
function highlightMatch(label: string, query: string): React.ReactNode {
|
|
42
|
+
if (!query) return label;
|
|
43
|
+
const lower = label.toLowerCase();
|
|
44
|
+
const target = query.toLowerCase();
|
|
45
|
+
const parts: React.ReactNode[] = [];
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
let matchIndex = lower.indexOf(target, cursor);
|
|
48
|
+
let key = 0;
|
|
49
|
+
while (matchIndex !== -1 && target.length > 0) {
|
|
50
|
+
if (matchIndex > cursor) parts.push(label.slice(cursor, matchIndex));
|
|
51
|
+
parts.push(<mark key={`m-${key++}`}>{label.slice(matchIndex, matchIndex + target.length)}</mark>);
|
|
52
|
+
cursor = matchIndex + target.length;
|
|
53
|
+
matchIndex = lower.indexOf(target, cursor);
|
|
54
|
+
}
|
|
55
|
+
if (cursor < label.length) parts.push(label.slice(cursor));
|
|
56
|
+
return parts.length ? parts : label;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
export function BrainGraphLayer({
|
|
35
60
|
model,
|
|
36
61
|
search,
|
|
@@ -46,13 +71,53 @@ export function BrainGraphLayer({
|
|
|
46
71
|
}) {
|
|
47
72
|
const language = useAppStore((state) => state.language);
|
|
48
73
|
const query = search.trim().toLowerCase();
|
|
74
|
+
|
|
75
|
+
// Control state local to the graph view so existing lifted search/selection
|
|
76
|
+
// behavior (and its reset in surface()) is preserved untouched.
|
|
77
|
+
const [activeTypes, setActiveTypes] = React.useState<Set<string>>(() => new Set());
|
|
78
|
+
const [timeDays, setTimeDays] = React.useState<number | null>(null);
|
|
79
|
+
const [typeaheadOpen, setTypeaheadOpen] = React.useState(false);
|
|
80
|
+
|
|
81
|
+
// Distinct entity types available in the graph (for the type filter chips).
|
|
82
|
+
const availableTypes = React.useMemo(() => {
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
for (const node of model.nodes) seen.add(node.type);
|
|
85
|
+
return Array.from(seen).sort((a, b) => a.localeCompare(b));
|
|
86
|
+
}, [model.nodes]);
|
|
87
|
+
|
|
88
|
+
// Drop type selections that no longer exist after a graph refresh.
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
setActiveTypes((prev) => {
|
|
91
|
+
if (prev.size === 0) return prev;
|
|
92
|
+
const next = new Set<string>();
|
|
93
|
+
for (const type of prev) if (availableTypes.includes(type)) next.add(type);
|
|
94
|
+
return next.size === prev.size ? prev : next;
|
|
95
|
+
});
|
|
96
|
+
}, [availableTypes]);
|
|
97
|
+
|
|
98
|
+
const hasTimestamps = React.useMemo(() => model.nodes.some((node) => typeof node.createdAt === "number"), [model.nodes]);
|
|
99
|
+
const timeCutoff = timeDays === null ? null : Date.now() - timeDays * DAY_MS;
|
|
100
|
+
|
|
101
|
+
const filtersActive = activeTypes.size > 0 || timeDays !== null;
|
|
102
|
+
|
|
49
103
|
const visibleNodes = React.useMemo(() => {
|
|
50
104
|
const filtered = model.nodes.filter((node) => {
|
|
105
|
+
if (activeTypes.size > 0 && !activeTypes.has(node.type)) return false;
|
|
106
|
+
if (timeCutoff !== null && typeof node.createdAt === "number" && node.createdAt < timeCutoff) return false;
|
|
51
107
|
if (!query) return true;
|
|
52
108
|
return `${node.label} ${node.type} ${node.summary}`.toLowerCase().includes(query);
|
|
53
109
|
});
|
|
54
110
|
return filtered.slice(0, 18);
|
|
111
|
+
}, [model.nodes, query, activeTypes, timeCutoff]);
|
|
112
|
+
|
|
113
|
+
// Typeahead suggestions: label-prefix/substring matches ranked by importance.
|
|
114
|
+
const suggestions = React.useMemo(() => {
|
|
115
|
+
if (!query) return [];
|
|
116
|
+
return model.nodes
|
|
117
|
+
.filter((node) => node.label.toLowerCase().includes(query) || node.type.toLowerCase().includes(query))
|
|
118
|
+
.slice(0, 6);
|
|
55
119
|
}, [model.nodes, query]);
|
|
120
|
+
|
|
56
121
|
const layout = React.useMemo(() => layoutGraphNodes(visibleNodes, 38, 24), [visibleNodes]);
|
|
57
122
|
const positionById = React.useMemo(() => new Map(layout.map((item) => [item.node.id, item])), [layout]);
|
|
58
123
|
const visibleEdges = React.useMemo(
|
|
@@ -61,6 +126,24 @@ export function BrainGraphLayer({
|
|
|
61
126
|
);
|
|
62
127
|
const selected = visibleNodes.find((node) => node.id === selectedId) || visibleNodes[0] || null;
|
|
63
128
|
|
|
129
|
+
// 1-hop neighbor set for the focused node, drives focus/dim + edge highlight.
|
|
130
|
+
const neighborIds = React.useMemo(
|
|
131
|
+
() => (selectedId ? computeGraphNeighbors(selectedId, model.edges) : new Set<string>()),
|
|
132
|
+
[selectedId, model.edges],
|
|
133
|
+
);
|
|
134
|
+
const focusActive = Boolean(selectedId) && neighborIds.size > 0;
|
|
135
|
+
|
|
136
|
+
const matchedIds = React.useMemo(() => {
|
|
137
|
+
if (!query) return new Set<string>();
|
|
138
|
+
return new Set(visibleNodes.filter((node) => node.label.toLowerCase().includes(query)).map((node) => node.id));
|
|
139
|
+
}, [visibleNodes, query]);
|
|
140
|
+
|
|
141
|
+
function focusState(nodeId: string): "true" | "false" | undefined {
|
|
142
|
+
if (!focusActive) return undefined;
|
|
143
|
+
if (nodeId === selectedId || neighborIds.has(nodeId)) return "true";
|
|
144
|
+
return "false";
|
|
145
|
+
}
|
|
146
|
+
|
|
64
147
|
return (
|
|
65
148
|
<section className="mind-core-graph" data-testid="emergent-knowledge-graph" aria-label={t(language, "brain.aria.graph")}>
|
|
66
149
|
<div className="brain-graph-head">
|
|
@@ -68,24 +151,159 @@ export function BrainGraphLayer({
|
|
|
68
151
|
<span>{t(language, "brain.level")} 5</span>
|
|
69
152
|
<strong>{t(language, "brain.depth.5")}</strong>
|
|
70
153
|
</div>
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
154
|
+
<div className="brain-graph-search-wrap">
|
|
155
|
+
<label className="brain-graph-search">
|
|
156
|
+
<Search className="h-3.5 w-3.5" />
|
|
157
|
+
<input
|
|
158
|
+
value={search}
|
|
159
|
+
onChange={(event) => {
|
|
160
|
+
onSearch(event.target.value);
|
|
161
|
+
setTypeaheadOpen(true);
|
|
162
|
+
}}
|
|
163
|
+
onFocus={() => setTypeaheadOpen(true)}
|
|
164
|
+
onBlur={() => window.setTimeout(() => setTypeaheadOpen(false), 120)}
|
|
165
|
+
placeholder={t(language, "brain.graph.search")}
|
|
166
|
+
aria-label={t(language, "brain.graph.searchAria")}
|
|
167
|
+
role="combobox"
|
|
168
|
+
aria-expanded={typeaheadOpen && Boolean(query)}
|
|
169
|
+
aria-autocomplete="list"
|
|
170
|
+
aria-controls="brain-graph-typeahead"
|
|
171
|
+
/>
|
|
172
|
+
{search ? (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
className="brain-graph-search-clear"
|
|
176
|
+
onClick={() => onSearch("")}
|
|
177
|
+
aria-label={t(language, "brain.graph.focus.clear")}
|
|
178
|
+
>
|
|
179
|
+
<X className="h-3 w-3" />
|
|
180
|
+
</button>
|
|
181
|
+
) : null}
|
|
182
|
+
</label>
|
|
183
|
+
{typeaheadOpen && query ? (
|
|
184
|
+
<ul
|
|
185
|
+
id="brain-graph-typeahead"
|
|
186
|
+
className="brain-graph-typeahead"
|
|
187
|
+
role="listbox"
|
|
188
|
+
aria-label={t(language, "brain.graph.search.typeaheadAria")}
|
|
189
|
+
>
|
|
190
|
+
{suggestions.length ? (
|
|
191
|
+
suggestions.map((node) => (
|
|
192
|
+
<li key={node.id} role="option" aria-selected={node.id === selectedId}>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onMouseDown={(event) => {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
onSearch(node.label);
|
|
198
|
+
onSelect(node.id);
|
|
199
|
+
setTypeaheadOpen(false);
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<span>{node.type}</span>
|
|
203
|
+
<strong>{highlightMatch(node.label, query)}</strong>
|
|
204
|
+
</button>
|
|
205
|
+
</li>
|
|
206
|
+
))
|
|
207
|
+
) : (
|
|
208
|
+
<li className="is-empty" aria-disabled>
|
|
209
|
+
{t(language, "brain.graph.search.noMatches")}
|
|
210
|
+
</li>
|
|
211
|
+
)}
|
|
212
|
+
</ul>
|
|
213
|
+
) : null}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="brain-graph-control-panel" role="group" aria-label={t(language, "brain.graph.control.panel")}>
|
|
218
|
+
<div className="brain-graph-filter-row">
|
|
219
|
+
<span className="brain-graph-control-label">
|
|
220
|
+
<Filter className="h-3 w-3" aria-hidden />
|
|
221
|
+
{t(language, "brain.graph.control.types")}
|
|
222
|
+
</span>
|
|
223
|
+
<div className="brain-graph-type-chips" role="group" aria-label={t(language, "brain.graph.control.types")}>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
className={`brain-graph-chip ${activeTypes.size === 0 ? "is-active" : ""}`}
|
|
227
|
+
aria-pressed={activeTypes.size === 0}
|
|
228
|
+
onClick={() => setActiveTypes(new Set())}
|
|
229
|
+
>
|
|
230
|
+
{t(language, "brain.graph.control.allTypes")}
|
|
231
|
+
</button>
|
|
232
|
+
{availableTypes.map((type) => {
|
|
233
|
+
const active = activeTypes.has(type);
|
|
234
|
+
return (
|
|
235
|
+
<button
|
|
236
|
+
key={type}
|
|
237
|
+
type="button"
|
|
238
|
+
className={`brain-graph-chip ${active ? "is-active" : ""}`}
|
|
239
|
+
aria-pressed={active}
|
|
240
|
+
aria-label={t(language, "brain.graph.control.toggleType", { type })}
|
|
241
|
+
onClick={() =>
|
|
242
|
+
setActiveTypes((prev) => {
|
|
243
|
+
const next = new Set(prev);
|
|
244
|
+
if (next.has(type)) next.delete(type);
|
|
245
|
+
else next.add(type);
|
|
246
|
+
return next;
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
>
|
|
250
|
+
{type}
|
|
251
|
+
</button>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div className="brain-graph-filter-row">
|
|
258
|
+
<span className="brain-graph-control-label">{t(language, "brain.graph.control.time")}</span>
|
|
259
|
+
<div className="brain-graph-time-chips" role="group" aria-label={t(language, "brain.graph.control.dateRange")}>
|
|
260
|
+
{TIME_WINDOWS.map((window) => {
|
|
261
|
+
const active = timeDays === window.days;
|
|
262
|
+
const label =
|
|
263
|
+
window.days === null
|
|
264
|
+
? t(language, "brain.graph.control.allTime")
|
|
265
|
+
: t(language, "brain.graph.control.recentWindow", { days: window.days });
|
|
266
|
+
return (
|
|
267
|
+
<button
|
|
268
|
+
key={window.days ?? "all"}
|
|
269
|
+
type="button"
|
|
270
|
+
className={`brain-graph-chip ${active ? "is-active" : ""}`}
|
|
271
|
+
aria-pressed={active}
|
|
272
|
+
disabled={window.days !== null && !hasTimestamps}
|
|
273
|
+
onClick={() => setTimeDays(window.days)}
|
|
274
|
+
>
|
|
275
|
+
{label}
|
|
276
|
+
</button>
|
|
277
|
+
);
|
|
278
|
+
})}
|
|
279
|
+
</div>
|
|
280
|
+
{filtersActive ? (
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
className="brain-graph-reset"
|
|
284
|
+
onClick={() => {
|
|
285
|
+
setActiveTypes(new Set());
|
|
286
|
+
setTimeDays(null);
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<RotateCcw className="h-3 w-3" aria-hidden />
|
|
290
|
+
{t(language, "brain.graph.control.resetFilters")}
|
|
291
|
+
</button>
|
|
292
|
+
) : null}
|
|
293
|
+
</div>
|
|
80
294
|
</div>
|
|
81
295
|
|
|
82
296
|
{visibleNodes.length ? (
|
|
83
|
-
<div className="brain-graph-canvas">
|
|
297
|
+
<div className="brain-graph-canvas" data-focus-active={focusActive ? "true" : "false"}>
|
|
84
298
|
<svg className="brain-graph-edges" viewBox="0 0 100 100" aria-hidden>
|
|
85
299
|
{visibleEdges.map((edge, index) => {
|
|
86
300
|
const source = positionById.get(edge.source);
|
|
87
301
|
const target = positionById.get(edge.target);
|
|
88
302
|
if (!source || !target) return null;
|
|
303
|
+
const touchesFocus =
|
|
304
|
+
focusActive && (edge.source === selectedId || edge.target === selectedId);
|
|
305
|
+
const touchesMatch = matchedIds.has(edge.source) || matchedIds.has(edge.target);
|
|
306
|
+
const highlight = touchesFocus || (query ? touchesMatch : false);
|
|
89
307
|
return (
|
|
90
308
|
<line
|
|
91
309
|
key={`${edge.id}-${index}`}
|
|
@@ -93,23 +311,32 @@ export function BrainGraphLayer({
|
|
|
93
311
|
y1={source.y}
|
|
94
312
|
x2={target.x}
|
|
95
313
|
y2={target.y}
|
|
314
|
+
data-highlight={
|
|
315
|
+
focusActive || query ? (highlight ? "true" : "false") : undefined
|
|
316
|
+
}
|
|
96
317
|
style={{ "--weight": String(clamp(edge.weight, 0.4, 2.8)) } as React.CSSProperties}
|
|
97
318
|
/>
|
|
98
319
|
);
|
|
99
320
|
})}
|
|
100
321
|
</svg>
|
|
101
|
-
{layout.map(({ node, x, y }, index) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
322
|
+
{layout.map(({ node, x, y }, index) => {
|
|
323
|
+
const isRecent =
|
|
324
|
+
typeof node.createdAt === "number" && Date.now() - node.createdAt <= RECENT_WINDOW_MS;
|
|
325
|
+
return (
|
|
326
|
+
<button
|
|
327
|
+
key={node.id}
|
|
328
|
+
type="button"
|
|
329
|
+
className={`graph-node ${selected?.id === node.id ? "is-selected" : ""} ${matchedIds.has(node.id) ? "is-match" : ""}`}
|
|
330
|
+
data-focus={focusState(node.id)}
|
|
331
|
+
data-recent={isRecent ? "true" : undefined}
|
|
332
|
+
style={layerStyle({ "--x": `${x}%`, "--y": `${y}%`, "--delay": `${index * 35}ms` })}
|
|
333
|
+
onClick={() => onSelect(selectedId === node.id ? null : node.id)}
|
|
334
|
+
>
|
|
335
|
+
<span>{node.type}</span>
|
|
336
|
+
{highlightMatch(node.label, query)}
|
|
337
|
+
</button>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
113
340
|
</div>
|
|
114
341
|
) : (
|
|
115
342
|
<div className="brain-graph-empty">{t(language, "brain.graph.empty")}</div>
|
|
@@ -121,7 +348,13 @@ export function BrainGraphLayer({
|
|
|
121
348
|
<span>{selected.type}</span>
|
|
122
349
|
<strong>{selected.label}</strong>
|
|
123
350
|
<p>{selected.summary || t(language, "brain.graph.summaryFallback")}</p>
|
|
124
|
-
|
|
351
|
+
{focusActive ? (
|
|
352
|
+
<p className="brain-graph-focus-meta" role="status">
|
|
353
|
+
{t(language, "brain.graph.focus.neighbors", { count: neighborIds.size })}
|
|
354
|
+
</p>
|
|
355
|
+
) : (
|
|
356
|
+
<p>{t(language, "brain.graph.focused")}</p>
|
|
357
|
+
)}
|
|
125
358
|
</>
|
|
126
359
|
) : (
|
|
127
360
|
<p>{t(language, "brain.graph.emptyFocus")}</p>
|
|
@@ -7,7 +7,19 @@ import { t } from "@/i18n";
|
|
|
7
7
|
import { BrainConversation } from "./BrainConversation";
|
|
8
8
|
import { buildBrainProof, buildBrainReadiness, buildMemoryFragments, currentModelName, parseKnowledgeGraph } from "./brainData";
|
|
9
9
|
import { DepthEmergence } from "./DepthEmergence";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
DEPTHS,
|
|
12
|
+
INGESTION_STAGE_ORDER,
|
|
13
|
+
type BrainDepth,
|
|
14
|
+
type BrainProof,
|
|
15
|
+
type EmergenceEvent,
|
|
16
|
+
type IngestionPipelineStage,
|
|
17
|
+
type IngestionSourceType,
|
|
18
|
+
type IngestionState,
|
|
19
|
+
type MemoryFragment,
|
|
20
|
+
type Message,
|
|
21
|
+
type MessageProof,
|
|
22
|
+
} from "./types";
|
|
11
23
|
|
|
12
24
|
export function BrainHome({
|
|
13
25
|
brainState,
|
|
@@ -31,8 +43,27 @@ export function BrainHome({
|
|
|
31
43
|
const [memoryFeedback, setMemoryFeedback] = React.useState<string | null>(null);
|
|
32
44
|
const [uploadingDocument, setUploadingDocument] = React.useState(false);
|
|
33
45
|
const [lastRecallQuery, setLastRecallQuery] = React.useState("");
|
|
46
|
+
const [ingestionStates, setIngestionStates] = React.useState<Record<IngestionSourceType, IngestionState | null>>({
|
|
47
|
+
file: null,
|
|
48
|
+
folder: null,
|
|
49
|
+
note: null,
|
|
50
|
+
web: null,
|
|
51
|
+
});
|
|
52
|
+
const [emergenceEvents, setEmergenceEvents] = React.useState<EmergenceEvent[]>([]);
|
|
34
53
|
const streamRef = React.useRef<HTMLDivElement>(null);
|
|
35
54
|
const recallTimerRef = React.useRef<number | null>(null);
|
|
55
|
+
const stageTimersRef = React.useRef<Record<IngestionSourceType, number[]>>({
|
|
56
|
+
file: [],
|
|
57
|
+
folder: [],
|
|
58
|
+
note: [],
|
|
59
|
+
web: [],
|
|
60
|
+
});
|
|
61
|
+
const pendingBaselineRef = React.useRef<
|
|
62
|
+
Partial<Record<IngestionSourceType, { memories: number; entities: number; label: string }>>
|
|
63
|
+
>({});
|
|
64
|
+
// Source types whose request has resolved and are awaiting count settle to record emergence.
|
|
65
|
+
const awaitingEmergenceRef = React.useRef<Set<IngestionSourceType>>(new Set());
|
|
66
|
+
const settleTimerRef = React.useRef<number | null>(null);
|
|
36
67
|
|
|
37
68
|
const memoriesQ = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
|
|
38
69
|
const historyQ = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
|
|
@@ -89,9 +120,156 @@ export function BrainHome({
|
|
|
89
120
|
React.useEffect(() => {
|
|
90
121
|
return () => {
|
|
91
122
|
if (recallTimerRef.current !== null) window.clearTimeout(recallTimerRef.current);
|
|
123
|
+
for (const timers of Object.values(stageTimersRef.current)) {
|
|
124
|
+
for (const timer of timers) window.clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const clearStageTimers = React.useCallback((sourceType: IngestionSourceType) => {
|
|
130
|
+
for (const timer of stageTimersRef.current[sourceType]) window.clearTimeout(timer);
|
|
131
|
+
stageTimersRef.current[sourceType] = [];
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const setStage = React.useCallback((sourceType: IngestionSourceType, stage: IngestionPipelineStage) => {
|
|
135
|
+
setIngestionStates((prev) => {
|
|
136
|
+
const current = prev[sourceType];
|
|
137
|
+
if (!current) return prev;
|
|
138
|
+
return {
|
|
139
|
+
...prev,
|
|
140
|
+
[sourceType]: {
|
|
141
|
+
...current,
|
|
142
|
+
stage,
|
|
143
|
+
completedAt: stage === "complete" || stage === "error" ? Date.now() : current.completedAt,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const beginIngestion = React.useCallback(
|
|
150
|
+
(sourceType: IngestionSourceType, label: string) => {
|
|
151
|
+
clearStageTimers(sourceType);
|
|
152
|
+
pendingBaselineRef.current[sourceType] = {
|
|
153
|
+
memories: memoryFragments.length,
|
|
154
|
+
entities: knowledgeConcepts.length,
|
|
155
|
+
label,
|
|
156
|
+
};
|
|
157
|
+
setIngestionStates((prev) => ({
|
|
158
|
+
...prev,
|
|
159
|
+
[sourceType]: {
|
|
160
|
+
sourceType,
|
|
161
|
+
label,
|
|
162
|
+
stage: "preparing",
|
|
163
|
+
startedAt: Date.now(),
|
|
164
|
+
completedAt: null,
|
|
165
|
+
newMemories: 0,
|
|
166
|
+
newEntities: 0,
|
|
167
|
+
},
|
|
168
|
+
}));
|
|
169
|
+
// Progressive disclosure of the in-flight pipeline while the request runs.
|
|
170
|
+
const interim: IngestionPipelineStage[] = ["parsing", "embedding", "indexing"];
|
|
171
|
+
interim.forEach((stage, index) => {
|
|
172
|
+
const timer = window.setTimeout(() => setStage(sourceType, stage), 420 * (index + 1));
|
|
173
|
+
stageTimersRef.current[sourceType].push(timer);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
[clearStageTimers, knowledgeConcepts.length, memoryFragments.length, setStage],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const resolveEmergence = React.useCallback(
|
|
180
|
+
(sourceType: IngestionSourceType, memoryCount: number, entityCount: number) => {
|
|
181
|
+
clearStageTimers(sourceType);
|
|
182
|
+
const baseline = pendingBaselineRef.current[sourceType];
|
|
183
|
+
const label = baseline?.label ?? "";
|
|
184
|
+
// Snapshot deltas after invalidation lands; cap at >=0 to avoid noise.
|
|
185
|
+
const newMemories = Math.max(0, memoryCount - (baseline?.memories ?? memoryCount));
|
|
186
|
+
const newEntities = Math.max(0, entityCount - (baseline?.entities ?? entityCount));
|
|
187
|
+
delete pendingBaselineRef.current[sourceType];
|
|
188
|
+
setIngestionStates((prev) => {
|
|
189
|
+
const current = prev[sourceType];
|
|
190
|
+
if (!current) return prev;
|
|
191
|
+
return {
|
|
192
|
+
...prev,
|
|
193
|
+
[sourceType]: {
|
|
194
|
+
...current,
|
|
195
|
+
stage: "complete",
|
|
196
|
+
completedAt: Date.now(),
|
|
197
|
+
newMemories,
|
|
198
|
+
newEntities,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
setEmergenceEvents((events) =>
|
|
203
|
+
[
|
|
204
|
+
{
|
|
205
|
+
id: `${sourceType}-${Date.now()}`,
|
|
206
|
+
sourceType,
|
|
207
|
+
label,
|
|
208
|
+
newMemories,
|
|
209
|
+
newEntities,
|
|
210
|
+
at: Date.now(),
|
|
211
|
+
},
|
|
212
|
+
...events,
|
|
213
|
+
].slice(0, 10),
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
[clearStageTimers],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Once a request resolves we wait for the refetched counts to settle, then record the
|
|
220
|
+
// real emergence delta. A fallback timer guarantees the panel never hangs on a stale count.
|
|
221
|
+
const markAwaitingEmergence = React.useCallback(
|
|
222
|
+
(sourceType: IngestionSourceType) => {
|
|
223
|
+
awaitingEmergenceRef.current.add(sourceType);
|
|
224
|
+
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
|
|
225
|
+
settleTimerRef.current = window.setTimeout(() => {
|
|
226
|
+
for (const pending of Array.from(awaitingEmergenceRef.current)) {
|
|
227
|
+
resolveEmergence(pending, memoryFragments.length, knowledgeConcepts.length);
|
|
228
|
+
awaitingEmergenceRef.current.delete(pending);
|
|
229
|
+
}
|
|
230
|
+
}, 1600);
|
|
231
|
+
},
|
|
232
|
+
[knowledgeConcepts.length, memoryFragments.length, resolveEmergence],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Flush awaiting ingestions as soon as the underlying counts change post-invalidation.
|
|
236
|
+
React.useEffect(() => {
|
|
237
|
+
if (awaitingEmergenceRef.current.size === 0) return;
|
|
238
|
+
for (const pending of Array.from(awaitingEmergenceRef.current)) {
|
|
239
|
+
const baseline = pendingBaselineRef.current[pending];
|
|
240
|
+
if (!baseline) {
|
|
241
|
+
awaitingEmergenceRef.current.delete(pending);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (memoryFragments.length !== baseline.memories || knowledgeConcepts.length !== baseline.entities) {
|
|
245
|
+
resolveEmergence(pending, memoryFragments.length, knowledgeConcepts.length);
|
|
246
|
+
awaitingEmergenceRef.current.delete(pending);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}, [memoryFragments.length, knowledgeConcepts.length, resolveEmergence]);
|
|
250
|
+
|
|
251
|
+
React.useEffect(() => {
|
|
252
|
+
return () => {
|
|
253
|
+
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
|
|
92
254
|
};
|
|
93
255
|
}, []);
|
|
94
256
|
|
|
257
|
+
const failIngestion = React.useCallback(
|
|
258
|
+
(sourceType: IngestionSourceType, reason: string) => {
|
|
259
|
+
clearStageTimers(sourceType);
|
|
260
|
+
delete pendingBaselineRef.current[sourceType];
|
|
261
|
+
setIngestionStates((prev) => {
|
|
262
|
+
const current = prev[sourceType];
|
|
263
|
+
if (!current) return prev;
|
|
264
|
+
return {
|
|
265
|
+
...prev,
|
|
266
|
+
[sourceType]: { ...current, stage: "error", completedAt: Date.now(), error: reason },
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
[clearStageTimers],
|
|
271
|
+
);
|
|
272
|
+
|
|
95
273
|
async function send() {
|
|
96
274
|
const text = draft.trim();
|
|
97
275
|
if (!text || streaming) return;
|
|
@@ -167,11 +345,13 @@ export function BrainHome({
|
|
|
167
345
|
setUploadingDocument(true);
|
|
168
346
|
setMemoryFeedback(t(language, "brain.upload.pending", { name: file.name }));
|
|
169
347
|
onBrainChange("recalling", 0.86);
|
|
348
|
+
beginIngestion("file", file.name);
|
|
170
349
|
|
|
171
350
|
try {
|
|
172
351
|
const result = await latticeApi.uploadDocument(file);
|
|
173
352
|
if (result.error) {
|
|
174
353
|
setMemoryFeedback(t(language, "brain.upload.failed", { reason: result.error }));
|
|
354
|
+
failIngestion("file", result.error);
|
|
175
355
|
return;
|
|
176
356
|
}
|
|
177
357
|
|
|
@@ -181,6 +361,7 @@ export function BrainHome({
|
|
|
181
361
|
void qc.invalidateQueries({ queryKey: ["memoryManager"] });
|
|
182
362
|
void qc.invalidateQueries({ queryKey: ["graph"] });
|
|
183
363
|
void qc.invalidateQueries({ queryKey: ["memoryBrainProof"] });
|
|
364
|
+
markAwaitingEmergence("file");
|
|
184
365
|
} finally {
|
|
185
366
|
setUploadingDocument(false);
|
|
186
367
|
}
|
|
@@ -191,15 +372,18 @@ export function BrainHome({
|
|
|
191
372
|
if (!target) return;
|
|
192
373
|
setMemoryFeedback(t(language, "brain.ingest.folder.pending", { path: target }));
|
|
193
374
|
onBrainChange("recalling", 0.84);
|
|
375
|
+
beginIngestion("folder", target);
|
|
194
376
|
const result = await latticeApi.connectFolder(target);
|
|
195
377
|
if (result.error || !result.ok) {
|
|
196
378
|
setMemoryFeedback(t(language, "brain.ingest.folder.failed", { reason: result.error || "unavailable" }));
|
|
379
|
+
failIngestion("folder", result.error || "unavailable");
|
|
197
380
|
return;
|
|
198
381
|
}
|
|
199
382
|
setMemoryFeedback(t(language, "brain.ingest.folder.saved", { path: target }));
|
|
200
383
|
setLastRecallQuery(target);
|
|
201
384
|
triggerBrainRecall();
|
|
202
385
|
void refreshBrainProof(target);
|
|
386
|
+
markAwaitingEmergence("folder");
|
|
203
387
|
}
|
|
204
388
|
|
|
205
389
|
async function ingestNote(note: string) {
|
|
@@ -207,15 +391,18 @@ export function BrainHome({
|
|
|
207
391
|
if (!content) return;
|
|
208
392
|
setMemoryFeedback(t(language, "brain.ingest.note.pending"));
|
|
209
393
|
onBrainChange("recalling", 0.84);
|
|
394
|
+
beginIngestion("note", content.slice(0, 80));
|
|
210
395
|
const result = await latticeApi.ingestNote(content, content.slice(0, 80));
|
|
211
396
|
if (result.error || !result.ok) {
|
|
212
397
|
setMemoryFeedback(t(language, "brain.ingest.note.failed", { reason: result.error || "unavailable" }));
|
|
398
|
+
failIngestion("note", result.error || "unavailable");
|
|
213
399
|
return;
|
|
214
400
|
}
|
|
215
401
|
setMemoryFeedback(t(language, "brain.ingest.note.saved"));
|
|
216
402
|
setLastRecallQuery(content.slice(0, 120));
|
|
217
403
|
triggerBrainRecall();
|
|
218
404
|
void refreshBrainProof(content.slice(0, 120));
|
|
405
|
+
markAwaitingEmergence("note");
|
|
219
406
|
}
|
|
220
407
|
|
|
221
408
|
async function ingestWeb(url: string) {
|
|
@@ -223,15 +410,18 @@ export function BrainHome({
|
|
|
223
410
|
if (!target) return;
|
|
224
411
|
setMemoryFeedback(t(language, "brain.ingest.web.pending", { url: target }));
|
|
225
412
|
onBrainChange("recalling", 0.84);
|
|
413
|
+
beginIngestion("web", target);
|
|
226
414
|
const result = await latticeApi.browserReadUrl(target);
|
|
227
415
|
if (result.error || !result.ok) {
|
|
228
416
|
setMemoryFeedback(t(language, "brain.ingest.web.failed", { reason: result.error || "unavailable" }));
|
|
417
|
+
failIngestion("web", result.error || "unavailable");
|
|
229
418
|
return;
|
|
230
419
|
}
|
|
231
420
|
setMemoryFeedback(t(language, "brain.ingest.web.saved", { url: target }));
|
|
232
421
|
setLastRecallQuery(target);
|
|
233
422
|
triggerBrainRecall();
|
|
234
423
|
void refreshBrainProof(target);
|
|
424
|
+
markAwaitingEmergence("web");
|
|
235
425
|
}
|
|
236
426
|
|
|
237
427
|
async function refreshBrainProof(query = lastRecallQuery) {
|
|
@@ -361,6 +551,8 @@ export function BrainHome({
|
|
|
361
551
|
messages={messages}
|
|
362
552
|
starterPrompts={starterPrompts}
|
|
363
553
|
memoryFeedback={memoryFeedback}
|
|
554
|
+
ingestionStates={ingestionStates}
|
|
555
|
+
emergenceEvents={emergenceEvents}
|
|
364
556
|
draft={draft}
|
|
365
557
|
streaming={streaming}
|
|
366
558
|
imageData={imageData}
|
|
@@ -33,7 +33,9 @@ export function parseKnowledgeGraph(data: unknown): KnowledgeGraphModel {
|
|
|
33
33
|
const label = textValue(node, ["title", "label", "name"], id.replace(/^[^:]+:/, ""));
|
|
34
34
|
const summary = textValue(node, ["summary", "description", "snippet"]) || textValue(metadata, ["summary", "description", "relative_path", "filename"]);
|
|
35
35
|
const importance = clamp(numberValue(node, ["importance_norm", "importance", "score"]) || 0.5, 0.08, 1);
|
|
36
|
-
|
|
36
|
+
const createdAt = timestampValue(node, ["created_at", "createdAt", "added_at", "addedAt", "timestamp", "updated_at", "updatedAt"])
|
|
37
|
+
?? timestampValue(metadata, ["created_at", "createdAt", "added_at", "addedAt", "timestamp", "updated_at", "updatedAt"]);
|
|
38
|
+
return [{ id, label, type, summary, importance, ...(createdAt !== undefined ? { createdAt } : {}) }];
|
|
37
39
|
}).sort((left, right) => right.importance - left.importance);
|
|
38
40
|
const ids = new Set(nodes.map((node) => node.id));
|
|
39
41
|
const edges = rawEdges.flatMap((edge, index): RelationshipThread[] => {
|
|
@@ -205,6 +207,28 @@ function titleValue(record: ApiRecord, keys: string[], fallback = "") {
|
|
|
205
207
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
// Parse a created/added timestamp into unix epoch milliseconds. Accepts ISO 8601
|
|
211
|
+
// strings, unix seconds, or unix milliseconds. Returns undefined when absent or
|
|
212
|
+
// unparseable so the time-exploration UI can fall back gracefully.
|
|
213
|
+
function timestampValue(record: ApiRecord, keys: string[]): number | undefined {
|
|
214
|
+
for (const key of keys) {
|
|
215
|
+
const value = record[key];
|
|
216
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
217
|
+
// Heuristic: values below ~1e12 are seconds, otherwise milliseconds.
|
|
218
|
+
return value < 1e12 ? value * 1000 : value;
|
|
219
|
+
}
|
|
220
|
+
if (typeof value === "string" && value.trim()) {
|
|
221
|
+
const numeric = Number(value);
|
|
222
|
+
if (Number.isFinite(numeric) && /^\d+$/.test(value.trim())) {
|
|
223
|
+
return numeric < 1e12 ? numeric * 1000 : numeric;
|
|
224
|
+
}
|
|
225
|
+
const parsed = Date.parse(value);
|
|
226
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
208
232
|
function numberValue(record: ApiRecord, keys: string[]) {
|
|
209
233
|
for (const key of keys) {
|
|
210
234
|
const value = Number(record[key]);
|