ltcai 4.0.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -24
- package/desktop/electron/main.cjs +44 -0
- package/docs/CHANGELOG.md +84 -0
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
- package/docs/V4_1_VALIDATION_REPORT.md +47 -0
- package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +97 -0
- package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +91 -0
- package/docs/V4_2_VALIDATION_REPORT.md +89 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +31 -26
- package/frontend/index.html +24 -0
- package/frontend/openapi.json +14436 -0
- package/frontend/src/App.tsx +184 -0
- package/frontend/src/api/client.ts +320 -0
- package/frontend/src/api/openapi.ts +16921 -0
- package/frontend/src/components/primitives.tsx +204 -0
- package/frontend/src/components/ui/badge.tsx +27 -0
- package/frontend/src/components/ui/button.tsx +37 -0
- package/frontend/src/components/ui/card.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +16 -0
- package/frontend/src/components/ui/textarea.tsx +16 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/main.tsx +23 -0
- package/frontend/src/pages/Act.tsx +245 -0
- package/frontend/src/pages/Ask.tsx +200 -0
- package/frontend/src/pages/Brain.tsx +267 -0
- package/frontend/src/pages/Capture.tsx +158 -0
- package/frontend/src/pages/Library.tsx +187 -0
- package/frontend/src/pages/System.tsx +378 -0
- package/frontend/src/routes.ts +85 -0
- package/frontend/src/store/appStore.ts +54 -0
- package/frontend/src/styles.css +107 -0
- package/kg_schema.py +1 -1
- package/knowledge_graph.py +4 -4
- package/lattice_brain/__init__.py +70 -0
- package/lattice_brain/_kg_common.py +1 -0
- package/lattice_brain/archive.py +133 -0
- package/lattice_brain/context.py +3 -0
- package/lattice_brain/conversations.py +3 -0
- package/lattice_brain/core.py +82 -0
- package/lattice_brain/discovery.py +1 -0
- package/lattice_brain/documents.py +1 -0
- package/lattice_brain/embeddings.py +82 -0
- package/lattice_brain/identity.py +13 -0
- package/lattice_brain/ingest.py +1 -0
- package/lattice_brain/memory.py +3 -0
- package/lattice_brain/network.py +1 -0
- package/lattice_brain/projection.py +1 -0
- package/lattice_brain/provenance.py +1 -0
- package/lattice_brain/retrieval.py +1 -0
- package/lattice_brain/schema.py +1 -0
- package/lattice_brain/storage/__init__.py +22 -0
- package/lattice_brain/storage/base.py +72 -0
- package/lattice_brain/storage/docker.py +105 -0
- package/lattice_brain/storage/factory.py +31 -0
- package/lattice_brain/storage/migration.py +190 -0
- package/lattice_brain/storage/postgres.py +123 -0
- package/lattice_brain/storage/sqlite.py +128 -0
- package/lattice_brain/store.py +3 -0
- package/lattice_brain/write_master.py +1 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/portability.py +69 -0
- package/latticeai/api/setup.py +5 -4
- package/latticeai/api/static_routes.py +4 -4
- package/latticeai/app_factory.py +17 -10
- package/latticeai/brain/__init__.py +6 -6
- package/latticeai/brain/_kg_common.py +1 -1
- package/latticeai/brain/network.py +1 -1
- package/latticeai/brain/retrieval.py +15 -0
- package/latticeai/brain/store.py +22 -6
- package/latticeai/core/config.py +8 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/kg_portability.py +82 -1
- package/package.json +55 -15
- package/scripts/build_frontend_assets.mjs +38 -0
- package/scripts/bump_version.py +4 -1
- package/scripts/export_openapi.py +31 -0
- package/scripts/lint_frontend.mjs +91 -0
- package/scripts/migrate_brain_storage.py +53 -0
- package/scripts/run_python.mjs +47 -0
- package/scripts/wheel_smoke.py +3 -0
- package/src-tauri/Cargo.lock +4833 -0
- package/src-tauri/Cargo.toml +19 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +7 -0
- package/src-tauri/src/main.rs +78 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/static/app/asset-manifest.json +32 -0
- package/static/app/assets/core-CwxXejkd.js +2 -0
- package/static/app/assets/core-CwxXejkd.js.map +1 -0
- package/static/app/assets/index-CDjiH_se.css +2 -0
- package/static/app/assets/index-C_HAkbAg.js +333 -0
- package/static/app/assets/index-C_HAkbAg.js.map +1 -0
- package/static/app/index.html +25 -0
- package/static/manifest.json +2 -2
- package/static/sw.js +4 -4
- package/scripts/build_v3_assets.mjs +0 -170
- package/scripts/lint_v3.mjs +0 -120
- package/static/v3/asset-manifest.json +0 -63
- package/static/v3/css/lattice.base.49deefb5.css +0 -128
- package/static/v3/css/lattice.base.css +0 -128
- package/static/v3/css/lattice.components.cde18231.css +0 -472
- package/static/v3/css/lattice.components.css +0 -472
- package/static/v3/css/lattice.shell.29d36d85.css +0 -452
- package/static/v3/css/lattice.shell.css +0 -452
- package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
- package/static/v3/css/lattice.tokens.css +0 -135
- package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
- package/static/v3/css/lattice.views.css +0 -360
- package/static/v3/index.html +0 -68
- package/static/v3/js/app.c5c80c46.js +0 -26
- package/static/v3/js/app.js +0 -26
- package/static/v3/js/core/api.ba0fbf14.js +0 -625
- package/static/v3/js/core/api.js +0 -625
- package/static/v3/js/core/components.f25b3b93.js +0 -230
- package/static/v3/js/core/components.js +0 -230
- package/static/v3/js/core/dom.a2773eb0.js +0 -148
- package/static/v3/js/core/dom.js +0 -148
- package/static/v3/js/core/i18n.880e1fec.js +0 -575
- package/static/v3/js/core/i18n.js +0 -575
- package/static/v3/js/core/router.584570f2.js +0 -37
- package/static/v3/js/core/router.js +0 -37
- package/static/v3/js/core/routes.37522821.js +0 -101
- package/static/v3/js/core/routes.js +0 -101
- package/static/v3/js/core/shell.e3f6bbfa.js +0 -420
- package/static/v3/js/core/shell.js +0 -420
- package/static/v3/js/core/store.7b2aa044.js +0 -123
- package/static/v3/js/core/store.js +0 -123
- package/static/v3/js/views/account.eff40715.js +0 -143
- package/static/v3/js/views/account.js +0 -143
- package/static/v3/js/views/activity.0d271ef9.js +0 -67
- package/static/v3/js/views/activity.js +0 -67
- package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
- package/static/v3/js/views/admin-audit.js +0 -185
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
- package/static/v3/js/views/admin-permissions.js +0 -177
- package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
- package/static/v3/js/views/admin-policies.js +0 -102
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
- package/static/v3/js/views/admin-private-vpc.js +0 -135
- package/static/v3/js/views/admin-security.07c66b72.js +0 -180
- package/static/v3/js/views/admin-security.js +0 -180
- package/static/v3/js/views/admin-users.f7ac7b43.js +0 -166
- package/static/v3/js/views/admin-users.js +0 -166
- package/static/v3/js/views/agents.17c5288d.js +0 -564
- package/static/v3/js/views/agents.js +0 -564
- package/static/v3/js/views/chat.e250e2cc.js +0 -624
- package/static/v3/js/views/chat.js +0 -624
- package/static/v3/js/views/files.adad14c1.js +0 -365
- package/static/v3/js/views/files.js +0 -365
- package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
- package/static/v3/js/views/graph-canvas.js +0 -509
- package/static/v3/js/views/home.24f8b8ae.js +0 -200
- package/static/v3/js/views/home.js +0 -200
- package/static/v3/js/views/hooks.37895880.js +0 -220
- package/static/v3/js/views/hooks.js +0 -220
- package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
- package/static/v3/js/views/hybrid-search.js +0 -194
- package/static/v3/js/views/knowledge-graph.4d09c537.js +0 -529
- package/static/v3/js/views/knowledge-graph.js +0 -529
- package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
- package/static/v3/js/views/marketplace.js +0 -141
- package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
- package/static/v3/js/views/mcp.js +0 -114
- package/static/v3/js/views/memory.4ebdf474.js +0 -147
- package/static/v3/js/views/memory.js +0 -147
- package/static/v3/js/views/models.a1ffa147.js +0 -256
- package/static/v3/js/views/models.js +0 -256
- package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
- package/static/v3/js/views/my-computer.js +0 -463
- package/static/v3/js/views/network.52a4f181.js +0 -97
- package/static/v3/js/views/network.js +0 -97
- package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
- package/static/v3/js/views/pipeline.js +0 -157
- package/static/v3/js/views/planning.4876fd77.js +0 -174
- package/static/v3/js/views/planning.js +0 -174
- package/static/v3/js/views/runs.b63b2afa.js +0 -144
- package/static/v3/js/views/runs.js +0 -144
- package/static/v3/js/views/settings.b7140634.js +0 -317
- package/static/v3/js/views/settings.js +0 -317
- package/static/v3/js/views/skills.c6c2f965.js +0 -109
- package/static/v3/js/views/skills.js +0 -109
- package/static/v3/js/views/snapshots.6f5db095.js +0 -135
- package/static/v3/js/views/snapshots.js +0 -135
- package/static/v3/js/views/tools.e4f11276.js +0 -108
- package/static/v3/js/views/tools.js +0 -108
- package/static/v3/js/views/workflows.7752225a.js +0 -213
- package/static/v3/js/views/workflows.js +0 -213
- package/static/v3/js/views/workspace-admin.c466029b.js +0 -156
- package/static/v3/js/views/workspace-admin.js +0 -156
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { ImagePlus, MessageSquare, Send, Trash2 } from "lucide-react";
|
|
4
|
+
import { latticeApi } from "@/api/client";
|
|
5
|
+
import { DataPanel, EmptyState, EntityList, JsonView, SourceBadge } from "@/components/primitives";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
9
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
10
|
+
import { asArray } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
type Msg = { role?: string; content?: string; timestamp?: string };
|
|
13
|
+
|
|
14
|
+
function fileToDataUrl(file: File) {
|
|
15
|
+
return new Promise<string>((resolve, reject) => {
|
|
16
|
+
const reader = new FileReader();
|
|
17
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
18
|
+
reader.onerror = () => reject(reader.error);
|
|
19
|
+
reader.readAsDataURL(file);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AskPage() {
|
|
24
|
+
const qc = useQueryClient();
|
|
25
|
+
const history = useQuery({ queryKey: ["chatHistory"], queryFn: latticeApi.chatHistory });
|
|
26
|
+
const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
|
|
27
|
+
const [conversationId, setConversationId] = React.useState<string | null>(null);
|
|
28
|
+
const conversation = useQuery({
|
|
29
|
+
queryKey: ["conversation", conversationId],
|
|
30
|
+
queryFn: () => latticeApi.conversation(conversationId || ""),
|
|
31
|
+
enabled: !!conversationId,
|
|
32
|
+
});
|
|
33
|
+
const [messages, setMessages] = React.useState<Msg[]>([]);
|
|
34
|
+
const [draft, setDraft] = React.useState("");
|
|
35
|
+
const [imageData, setImageData] = React.useState<string | null>(null);
|
|
36
|
+
const [trace, setTrace] = React.useState<unknown>(null);
|
|
37
|
+
const [streaming, setStreaming] = React.useState(false);
|
|
38
|
+
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
if (conversation.data?.ok) setMessages(asArray<Msg>((conversation.data.data as Record<string, unknown>).messages || conversation.data.data));
|
|
41
|
+
}, [conversation.data]);
|
|
42
|
+
|
|
43
|
+
const send = async () => {
|
|
44
|
+
const message = draft.trim();
|
|
45
|
+
if (!message || streaming) return;
|
|
46
|
+
setDraft("");
|
|
47
|
+
setMessages((items) => [...items, { role: "user", content: message }, { role: "assistant", content: "" }]);
|
|
48
|
+
setStreaming(true);
|
|
49
|
+
try {
|
|
50
|
+
const result = await latticeApi.streamChat(
|
|
51
|
+
{ message, conversation_id: conversationId || undefined, image_data: imageData || undefined },
|
|
52
|
+
{
|
|
53
|
+
onChunk: (_delta, fullText) => {
|
|
54
|
+
setMessages((items) => {
|
|
55
|
+
const next = [...items];
|
|
56
|
+
const last = next[next.length - 1] || { role: "assistant" };
|
|
57
|
+
next[next.length - 1] = { ...last, role: "assistant", content: fullText };
|
|
58
|
+
return next;
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
onTrace: setTrace,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
if (result.error) {
|
|
65
|
+
setMessages((items) => {
|
|
66
|
+
const next = [...items];
|
|
67
|
+
next[next.length - 1] = { role: "assistant", content: `Unavailable: ${result.error}` };
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
setStreaming(false);
|
|
73
|
+
setImageData(null);
|
|
74
|
+
await qc.invalidateQueries({ queryKey: ["chatHistory"] });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const deleteMutation = useMutation({
|
|
79
|
+
mutationFn: (id: string) => latticeApi.deleteConversation(id),
|
|
80
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["chatHistory"] }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="grid min-h-[calc(100vh-7rem)] gap-4 xl:grid-cols-[18rem_minmax(0,1fr)_22rem]">
|
|
85
|
+
<Card className="overflow-hidden">
|
|
86
|
+
<CardHeader>
|
|
87
|
+
<CardTitle className="flex items-center gap-2"><MessageSquare className="h-4 w-4" /> Conversations</CardTitle>
|
|
88
|
+
<CardDescription>Durable history from the backend conversation store.</CardDescription>
|
|
89
|
+
</CardHeader>
|
|
90
|
+
<CardContent className="space-y-2">
|
|
91
|
+
<SourceBadge result={history.data} />
|
|
92
|
+
{asArray<Record<string, unknown>>(history.data?.data).length ? asArray<Record<string, unknown>>(history.data?.data).map((item) => (
|
|
93
|
+
<button
|
|
94
|
+
key={String(item.id)}
|
|
95
|
+
onClick={() => setConversationId(String(item.id))}
|
|
96
|
+
className="block w-full rounded-md border border-border bg-background p-3 text-left text-sm transition hover:bg-muted"
|
|
97
|
+
>
|
|
98
|
+
<div className="font-medium">{String(item.title || item.id)}</div>
|
|
99
|
+
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
100
|
+
<span>{String(item.updated_at || "")}</span>
|
|
101
|
+
<Trash2
|
|
102
|
+
className="h-3.5 w-3.5"
|
|
103
|
+
onClick={(e) => {
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
deleteMutation.mutate(String(item.id));
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</button>
|
|
110
|
+
)) : <EmptyState title="No conversations" detail="Start a new exchange or sign in to load history." />}
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
|
|
114
|
+
<section className="flex min-h-[40rem] flex-col rounded-lg border border-border bg-card">
|
|
115
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border p-4">
|
|
116
|
+
<div>
|
|
117
|
+
<h1 className="text-xl font-semibold">Ask</h1>
|
|
118
|
+
<p className="text-sm text-muted-foreground">Streams through `/chat`; no local answer is fabricated when the model is unavailable.</p>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
121
|
+
<Badge variant="muted">{String((models.data?.data as Record<string, unknown>)?.current || "no model loaded")}</Badge>
|
|
122
|
+
<SourceBadge result={models.data} />
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="scrollbar-thin flex-1 space-y-3 overflow-auto p-4">
|
|
126
|
+
{messages.length ? messages.map((msg, index) => (
|
|
127
|
+
<div key={index} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
|
128
|
+
<div className={`max-w-[78%] rounded-lg border p-3 text-sm ${msg.role === "user" ? "border-primary/30 bg-primary/15" : "border-border bg-background"}`}>
|
|
129
|
+
<div className="mb-1 text-xs uppercase text-muted-foreground">{msg.role || "message"}</div>
|
|
130
|
+
<div className="whitespace-pre-wrap">{msg.content}</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)) : <EmptyState title="Ready" detail="Ask a question once the backend and model are available." />}
|
|
134
|
+
</div>
|
|
135
|
+
<div className="border-t border-border p-4">
|
|
136
|
+
{imageData ? <Badge variant="success" className="mb-2">image attached</Badge> : null}
|
|
137
|
+
<Textarea
|
|
138
|
+
value={draft}
|
|
139
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
140
|
+
onKeyDown={(e) => {
|
|
141
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
void send();
|
|
144
|
+
}
|
|
145
|
+
}}
|
|
146
|
+
placeholder="Ask the brain..."
|
|
147
|
+
/>
|
|
148
|
+
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
|
149
|
+
<label className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted">
|
|
150
|
+
<ImagePlus className="h-4 w-4" />
|
|
151
|
+
Attach image
|
|
152
|
+
<input
|
|
153
|
+
type="file"
|
|
154
|
+
accept="image/*"
|
|
155
|
+
className="sr-only"
|
|
156
|
+
onChange={async (e) => {
|
|
157
|
+
const file = e.target.files?.[0];
|
|
158
|
+
if (file) setImageData(await fileToDataUrl(file));
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
</label>
|
|
162
|
+
<Button disabled={!draft.trim() || streaming} onClick={() => void send()}>
|
|
163
|
+
<Send className="h-4 w-4" /> Send
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<aside className="space-y-4">
|
|
170
|
+
<ContextPreview question={draft || [...messages].reverse().find((m: Msg) => m.role === "user")?.content || ""} trace={trace} />
|
|
171
|
+
</aside>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ContextPreview({ question, trace }: { question: string; trace: unknown }) {
|
|
177
|
+
const hybrid = useQuery({
|
|
178
|
+
queryKey: ["askHybrid", question],
|
|
179
|
+
queryFn: () => latticeApi.hybridSearch(question),
|
|
180
|
+
enabled: question.trim().length > 2,
|
|
181
|
+
});
|
|
182
|
+
const graph = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
|
|
183
|
+
return (
|
|
184
|
+
<>
|
|
185
|
+
<DataPanel title="Retrieval preview" result={hybrid.data}>
|
|
186
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).matches || data} titleKey="title" metaKey="type" limit={5} />}
|
|
187
|
+
</DataPanel>
|
|
188
|
+
<DataPanel title="Graph context" result={graph.data}>
|
|
189
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).nodes} titleKey="title" metaKey="type" limit={5} />}
|
|
190
|
+
</DataPanel>
|
|
191
|
+
<Card>
|
|
192
|
+
<CardHeader>
|
|
193
|
+
<CardTitle>Why this context</CardTitle>
|
|
194
|
+
<CardDescription>Trace emitted by `/chat` when the backend includes it.</CardDescription>
|
|
195
|
+
</CardHeader>
|
|
196
|
+
<CardContent>{trace ? <JsonView value={trace} /> : <EmptyState title="No trace yet" />}</CardContent>
|
|
197
|
+
</Card>
|
|
198
|
+
</>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import cytoscape, { Core } from "cytoscape";
|
|
4
|
+
import { BrainCircuit, DatabaseBackup, Network, Search, Sparkles } from "lucide-react";
|
|
5
|
+
import { latticeApi } from "@/api/client";
|
|
6
|
+
import { ActionButton, DataPanel, EntityList, JsonView, LoadingPanel, StatGrid, Tabs } from "@/components/primitives";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
12
|
+
import { asArray, pct } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
type BrainTab = "overview" | "graph" | "search" | "memory" | "provenance" | "portability";
|
|
15
|
+
|
|
16
|
+
const tabs: Array<{ id: BrainTab; label: string }> = [
|
|
17
|
+
{ id: "overview", label: "Overview" },
|
|
18
|
+
{ id: "graph", label: "Graph" },
|
|
19
|
+
{ id: "search", label: "Search" },
|
|
20
|
+
{ id: "memory", label: "Memory" },
|
|
21
|
+
{ id: "provenance", label: "Provenance" },
|
|
22
|
+
{ id: "portability", label: "Portability" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function graphElements(data: unknown) {
|
|
26
|
+
const graph = data as { nodes?: Array<Record<string, unknown>>; edges?: Array<Record<string, unknown>> };
|
|
27
|
+
const nodes = asArray<Record<string, unknown>>(graph.nodes).slice(0, 160).map((node) => ({
|
|
28
|
+
data: {
|
|
29
|
+
id: String(node.id || node.node_id || node.title),
|
|
30
|
+
label: String(node.title || node.label || node.id || "Node"),
|
|
31
|
+
type: String(node.type || "Node"),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
const nodeIds = new Set(nodes.map((node) => node.data.id));
|
|
35
|
+
const edges = asArray<Record<string, unknown>>(graph.edges).slice(0, 260).flatMap((edge, index) => {
|
|
36
|
+
const source = String(edge.from || edge.source || edge.source_id || "");
|
|
37
|
+
const target = String(edge.to || edge.target || edge.target_id || "");
|
|
38
|
+
if (!nodeIds.has(source) || !nodeIds.has(target)) return [];
|
|
39
|
+
return [{
|
|
40
|
+
data: {
|
|
41
|
+
id: String(edge.id || `edge-${index}`),
|
|
42
|
+
source,
|
|
43
|
+
target,
|
|
44
|
+
label: String(edge.type || edge.label || ""),
|
|
45
|
+
},
|
|
46
|
+
}];
|
|
47
|
+
});
|
|
48
|
+
return [...nodes, ...edges];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CytoscapeGraph({ data }: { data: unknown }) {
|
|
52
|
+
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
53
|
+
const cyRef = React.useRef<Core | null>(null);
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (!hostRef.current) return;
|
|
56
|
+
const elements = graphElements(data);
|
|
57
|
+
cyRef.current?.destroy();
|
|
58
|
+
cyRef.current = cytoscape({
|
|
59
|
+
container: hostRef.current,
|
|
60
|
+
elements,
|
|
61
|
+
style: [
|
|
62
|
+
{
|
|
63
|
+
selector: "node",
|
|
64
|
+
style: {
|
|
65
|
+
"background-color": "#21c7bd",
|
|
66
|
+
"border-color": "#88fff5",
|
|
67
|
+
"border-width": 1,
|
|
68
|
+
color: "#f7ffff",
|
|
69
|
+
label: "data(label)",
|
|
70
|
+
"font-size": 9,
|
|
71
|
+
"text-outline-color": "#071012",
|
|
72
|
+
"text-outline-width": 2,
|
|
73
|
+
width: 22,
|
|
74
|
+
height: 22,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
selector: "edge",
|
|
79
|
+
style: {
|
|
80
|
+
width: 1.2,
|
|
81
|
+
"line-color": "#6b7893",
|
|
82
|
+
"target-arrow-shape": "triangle",
|
|
83
|
+
"target-arrow-color": "#6b7893",
|
|
84
|
+
"curve-style": "bezier",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
layout: { name: "cose", animate: false, idealEdgeLength: 110, nodeRepulsion: 4500 },
|
|
89
|
+
wheelSensitivity: 0.25,
|
|
90
|
+
});
|
|
91
|
+
return () => cyRef.current?.destroy();
|
|
92
|
+
}, [data]);
|
|
93
|
+
return <div ref={hostRef} data-testid="brain-cytoscape" className="h-[520px] w-full overflow-hidden rounded-lg border border-border bg-background brain-grid" />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function BrainPage({ initialTab }: { initialTab?: string }) {
|
|
97
|
+
const [tab, setTab] = React.useState<BrainTab>((initialTab as BrainTab) || "graph");
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
if (initialTab && tabs.some((item) => item.id === initialTab)) setTab(initialTab as BrainTab);
|
|
100
|
+
}, [initialTab]);
|
|
101
|
+
const graph = useQuery({ queryKey: ["graph"], queryFn: latticeApi.graph });
|
|
102
|
+
const stats = useQuery({ queryKey: ["graphStats"], queryFn: latticeApi.graphStats });
|
|
103
|
+
const index = useQuery({ queryKey: ["index"], queryFn: latticeApi.indexStatus });
|
|
104
|
+
const coverage = useQuery({ queryKey: ["coverage"], queryFn: latticeApi.graphCoverage });
|
|
105
|
+
const provenance = useQuery({ queryKey: ["provenance"], queryFn: () => latticeApi.graphProvenance(50) });
|
|
106
|
+
const memory = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-4">
|
|
110
|
+
<header className="grid gap-4 xl:grid-cols-[1.4fr_0.6fr]">
|
|
111
|
+
<div>
|
|
112
|
+
<div className="flex items-center gap-2 text-sm text-primary"><BrainCircuit className="h-4 w-4" /> Graph-first Digital Brain</div>
|
|
113
|
+
<h1 className="mt-2 text-3xl font-semibold tracking-normal">Brain</h1>
|
|
114
|
+
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
|
115
|
+
The visible knowledge substrate: graph, memory, provenance, retrieval, and local portability. Empty states come from API availability, not canned data.
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
119
|
+
<div className="text-xs uppercase text-muted-foreground">Provenance coverage</div>
|
|
120
|
+
<div className="mt-2 text-3xl font-semibold">{pct((coverage.data?.data as Record<string, unknown>)?.coverage_ratio)}</div>
|
|
121
|
+
<div className="mt-2 text-sm text-muted-foreground">Source: {coverage.data?.source || "loading"}</div>
|
|
122
|
+
</div>
|
|
123
|
+
</header>
|
|
124
|
+
<Tabs tabs={tabs} value={tab} onChange={(id) => setTab(id as BrainTab)} />
|
|
125
|
+
|
|
126
|
+
{tab === "overview" ? (
|
|
127
|
+
<div className="grid gap-4 xl:grid-cols-2">
|
|
128
|
+
<DataPanel title="Brain status" result={stats.data}>
|
|
129
|
+
{(data) => <StatGrid stats={[
|
|
130
|
+
{ label: "Nodes", value: (data as Record<string, unknown>).total_nodes ?? 0 },
|
|
131
|
+
{ label: "Edges", value: (data as Record<string, unknown>).total_edges ?? 0 },
|
|
132
|
+
{ label: "Node types", value: Object.keys(((data as Record<string, unknown>).nodes as Record<string, unknown>) || {}).length },
|
|
133
|
+
{ label: "Edge types", value: Object.keys(((data as Record<string, unknown>).edges as Record<string, unknown>) || {}).length },
|
|
134
|
+
]} />}
|
|
135
|
+
</DataPanel>
|
|
136
|
+
<DataPanel title="Retrieval index" result={index.data}>
|
|
137
|
+
{(data) => <JsonView value={data} />}
|
|
138
|
+
</DataPanel>
|
|
139
|
+
<DataPanel title="Memory tiers" result={memory.data}>
|
|
140
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).tiers || (data as Record<string, unknown>).sources} titleKey="name" metaKey="health" />}
|
|
141
|
+
</DataPanel>
|
|
142
|
+
<DataPanel title="Recent provenance" result={provenance.data}>
|
|
143
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" />}
|
|
144
|
+
</DataPanel>
|
|
145
|
+
</div>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
{tab === "graph" ? (
|
|
149
|
+
graph.isLoading ? <LoadingPanel title="Knowledge graph" /> : (
|
|
150
|
+
<DataPanel title="Knowledge graph" description="Cytoscape.js explorer backed by /knowledge-graph/graph." result={graph.data}>
|
|
151
|
+
{(data) => <CytoscapeGraph data={data} />}
|
|
152
|
+
</DataPanel>
|
|
153
|
+
)
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
156
|
+
{tab === "search" ? <HybridSearch /> : null}
|
|
157
|
+
{tab === "memory" ? <MemoryPanel /> : null}
|
|
158
|
+
{tab === "provenance" ? <ProvenancePanel /> : null}
|
|
159
|
+
{tab === "portability" ? <PortabilityPanel /> : null}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function HybridSearch() {
|
|
165
|
+
const [query, setQuery] = React.useState("lattice brain");
|
|
166
|
+
const search = useMutation({ mutationFn: () => latticeApi.hybridSearch(query) });
|
|
167
|
+
return (
|
|
168
|
+
<Card>
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<CardTitle className="flex items-center gap-2"><Search className="h-4 w-4" /> Hybrid search</CardTitle>
|
|
171
|
+
<CardDescription>Calls the backend fused search endpoint and renders per-result source scores when returned.</CardDescription>
|
|
172
|
+
</CardHeader>
|
|
173
|
+
<CardContent className="space-y-3">
|
|
174
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
175
|
+
<Input placeholder="lattice brain" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && search.mutate()} />
|
|
176
|
+
<Button onClick={() => search.mutate()} disabled={!query.trim() || search.isPending}>Search</Button>
|
|
177
|
+
</div>
|
|
178
|
+
{search.data ? (
|
|
179
|
+
<DataPanel title="Results" result={search.data}>
|
|
180
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).matches || data} titleKey="title" metaKey="type" limit={12} />}
|
|
181
|
+
</DataPanel>
|
|
182
|
+
) : null}
|
|
183
|
+
</CardContent>
|
|
184
|
+
</Card>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function MemoryPanel() {
|
|
189
|
+
const [query, setQuery] = React.useState("");
|
|
190
|
+
const manager = useQuery({ queryKey: ["memoryManager"], queryFn: latticeApi.memoryManager });
|
|
191
|
+
const recall = useMutation({ mutationFn: () => latticeApi.memoryRecall(query, 25) });
|
|
192
|
+
return (
|
|
193
|
+
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
|
194
|
+
<DataPanel title="Memory manager" result={manager.data}>
|
|
195
|
+
{(data) => <JsonView value={data} />}
|
|
196
|
+
</DataPanel>
|
|
197
|
+
<Card>
|
|
198
|
+
<CardHeader>
|
|
199
|
+
<CardTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> Recall</CardTitle>
|
|
200
|
+
<CardDescription>Searches the real memory recall endpoint.</CardDescription>
|
|
201
|
+
</CardHeader>
|
|
202
|
+
<CardContent className="space-y-3">
|
|
203
|
+
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Recall memories about..." />
|
|
204
|
+
<div className="flex gap-2">
|
|
205
|
+
<Button disabled={!query.trim() || recall.isPending} onClick={() => recall.mutate()}>Recall</Button>
|
|
206
|
+
<ActionButton label="Compact" action={() => latticeApi.memoryCompact()} />
|
|
207
|
+
<ActionButton label="Rebuild vector" action={() => latticeApi.memoryRebuild()} />
|
|
208
|
+
</div>
|
|
209
|
+
{recall.data ? <JsonView value={recall.data.data} /> : null}
|
|
210
|
+
</CardContent>
|
|
211
|
+
</Card>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ProvenancePanel() {
|
|
217
|
+
const provenance = useQuery({ queryKey: ["provenance"], queryFn: () => latticeApi.graphProvenance(80) });
|
|
218
|
+
const coverage = useQuery({ queryKey: ["coverage"], queryFn: latticeApi.graphCoverage });
|
|
219
|
+
return (
|
|
220
|
+
<div className="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
|
|
221
|
+
<DataPanel title="Coverage" result={coverage.data}>
|
|
222
|
+
{(data) => <JsonView value={data} />}
|
|
223
|
+
</DataPanel>
|
|
224
|
+
<DataPanel title="Recent ingestion provenance" result={provenance.data}>
|
|
225
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).items || data} titleKey="source" metaKey="source_type" limit={14} />}
|
|
226
|
+
</DataPanel>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function PortabilityPanel() {
|
|
232
|
+
const qc = useQueryClient();
|
|
233
|
+
const [artifact, setArtifact] = React.useState("");
|
|
234
|
+
const port = useQuery({ queryKey: ["portability"], queryFn: latticeApi.graphPortability });
|
|
235
|
+
const importMutation = useMutation({
|
|
236
|
+
mutationFn: () => latticeApi.graphImport(JSON.parse(artifact), true),
|
|
237
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["portability"] }),
|
|
238
|
+
});
|
|
239
|
+
return (
|
|
240
|
+
<div className="grid gap-4 xl:grid-cols-2">
|
|
241
|
+
<DataPanel title="Portability status" result={port.data}>
|
|
242
|
+
{(data) => <JsonView value={data} />}
|
|
243
|
+
</DataPanel>
|
|
244
|
+
<Card>
|
|
245
|
+
<CardHeader>
|
|
246
|
+
<CardTitle className="flex items-center gap-2"><DatabaseBackup className="h-4 w-4" /> Export, backup, import</CardTitle>
|
|
247
|
+
<CardDescription>Every control calls a real portability endpoint. Import is dry-run by default from pasted JSON.</CardDescription>
|
|
248
|
+
</CardHeader>
|
|
249
|
+
<CardContent className="space-y-3">
|
|
250
|
+
<div className="flex flex-wrap gap-2">
|
|
251
|
+
<ActionButton label="Export graph JSON" action={() => latticeApi.graphExport()} />
|
|
252
|
+
<ActionButton label="Create backup" action={() => latticeApi.graphBackup()} />
|
|
253
|
+
</div>
|
|
254
|
+
<Textarea value={artifact} onChange={(e) => setArtifact(e.target.value)} placeholder="Paste an export artifact JSON for dry-run import" />
|
|
255
|
+
<Button
|
|
256
|
+
variant="outline"
|
|
257
|
+
disabled={!artifact.trim() || importMutation.isPending}
|
|
258
|
+
onClick={() => importMutation.mutate()}
|
|
259
|
+
>
|
|
260
|
+
Dry-run import
|
|
261
|
+
</Button>
|
|
262
|
+
{importMutation.data ? <JsonView value={importMutation.data.data} /> : null}
|
|
263
|
+
</CardContent>
|
|
264
|
+
</Card>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { FolderPlus, Globe2, HardDrive, Upload } from "lucide-react";
|
|
4
|
+
import { latticeApi } from "@/api/client";
|
|
5
|
+
import { ActionButton, DataPanel, EntityList, JsonView, Tabs } from "@/components/primitives";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { asArray } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
type CaptureTab = "files" | "local" | "browser" | "pipeline";
|
|
12
|
+
|
|
13
|
+
const tabs: Array<{ id: CaptureTab; label: string }> = [
|
|
14
|
+
{ id: "files", label: "Files" },
|
|
15
|
+
{ id: "local", label: "Local folders" },
|
|
16
|
+
{ id: "browser", label: "Web capture" },
|
|
17
|
+
{ id: "pipeline", label: "Pipeline" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function CapturePage({ initialTab }: { initialTab?: string }) {
|
|
21
|
+
const [tab, setTab] = React.useState<CaptureTab>((initialTab as CaptureTab) || "files");
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
if (initialTab === "pipeline" || initialTab === "local" || initialTab === "files") setTab(initialTab);
|
|
24
|
+
}, [initialTab]);
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-4">
|
|
27
|
+
<header>
|
|
28
|
+
<div className="flex items-center gap-2 text-sm text-primary"><Upload className="h-4 w-4" /> One ingestion door</div>
|
|
29
|
+
<h1 className="mt-2 text-3xl font-semibold">Capture</h1>
|
|
30
|
+
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">Documents, folders, and URLs enter the brain through existing ingestion endpoints with provenance.</p>
|
|
31
|
+
</header>
|
|
32
|
+
<Tabs tabs={tabs} value={tab} onChange={(id) => setTab(id as CaptureTab)} />
|
|
33
|
+
{tab === "files" ? <FilesPanel /> : null}
|
|
34
|
+
{tab === "local" ? <LocalPanel /> : null}
|
|
35
|
+
{tab === "browser" ? <BrowserPanel /> : null}
|
|
36
|
+
{tab === "pipeline" ? <PipelinePanel /> : null}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FilesPanel() {
|
|
42
|
+
const qc = useQueryClient();
|
|
43
|
+
const docs = useQuery({ queryKey: ["documents"], queryFn: () => latticeApi.documents(200) });
|
|
44
|
+
const upload = useMutation({
|
|
45
|
+
mutationFn: (files: FileList) => Promise.all(Array.from(files).map((file) => latticeApi.uploadDocument(file))),
|
|
46
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
|
|
47
|
+
});
|
|
48
|
+
return (
|
|
49
|
+
<div className="grid gap-4 xl:grid-cols-[0.75fr_1.25fr]">
|
|
50
|
+
<Card>
|
|
51
|
+
<CardHeader>
|
|
52
|
+
<CardTitle className="flex items-center gap-2"><Upload className="h-4 w-4" /> Upload documents</CardTitle>
|
|
53
|
+
<CardDescription>Multipart upload to `/upload/document`; accepted files are parsed and indexed by the backend.</CardDescription>
|
|
54
|
+
</CardHeader>
|
|
55
|
+
<CardContent>
|
|
56
|
+
<label className="flex min-h-44 cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/30 p-5 text-center">
|
|
57
|
+
<Upload className="h-7 w-7 text-primary" />
|
|
58
|
+
<span className="font-medium">Choose files</span>
|
|
59
|
+
<span className="text-sm text-muted-foreground">PDF, DOCX, XLSX, PPTX, TXT, MD, CSV according to backend policy.</span>
|
|
60
|
+
<input type="file" multiple className="sr-only" onChange={(e) => e.target.files && upload.mutate(e.target.files)} />
|
|
61
|
+
</label>
|
|
62
|
+
{upload.data ? <JsonView value={upload.data.map((item) => item.data)} /> : null}
|
|
63
|
+
</CardContent>
|
|
64
|
+
</Card>
|
|
65
|
+
<DataPanel title="Uploaded documents" result={docs.data}>
|
|
66
|
+
{(data) => <EntityList items={(data as Record<string, unknown>).documents || data} titleKey="filename" metaKey="ingest_state" limit={12} />}
|
|
67
|
+
</DataPanel>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function LocalPanel() {
|
|
73
|
+
const qc = useQueryClient();
|
|
74
|
+
const [path, setPath] = React.useState("");
|
|
75
|
+
const local = useQuery({ queryKey: ["localSources"], queryFn: latticeApi.localSources });
|
|
76
|
+
const agent = useQuery({ queryKey: ["localAgent"], queryFn: latticeApi.localAgent });
|
|
77
|
+
const connect = useMutation({
|
|
78
|
+
mutationFn: () => latticeApi.connectFolder(path),
|
|
79
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["localSources"] }),
|
|
80
|
+
});
|
|
81
|
+
return (
|
|
82
|
+
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
|
83
|
+
<Card>
|
|
84
|
+
<CardHeader>
|
|
85
|
+
<CardTitle className="flex items-center gap-2"><FolderPlus className="h-4 w-4" /> Connect folder</CardTitle>
|
|
86
|
+
<CardDescription>The click is explicit consent; the backend still enforces its permission workflow.</CardDescription>
|
|
87
|
+
</CardHeader>
|
|
88
|
+
<CardContent className="space-y-3">
|
|
89
|
+
<Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="/Users/me/Documents/project" />
|
|
90
|
+
<Button disabled={!path.trim() || connect.isPending} onClick={() => connect.mutate()}>Connect and watch</Button>
|
|
91
|
+
{connect.data ? <JsonView value={connect.data.data || connect.data.error} /> : null}
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
<DataPanel title="Connected sources" result={local.data}>
|
|
95
|
+
{(data) => (
|
|
96
|
+
<div className="space-y-3">
|
|
97
|
+
<EntityList items={(data as Record<string, unknown>).sources} titleKey="path" metaKey="status" />
|
|
98
|
+
{asArray<Record<string, unknown>>((data as Record<string, unknown>).sources).map((source) => (
|
|
99
|
+
<ActionButton
|
|
100
|
+
key={String(source.id || source.source_id || source.path)}
|
|
101
|
+
label={`Stop ${String(source.path || source.id || "source")}`}
|
|
102
|
+
action={() => latticeApi.localWatchStop(String(source.id || source.source_id))}
|
|
103
|
+
invalidate={["localSources"]}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</DataPanel>
|
|
109
|
+
<DataPanel title="Local runtime probe" result={agent.data} className="xl:col-span-2">
|
|
110
|
+
{(data) => <JsonView value={data} />}
|
|
111
|
+
</DataPanel>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function BrowserPanel() {
|
|
117
|
+
const [url, setUrl] = React.useState("");
|
|
118
|
+
const read = useMutation({ mutationFn: () => latticeApi.browserReadUrl(url) });
|
|
119
|
+
return (
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<CardTitle className="flex items-center gap-2"><Globe2 className="h-4 w-4" /> URL capture</CardTitle>
|
|
123
|
+
<CardDescription>Fetches a URL locally through `/api/browser/read-url` and ingests the content with provenance.</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent className="space-y-3">
|
|
126
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
127
|
+
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/article" />
|
|
128
|
+
<Button disabled={!url.trim() || read.isPending} onClick={() => read.mutate()}>Capture URL</Button>
|
|
129
|
+
</div>
|
|
130
|
+
{read.data ? <JsonView value={read.data.data || read.data.error} /> : null}
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function PipelinePanel() {
|
|
137
|
+
const index = useQuery({ queryKey: ["index"], queryFn: latticeApi.indexStatus });
|
|
138
|
+
const stats = useQuery({ queryKey: ["graphStats"], queryFn: latticeApi.graphStats });
|
|
139
|
+
return (
|
|
140
|
+
<div className="grid gap-4 xl:grid-cols-2">
|
|
141
|
+
<DataPanel title="Index pipeline" result={index.data}>
|
|
142
|
+
{(data) => <JsonView value={data} />}
|
|
143
|
+
</DataPanel>
|
|
144
|
+
<DataPanel title="Graph totals" result={stats.data}>
|
|
145
|
+
{(data) => <JsonView value={data} />}
|
|
146
|
+
</DataPanel>
|
|
147
|
+
<Card className="xl:col-span-2">
|
|
148
|
+
<CardHeader>
|
|
149
|
+
<CardTitle className="flex items-center gap-2"><HardDrive className="h-4 w-4" /> Rebuild controls</CardTitle>
|
|
150
|
+
<CardDescription>Rebuild calls the existing index endpoint. No background work is implied unless the API accepts it.</CardDescription>
|
|
151
|
+
</CardHeader>
|
|
152
|
+
<CardContent>
|
|
153
|
+
<ActionButton label="Rebuild retrieval index" action={() => latticeApi.rebuildIndex()} invalidate={["index"]} />
|
|
154
|
+
</CardContent>
|
|
155
|
+
</Card>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|