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
package/README.md
CHANGED
|
@@ -200,26 +200,25 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for developer workflow details.
|
|
|
200
200
|
|
|
201
201
|
## Current Release
|
|
202
202
|
|
|
203
|
-
The current release is **7.
|
|
204
|
-
|
|
205
|
-
-
|
|
206
|
-
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
211
|
-
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
- `dist/ltcai-7.
|
|
219
|
-
- `
|
|
220
|
-
- `ltcai-7.
|
|
221
|
-
- `
|
|
222
|
-
- `src-tauri/target/release/bundle/dmg/Lattice AI_7.0.0_aarch64.dmg`
|
|
203
|
+
The current release is **7.1.0 Brain Usability Completion**:
|
|
204
|
+
|
|
205
|
+
- First-run onboarding now explains detected hardware, recommendation timing,
|
|
206
|
+
install progress, and the next action in plain language.
|
|
207
|
+
- Brain Home shows ingestion progress for files, folders, notes, and web pages,
|
|
208
|
+
including memory emergence after content lands.
|
|
209
|
+
- Knowledge Graph exploration adds search suggestions, type filters, time
|
|
210
|
+
exploration, focus movement, and neighbor highlighting.
|
|
211
|
+
- Chat answers surface inline source citations and accessible proof payloads.
|
|
212
|
+
- Workspace/profile/admin entry points, empty/error feedback, consent revoke
|
|
213
|
+
feedback, and VS Code extension sync status are visible in the product shell.
|
|
214
|
+
|
|
215
|
+
Expected artifacts for 7.1.0 release must use exact filenames:
|
|
216
|
+
|
|
217
|
+
- `dist/ltcai-7.1.0-py3-none-any.whl`
|
|
218
|
+
- `dist/ltcai-7.1.0.tar.gz`
|
|
219
|
+
- `ltcai-7.1.0.tgz`
|
|
220
|
+
- `dist/ltcai-7.1.0.vsix`
|
|
221
|
+
- `src-tauri/target/release/bundle/dmg/Lattice AI_7.1.0_aarch64.dmg`
|
|
223
222
|
|
|
224
223
|
Do not upload `dist/*`. Package registry publishing remains owner-run.
|
|
225
224
|
|
|
@@ -238,6 +237,7 @@ Do not upload `dist/*`. Package registry publishing remains owner-run.
|
|
|
238
237
|
|
|
239
238
|
| Version | Theme |
|
|
240
239
|
| --- | --- |
|
|
240
|
+
| 7.1.0 | Brain Usability Completion: clearer first-run onboarding, ingestion progress/emergence, richer graph controls, inline answer proof, workspace/profile/admin discovery, empty/error/consent feedback, and VS Code sync status |
|
|
241
241
|
| 7.0.0 | Brain Productization Loop: first-screen ingestion for files/folders/notes/web, answer-level memory proof and source citations, model-continuity demo flow, five-minute first-run loop, and recall/KG quality eval in CI |
|
|
242
242
|
| 6.7.0 | Brain IA Cleanup: reachable rich pages, separated product routes vs compatibility aliases, shared Brain shell navigation, and lazy-loaded rich pages |
|
|
243
243
|
| 6.6.0 | Brain Proof Runtime: backend-owned Brain proof API, model-continuity wiring, first-screen proof that saved context can be recalled across model changes, and direct Brain Home document upload |
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ The top entry is either the current unreleased main-branch work or the current
|
|
|
4
4
|
release line. Older entries are historical and may describe behavior as it
|
|
5
5
|
existed at that release.
|
|
6
6
|
|
|
7
|
+
## [7.1.0] - 2026-06-20
|
|
8
|
+
|
|
9
|
+
> Brain Usability Completion. Completes the 7.1.0 first-run through editor-sync
|
|
10
|
+
> usability pass: clear onboarding, visible ingestion, graph controls, inline
|
|
11
|
+
> proof, workspace/admin discovery, feedback states, and VS Code sync status.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Hardware visualization, expected timing, install timeline, and next-action
|
|
15
|
+
copy in first-run onboarding.
|
|
16
|
+
- Brain Home ingestion stage disclosure and memory emergence timeline for file,
|
|
17
|
+
folder, note, and URL sources.
|
|
18
|
+
- Knowledge Graph search suggestions, entity type filters, recent/all-time time
|
|
19
|
+
exploration, focus clearing, and neighbor highlighting.
|
|
20
|
+
- Inline answer citation markers with keyboard-accessible proof cards.
|
|
21
|
+
- Workspace/profile switcher, Admin Console gate, consent revoke feedback, and
|
|
22
|
+
shared empty/error feedback surfaces in the Brain shell.
|
|
23
|
+
- VS Code extension heartbeat/status endpoint plus extension and main-app sync
|
|
24
|
+
indicators for connected/indexing/synced/offline states.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Package/runtime/static metadata is synchronized to 7.1.0; package publish and
|
|
28
|
+
deployment remain owner-run only.
|
|
29
|
+
|
|
7
30
|
## [7.0.0] - 2026-06-18
|
|
8
31
|
|
|
9
32
|
> Brain Productization Loop. Turns the Brain proof work into a first-five-minute
|
package/frontend/src/App.tsx
CHANGED
|
@@ -5,6 +5,12 @@ import { useAppStore } from "@/store/appStore";
|
|
|
5
5
|
import { parseHash, productShellRoutes } from "@/routes";
|
|
6
6
|
import { BrainHome } from "@/features/brain/BrainHome";
|
|
7
7
|
import { AdminConsole } from "@/features/admin/AdminConsole";
|
|
8
|
+
import { WorkspaceProfileSwitcher } from "@/components/WorkspaceProfileSwitcher";
|
|
9
|
+
import { AdminAccessGate } from "@/components/AdminAccessGate";
|
|
10
|
+
import { t, type Language } from "@/i18n";
|
|
11
|
+
import { latticeApi } from "@/api/client";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { FeedbackState } from "@/components/FeedbackState";
|
|
8
14
|
|
|
9
15
|
const ActPage = React.lazy(() => import("@/pages/Act").then((module) => ({ default: module.ActPage })));
|
|
10
16
|
const BrainPage = React.lazy(() => import("@/pages/Brain").then((module) => ({ default: module.BrainPage })));
|
|
@@ -95,6 +101,7 @@ function BrainShell({
|
|
|
95
101
|
active: string;
|
|
96
102
|
children: React.ReactNode;
|
|
97
103
|
}) {
|
|
104
|
+
const language = useAppStore((state) => state.language);
|
|
98
105
|
return (
|
|
99
106
|
<main className="brain-shell-page" aria-label="Lattice workspace">
|
|
100
107
|
<nav className="brain-shell-nav" aria-label="Brain workspace navigation">
|
|
@@ -103,19 +110,92 @@ function BrainShell({
|
|
|
103
110
|
key={item.id}
|
|
104
111
|
type="button"
|
|
105
112
|
className={item.id === active ? "is-active" : ""}
|
|
113
|
+
aria-current={item.id === active ? "page" : undefined}
|
|
106
114
|
onClick={() => navigateHash(`/${item.path}`)}
|
|
107
115
|
>
|
|
108
116
|
{item.label}
|
|
109
117
|
</button>
|
|
110
118
|
))}
|
|
119
|
+
<div className="brain-shell-switchers" aria-label={t(language, "shell.workspace.label")}>
|
|
120
|
+
<VsCodeSyncStatus language={language} />
|
|
121
|
+
<WorkspaceProfileSwitcher language={language} />
|
|
122
|
+
<AdminAccessGate language={language} />
|
|
123
|
+
</div>
|
|
111
124
|
</nav>
|
|
112
125
|
<section className="brain-shell-content">
|
|
126
|
+
<ExternalConsentStatus language={language} />
|
|
113
127
|
{children}
|
|
114
128
|
</section>
|
|
115
129
|
</main>
|
|
116
130
|
);
|
|
117
131
|
}
|
|
118
132
|
|
|
133
|
+
function ExternalConsentStatus({ language }: { language: Language }) {
|
|
134
|
+
const externalConsent = useAppStore((state) => state.externalConsent);
|
|
135
|
+
const setExternalConsent = useAppStore((state) => state.setExternalConsent);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<section className="external-consent-status" aria-label={t(language, "feedback.consent.aria")}>
|
|
139
|
+
<FeedbackState
|
|
140
|
+
tone={externalConsent ? "empty" : "error"}
|
|
141
|
+
language={language}
|
|
142
|
+
title={externalConsent ? t(language, "feedback.consent.activeTitle") : t(language, "feedback.consent.revokedTitle")}
|
|
143
|
+
body={externalConsent ? t(language, "feedback.consent.activeBody") : t(language, "feedback.consent.revokedBody")}
|
|
144
|
+
actionLabel={externalConsent ? t(language, "feedback.consent.revoke") : t(language, "feedback.consent.reenable")}
|
|
145
|
+
onAction={() => setExternalConsent(!externalConsent)}
|
|
146
|
+
/>
|
|
147
|
+
</section>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function VsCodeSyncStatus({ language }: { language: Language }) {
|
|
152
|
+
const bridge = useQuery({
|
|
153
|
+
queryKey: ["workspaceVscodeStatus"],
|
|
154
|
+
queryFn: latticeApi.workspaceVscodeStatus,
|
|
155
|
+
refetchInterval: 15000,
|
|
156
|
+
});
|
|
157
|
+
const index = useQuery({
|
|
158
|
+
queryKey: ["index"],
|
|
159
|
+
queryFn: latticeApi.indexStatus,
|
|
160
|
+
refetchInterval: 15000,
|
|
161
|
+
});
|
|
162
|
+
const data = bridge.data?.data as Record<string, unknown> | undefined;
|
|
163
|
+
const lastSeen = Number(data?.last_seen_ms || 0);
|
|
164
|
+
const connected = Boolean(data?.connected) || (lastSeen > 0 && Date.now() - lastSeen < 60000);
|
|
165
|
+
const indexData = index.data?.data as Record<string, unknown> | undefined;
|
|
166
|
+
const indexStatus = String(indexData?.status || indexData?.state || "");
|
|
167
|
+
const indexing = /index|build|running|pending/i.test(indexStatus);
|
|
168
|
+
const state = bridge.isLoading
|
|
169
|
+
? "checking"
|
|
170
|
+
: !connected
|
|
171
|
+
? "offline"
|
|
172
|
+
: indexing
|
|
173
|
+
? "indexing"
|
|
174
|
+
: "synced";
|
|
175
|
+
const labelKey = `shell.sync.${state}`;
|
|
176
|
+
const detail = connected
|
|
177
|
+
? t(language, "shell.sync.detail")
|
|
178
|
+
: t(language, "shell.sync.detailOffline");
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={`vscode-sync-status is-${state}`}
|
|
184
|
+
aria-label={`${t(language, "shell.sync.label")}: ${t(language, labelKey)}`}
|
|
185
|
+
title={`${t(language, "shell.sync.label")}: ${t(language, labelKey)}\n${detail}`}
|
|
186
|
+
onClick={() => {
|
|
187
|
+
window.location.hash = "/settings";
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<span className="vscode-sync-dot" aria-hidden="true" />
|
|
191
|
+
<span className="vscode-sync-copy">
|
|
192
|
+
<strong>{t(language, "shell.sync.label")}</strong>
|
|
193
|
+
<small>{t(language, labelKey)}</small>
|
|
194
|
+
</span>
|
|
195
|
+
</button>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
119
199
|
function PageLoader() {
|
|
120
200
|
return (
|
|
121
201
|
<div className="brain-shell-loader" role="status">
|
|
@@ -416,6 +416,12 @@ export const latticeApi = {
|
|
|
416
416
|
},
|
|
417
417
|
health: () => get("/health", {}),
|
|
418
418
|
workspaceOs: () => get("/workspace/os", { counts: {}, models: {}, workspace_registry: { workspaces: [] } }),
|
|
419
|
+
workspaceVscodeStatus: () => get("/workspace/vscode/status", {
|
|
420
|
+
connected: false,
|
|
421
|
+
last_seen_ms: 0,
|
|
422
|
+
status: "offline",
|
|
423
|
+
index_status: "unknown",
|
|
424
|
+
}),
|
|
419
425
|
indexStatus: () => get("/api/index/status", {}),
|
|
420
426
|
rebuildIndex: () => post("/api/index/rebuild", { full: false, include_nodes: true, include_chunks: true }, {}),
|
|
421
427
|
graph: () => get("/knowledge-graph/graph", { nodes: [], edges: [] }),
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ShieldCheck, Lock } from "lucide-react";
|
|
3
|
+
import { t, type Language } from "@/i18n";
|
|
4
|
+
import { useAppStore, type WorkspaceMode } from "@/store/appStore";
|
|
5
|
+
|
|
6
|
+
const MODES: WorkspaceMode[] = ["basic", "advanced", "admin"];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Role-aware admin entry point for the Brain shell.
|
|
10
|
+
*
|
|
11
|
+
* Mode is the access proxy used across the app (SystemPage gates the admin
|
|
12
|
+
* tab and AdminPanel on `mode`). When the user is in advanced/admin mode we
|
|
13
|
+
* surface a discoverable Admin Console button plus a compact mode switcher.
|
|
14
|
+
* In basic mode the console is hidden behind an explained affordance that
|
|
15
|
+
* promotes the user to admin mode rather than failing silently.
|
|
16
|
+
*/
|
|
17
|
+
export function AdminAccessGate({ language }: { language: Language }) {
|
|
18
|
+
const mode = useAppStore((state) => state.mode);
|
|
19
|
+
const setMode = useAppStore((state) => state.setMode);
|
|
20
|
+
const canSeeAdmin = mode === "advanced" || mode === "admin";
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="admin-access-gate">
|
|
24
|
+
{canSeeAdmin ? (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
className="admin-access-button"
|
|
28
|
+
title={t(language, "shell.admin.tooltip")}
|
|
29
|
+
aria-label={t(language, "shell.admin.open")}
|
|
30
|
+
onClick={() => {
|
|
31
|
+
window.location.hash = "/admin";
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
|
35
|
+
<span>{t(language, "shell.admin.label")}</span>
|
|
36
|
+
</button>
|
|
37
|
+
) : (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
className="admin-access-button is-locked"
|
|
41
|
+
title={t(language, "shell.admin.needsMode")}
|
|
42
|
+
aria-label={t(language, "shell.admin.needsMode")}
|
|
43
|
+
onClick={() => setMode("admin")}
|
|
44
|
+
>
|
|
45
|
+
<Lock className="h-3.5 w-3.5" aria-hidden="true" />
|
|
46
|
+
<span>{t(language, "shell.admin.enable")}</span>
|
|
47
|
+
</button>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<div
|
|
51
|
+
className="mode-switcher-mini"
|
|
52
|
+
role="group"
|
|
53
|
+
aria-label={t(language, "shell.mode.label")}
|
|
54
|
+
title={t(language, "shell.mode.info")}
|
|
55
|
+
>
|
|
56
|
+
{MODES.map((item) => (
|
|
57
|
+
<button
|
|
58
|
+
key={item}
|
|
59
|
+
type="button"
|
|
60
|
+
className={mode === item ? "is-active" : ""}
|
|
61
|
+
aria-pressed={mode === item}
|
|
62
|
+
onClick={() => setMode(item)}
|
|
63
|
+
>
|
|
64
|
+
{t(language, `shell.mode.${item}`)}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|