ltcai 4.4.0 → 4.5.1
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 +46 -18
- package/docs/CHANGELOG.md +85 -0
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
- package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
- package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
- package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
- package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
- package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
- package/docs/V4_5_1_UX_REPORT.md +45 -0
- package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
- package/docs/architecture.md +8 -4
- package/frontend/src/App.tsx +152 -91
- package/frontend/src/api/client.ts +83 -1
- package/frontend/src/components/FirstRunGuide.tsx +99 -0
- package/frontend/src/components/primitives.tsx +131 -25
- package/frontend/src/components/ui/badge.tsx +2 -2
- package/frontend/src/components/ui/button.tsx +7 -7
- package/frontend/src/components/ui/card.tsx +5 -5
- package/frontend/src/components/ui/input.tsx +1 -1
- package/frontend/src/components/ui/textarea.tsx +1 -1
- package/frontend/src/pages/Act.tsx +58 -28
- package/frontend/src/pages/Ask.tsx +51 -19
- package/frontend/src/pages/Brain.tsx +60 -42
- package/frontend/src/pages/Capture.tsx +24 -24
- package/frontend/src/pages/Library.tsx +222 -32
- package/frontend/src/pages/System.tsx +56 -34
- package/frontend/src/routes.ts +15 -13
- package/frontend/src/store/appStore.ts +8 -1
- package/frontend/src/styles.css +666 -36
- 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/models.py +107 -18
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/model_compat.py +250 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +136 -32
- package/latticeai/services/model_catalog.py +2 -2
- package/latticeai/services/model_recommendation.py +8 -1
- package/latticeai/services/model_runtime.py +18 -3
- package/package.json +1 -1
- package/scripts/build_frontend_assets.mjs +12 -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 +5 -5
- package/static/app/assets/index-3G8qcrIS.js +336 -0
- package/static/app/assets/index-3G8qcrIS.js.map +1 -0
- package/static/app/assets/index-C0wYZp7k.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-CHHal8Zl.css +0 -2
- package/static/app/assets/index-pdzil9ac.js +0 -333
- package/static/app/assets/index-pdzil9ac.js.map +0 -1
|
@@ -77,6 +77,21 @@ function workspaceHeaders(): Record<string, string> {
|
|
|
77
77
|
return workspaceId ? { "X-Workspace-Id": workspaceId } : {};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function friendlyError(error: unknown, fallback: string) {
|
|
81
|
+
if (!error) return fallback;
|
|
82
|
+
const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : null;
|
|
83
|
+
const detail = record?.detail;
|
|
84
|
+
if (typeof detail === "string") return detail;
|
|
85
|
+
const detailRecord = typeof detail === "object" && detail !== null ? detail as Record<string, unknown> : null;
|
|
86
|
+
if (detailRecord) {
|
|
87
|
+
const message = detailRecord.user_message || detailRecord.reason || detailRecord.action || detailRecord.status;
|
|
88
|
+
if (message) return String(message);
|
|
89
|
+
}
|
|
90
|
+
const message = record?.message || record?.error;
|
|
91
|
+
if (message) return String(message);
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
async function apiJson<T>(
|
|
81
96
|
method: HttpMethod,
|
|
82
97
|
path: string,
|
|
@@ -108,7 +123,7 @@ async function apiJson<T>(
|
|
|
108
123
|
status: response.status,
|
|
109
124
|
data: emptyFor(opts.shape),
|
|
110
125
|
source: "unavailable",
|
|
111
|
-
error:
|
|
126
|
+
error: friendlyError(error, response.statusText),
|
|
112
127
|
};
|
|
113
128
|
} catch (err) {
|
|
114
129
|
return {
|
|
@@ -163,6 +178,69 @@ export type ChatEventHandlers = {
|
|
|
163
178
|
signal?: AbortSignal;
|
|
164
179
|
};
|
|
165
180
|
|
|
181
|
+
export type ModelPrepareHandlers = {
|
|
182
|
+
onProgress?: (data: Record<string, unknown>) => void;
|
|
183
|
+
onDone?: (data: Record<string, unknown>) => void;
|
|
184
|
+
onError?: (data: Record<string, unknown>) => void;
|
|
185
|
+
signal?: AbortSignal;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
async function streamModelPrepare(
|
|
189
|
+
body: { model: string; engine?: string; allow_download?: boolean },
|
|
190
|
+
handlers: ModelPrepareHandlers = {},
|
|
191
|
+
) {
|
|
192
|
+
const base = await apiBase();
|
|
193
|
+
const res = await fetch(`${base}/engines/prepare-model/stream`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
credentials: "same-origin",
|
|
196
|
+
signal: handlers.signal,
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
Accept: "text/event-stream",
|
|
200
|
+
...workspaceHeaders(),
|
|
201
|
+
} satisfies HeadersInit,
|
|
202
|
+
body: JSON.stringify({ engine: null, allow_download: false, ...body }),
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok || !res.body || !(res.headers.get("content-type") || "").includes("text/event-stream")) {
|
|
205
|
+
const payload = await res.json().catch(() => null);
|
|
206
|
+
const detail = payload?.detail && typeof payload.detail === "object" ? payload.detail : payload;
|
|
207
|
+
const message = friendlyError(payload, res.statusText);
|
|
208
|
+
handlers.onError?.({ status: "error", user_message: message, ...(detail || {}) });
|
|
209
|
+
return { source: "live" as const, ok: false, status: res.status, data: detail || {}, error: message };
|
|
210
|
+
}
|
|
211
|
+
const reader = res.body.getReader();
|
|
212
|
+
const decoder = new TextDecoder();
|
|
213
|
+
let buffer = "";
|
|
214
|
+
let eventName = "message";
|
|
215
|
+
let finalData: Record<string, unknown> = {};
|
|
216
|
+
for (;;) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done) break;
|
|
219
|
+
buffer += decoder.decode(value, { stream: true });
|
|
220
|
+
const parts = buffer.split("\n\n");
|
|
221
|
+
buffer = parts.pop() || "";
|
|
222
|
+
for (const part of parts) {
|
|
223
|
+
const lines = part.split("\n");
|
|
224
|
+
eventName = lines.find((item) => item.startsWith("event:"))?.slice(6).trim() || "message";
|
|
225
|
+
const dataLine = lines.find((item) => item.startsWith("data:"));
|
|
226
|
+
if (!dataLine) continue;
|
|
227
|
+
const raw = dataLine.slice(5).trim();
|
|
228
|
+
const data = raw ? JSON.parse(raw) as Record<string, unknown> : {};
|
|
229
|
+
if (eventName === "progress") handlers.onProgress?.(data);
|
|
230
|
+
if (eventName === "error") {
|
|
231
|
+
const detail = typeof data.detail === "object" && data.detail !== null ? data.detail as Record<string, unknown> : data;
|
|
232
|
+
handlers.onError?.(detail);
|
|
233
|
+
return { source: "live" as const, ok: false, status: Number(data.status_code || 500), data: detail, error: friendlyError({ detail }, "Model setup failed") };
|
|
234
|
+
}
|
|
235
|
+
if (eventName === "done") {
|
|
236
|
+
finalData = data;
|
|
237
|
+
handlers.onDone?.(data);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { source: "live" as const, ok: true, status: 200, data: finalData };
|
|
242
|
+
}
|
|
243
|
+
|
|
166
244
|
async function streamChat(body: Record<string, unknown>, handlers: ChatEventHandlers = {}) {
|
|
167
245
|
const base = await apiBase();
|
|
168
246
|
const res = await fetch(`${base}/chat`, {
|
|
@@ -263,6 +341,10 @@ export const latticeApi = {
|
|
|
263
341
|
connectFolder: (path: string) => post("/knowledge-graph/local/index", { path, approved: true, watch_enabled: true, consent: { approved: true, source: "desktop-spa" } }, {}),
|
|
264
342
|
localWatchStop: (source_id: string) => post("/knowledge-graph/local/watch/stop", { source_id }, {}),
|
|
265
343
|
models: () => get("/models", { catalog: [], loaded: [], recommended: [] }),
|
|
344
|
+
modelRecommendations: (engine = "local_mlx") => get("/models/recommendations", { profile: {}, recommendations: { models: [], families: [], counts: {} } }, { engine }),
|
|
345
|
+
installEngine: (engine: string) => post("/engines/install", { engine }, {}),
|
|
346
|
+
prepareModel: (model: string, engine?: string, allow_download = false) => post("/engines/prepare-model", { model, engine: engine || null, allow_download }, {}),
|
|
347
|
+
streamModelPrepare,
|
|
266
348
|
loadModel: (model_id: string, engine?: string, allow_download = false) => post("/models/load", { model_id, engine: engine || null, allow_download }, {}),
|
|
267
349
|
unloadModel: (model_id: string) => del(`/models/unload/${encodeURIComponent(model_id)}`, {}),
|
|
268
350
|
embeddingsStatus: () => get("/api/embeddings/status", {}),
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { ArrowRight, CheckCircle2, Cpu, Download, Layers3, Library, PlayCircle, SlidersHorizontal, UserCircle, Users } from "lucide-react";
|
|
4
|
+
import { latticeApi } from "@/api/client";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { useAppStore } from "@/store/appStore";
|
|
8
|
+
import { go } from "@/routes";
|
|
9
|
+
import { asArray } from "@/lib/utils";
|
|
10
|
+
|
|
11
|
+
function readDismissed() {
|
|
12
|
+
try {
|
|
13
|
+
return localStorage.getItem("lattice.onboarding.dismissed") === "true";
|
|
14
|
+
} catch {}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FirstRunGuide() {
|
|
19
|
+
const [dismissed, setDismissed] = React.useState(readDismissed);
|
|
20
|
+
const mode = useAppStore((state) => state.mode);
|
|
21
|
+
const profile = useQuery({ queryKey: ["profile"], queryFn: latticeApi.profile });
|
|
22
|
+
const workspace = useQuery({ queryKey: ["workspaceOs"], queryFn: latticeApi.workspaceOs });
|
|
23
|
+
const models = useQuery({ queryKey: ["models"], queryFn: latticeApi.models });
|
|
24
|
+
const recs = useQuery({ queryKey: ["modelRecommendations", "local_mlx"], queryFn: () => latticeApi.modelRecommendations("local_mlx") });
|
|
25
|
+
if (dismissed) return null;
|
|
26
|
+
|
|
27
|
+
const profileData = (profile.data?.data || {}) as Record<string, unknown>;
|
|
28
|
+
const workspaceData = (workspace.data?.data || {}) as Record<string, unknown>;
|
|
29
|
+
const registry = (workspaceData.workspace_registry || {}) as Record<string, unknown>;
|
|
30
|
+
const modelData = (models.data?.data || {}) as Record<string, unknown>;
|
|
31
|
+
const recommendationData = ((recs.data?.data as Record<string, unknown> | undefined)?.recommendations || {}) as Record<string, unknown>;
|
|
32
|
+
const currentModel = String(modelData.current || "");
|
|
33
|
+
const loadedModels = asArray(modelData.loaded);
|
|
34
|
+
const topPick = recommendationData.top_pick as Record<string, unknown> | undefined;
|
|
35
|
+
const compatProfiles = asArray<Record<string, unknown>>(modelData.compat_profiles);
|
|
36
|
+
const readyProfile = compatProfiles.some((item) => item.chat_compatible || item.quality_status === "ok" || item.quality_status === "degraded");
|
|
37
|
+
|
|
38
|
+
const steps = [
|
|
39
|
+
{ label: "Make it yours", done: Boolean(profileData.email), icon: UserCircle, action: "account", detail: "Sign in or keep a local profile." },
|
|
40
|
+
{ label: "Choose a space", done: Boolean(registry.active_workspace || workspaceData.active_workspace), icon: Users, action: "workspace-admin", detail: "Decide where memories belong." },
|
|
41
|
+
{ label: "Meet your Mac", done: recs.isSuccess, icon: Cpu, action: "models", detail: "Let Lattice inspect what can run locally." },
|
|
42
|
+
{ label: "Pick a brain", done: Boolean(topPick || currentModel), icon: Library, action: "models", detail: "Use the recommended local model." },
|
|
43
|
+
{ label: "Install locally", done: Boolean(currentModel || loadedModels.length), icon: Download, action: "models", detail: "Download only with explicit consent." },
|
|
44
|
+
{ label: "Try a question", done: Boolean(readyProfile || currentModel || loadedModels.length), icon: PlayCircle, action: "chat", detail: "Confirm the model can answer." },
|
|
45
|
+
{ label: "Set the pace", done: Boolean(mode), icon: SlidersHorizontal, action: "settings", detail: "Stay Calm or switch deeper." },
|
|
46
|
+
{ label: "Explore memory", done: true, icon: Layers3, action: "knowledge-graph", detail: "Open the living map." },
|
|
47
|
+
];
|
|
48
|
+
const completed = steps.filter((step) => step.done).length;
|
|
49
|
+
const nextStep = steps.find((step) => !step.done) || steps[steps.length - 1];
|
|
50
|
+
const progress = Math.round((completed / steps.length) * 100);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<section className="arrival-panel" aria-label="First 10 minutes">
|
|
54
|
+
<div className="arrival-copy">
|
|
55
|
+
<div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
|
|
56
|
+
<h2>Build your Digital Brain without guessing.</h2>
|
|
57
|
+
<p>
|
|
58
|
+
Start with a space, let Lattice recommend a private local model, then add the first pieces of knowledge.
|
|
59
|
+
Every step keeps the next action visible.
|
|
60
|
+
</p>
|
|
61
|
+
<div className="arrival-actions">
|
|
62
|
+
<Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open memory map" : `Continue: ${nextStep.label}`}</Button>
|
|
63
|
+
<Button variant="outline" onClick={() => go("models")}>Set up model</Button>
|
|
64
|
+
<Button variant="ghost" onClick={() => {
|
|
65
|
+
try { localStorage.setItem("lattice.onboarding.dismissed", "true"); } catch {}
|
|
66
|
+
setDismissed(true);
|
|
67
|
+
}}>
|
|
68
|
+
Hide
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="journey-panel">
|
|
73
|
+
<div className="journey-head">
|
|
74
|
+
<div>
|
|
75
|
+
<div className="text-sm font-semibold">{completed} of {steps.length} ready</div>
|
|
76
|
+
<div className="text-xs text-muted-foreground">{mode === "basic" ? "Calm mode" : `${mode} mode`}</div>
|
|
77
|
+
</div>
|
|
78
|
+
<Badge variant={progress === 100 ? "success" : "warning"}>{progress}%</Badge>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="journey-progress"><span style={{ width: `${progress}%` }} /></div>
|
|
81
|
+
<div className="journey-steps">
|
|
82
|
+
{steps.map((step) => {
|
|
83
|
+
const Icon = step.icon;
|
|
84
|
+
return (
|
|
85
|
+
<button key={step.label} onClick={() => go(step.action)} className="journey-step">
|
|
86
|
+
<span className="journey-icon"><Icon className="h-4 w-4" /></span>
|
|
87
|
+
<span className="min-w-0">
|
|
88
|
+
<span className="block truncate text-sm font-semibold">{step.label}</span>
|
|
89
|
+
<span className="block truncate text-xs text-muted-foreground">{step.detail}</span>
|
|
90
|
+
</span>
|
|
91
|
+
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
-
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
|
4
|
-
import { ApiResult } from "@/api/client";
|
|
3
|
+
import { AlertCircle, CheckCircle2, Loader2, LockKeyhole, Sparkles } from "lucide-react";
|
|
4
|
+
import type { ApiResult } from "@/api/client";
|
|
5
5
|
import { Badge } from "@/components/ui/badge";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { useAppStore } from "@/store/appStore";
|
|
8
9
|
import { cn, asArray, fmtNumber, shortId, titleize } from "@/lib/utils";
|
|
9
10
|
|
|
10
11
|
export function SourceBadge({ result }: { result?: Pick<ApiResult, "source" | "ok" | "status"> }) {
|
|
12
|
+
const mode = useAppStore((state) => state.mode);
|
|
11
13
|
if (!result) return <Badge variant="muted">not loaded</Badge>;
|
|
12
|
-
if (result.source === "live" && result.ok) return <Badge variant="success">
|
|
13
|
-
return <Badge variant="warning">unavailable</Badge>;
|
|
14
|
+
if (result.source === "live" && result.ok) return <Badge variant="success">{mode === "basic" ? "ready" : "connected"}</Badge>;
|
|
15
|
+
return <Badge variant="warning">{mode === "basic" ? "needs setup" : "unavailable"}</Badge>;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function EmptyState({ title = "Unavailable", detail }: { title?: string; detail?: React.ReactNode }) {
|
|
17
19
|
return (
|
|
18
|
-
<div className="flex min-h-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
<div className="flex min-h-36 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/24 p-6 text-center text-sm text-muted-foreground">
|
|
21
|
+
<div className="grid h-10 w-10 place-items-center rounded-md border border-border bg-card">
|
|
22
|
+
<Sparkles className="h-5 w-5 text-primary" />
|
|
23
|
+
</div>
|
|
24
|
+
<div className="text-base font-semibold text-foreground">{title}</div>
|
|
25
|
+
{detail ? <div className="max-w-md leading-6">{detail}</div> : null}
|
|
22
26
|
</div>
|
|
23
27
|
);
|
|
24
28
|
}
|
|
@@ -36,8 +40,9 @@ export function DataPanel<T>({
|
|
|
36
40
|
children: (data: T) => React.ReactNode;
|
|
37
41
|
className?: string;
|
|
38
42
|
}) {
|
|
43
|
+
const mode = useAppStore((state) => state.mode);
|
|
39
44
|
return (
|
|
40
|
-
<Card className={className}>
|
|
45
|
+
<Card className={cn("overflow-hidden", className)}>
|
|
41
46
|
<CardHeader className="flex-row items-start justify-between gap-3">
|
|
42
47
|
<div>
|
|
43
48
|
<CardTitle>{title}</CardTitle>
|
|
@@ -46,7 +51,9 @@ export function DataPanel<T>({
|
|
|
46
51
|
<SourceBadge result={result} />
|
|
47
52
|
</CardHeader>
|
|
48
53
|
<CardContent>
|
|
49
|
-
{result?.ok ? children(result.data) :
|
|
54
|
+
{result?.ok ? children(result.data) : (
|
|
55
|
+
<EmptyState detail={mode === "basic" ? "This area needs setup or is not available yet." : result?.error || "This capability is not reporting right now."} />
|
|
56
|
+
)}
|
|
50
57
|
</CardContent>
|
|
51
58
|
</Card>
|
|
52
59
|
);
|
|
@@ -71,10 +78,10 @@ export function StatGrid({ stats }: { stats: Array<{ label: string; value: unkno
|
|
|
71
78
|
return (
|
|
72
79
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
73
80
|
{stats.map((stat) => (
|
|
74
|
-
<div key={stat.label} className="rounded-
|
|
75
|
-
<div className="text-xs uppercase
|
|
76
|
-
<div className="mt-
|
|
77
|
-
{stat.hint ? <div className="mt-
|
|
81
|
+
<div key={stat.label} className="rounded-lg border border-border bg-background/55 p-4">
|
|
82
|
+
<div className="text-xs uppercase text-muted-foreground">{stat.label}</div>
|
|
83
|
+
<div className="mt-2 text-2xl font-semibold leading-tight">{typeof stat.value === "number" ? fmtNumber(stat.value) : String(stat.value ?? "-")}</div>
|
|
84
|
+
{stat.hint ? <div className="mt-2 text-xs leading-5 text-muted-foreground">{stat.hint}</div> : null}
|
|
78
85
|
</div>
|
|
79
86
|
))}
|
|
80
87
|
</div>
|
|
@@ -92,6 +99,32 @@ function scalarText(value: unknown) {
|
|
|
92
99
|
return String(value);
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
const BASIC_HIDDEN_KEY = /(^id$|_id$|token|secret|passphrase|fingerprint|public_key|private_key|dsn|schema|endpoint|base_url|localhost|127\.0\.0\.1|stack|trace|raw|runtime|engine|module|port|host|api|internal)/i;
|
|
103
|
+
|
|
104
|
+
function hideInBasic(key: string) {
|
|
105
|
+
return BASIC_HIDDEN_KEY.test(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function humanText(value: unknown) {
|
|
109
|
+
const text = scalarText(value);
|
|
110
|
+
if (text === "-") return text;
|
|
111
|
+
if (text.includes("/") || text.includes("@") || /\.[a-z0-9]{2,5}$/i.test(text)) return text;
|
|
112
|
+
return titleize(text.replace(/^agent:/i, "").replace(/^tool:/i, ""));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function firstRecordList(value: Record<string, unknown>) {
|
|
116
|
+
const preferred = [
|
|
117
|
+
"documents", "sources", "items", "agents", "workflows", "runs", "events",
|
|
118
|
+
"permissions", "models", "peers", "invitations", "roles", "policies",
|
|
119
|
+
"hooks", "tools", "templates", "plugins", "recent_events",
|
|
120
|
+
];
|
|
121
|
+
for (const key of preferred) {
|
|
122
|
+
const rows = asArray<Record<string, unknown>>(value[key]);
|
|
123
|
+
if (rows.length) return rows;
|
|
124
|
+
}
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
95
128
|
export function ValuePreview({ value }: { value: unknown }) {
|
|
96
129
|
if (typeof value === "boolean") {
|
|
97
130
|
return <Badge variant={value ? "success" : "muted"}>{value ? "enabled" : "disabled"}</Badge>;
|
|
@@ -119,7 +152,10 @@ export function ValuePreview({ value }: { value: unknown }) {
|
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
export function KeyValueList({ data, limit = 8 }: { data: Record<string, unknown>; limit?: number }) {
|
|
122
|
-
const
|
|
155
|
+
const mode = useAppStore((state) => state.mode);
|
|
156
|
+
const rows = Object.entries(data || {})
|
|
157
|
+
.filter(([key]) => mode !== "basic" || !hideInBasic(key))
|
|
158
|
+
.slice(0, limit);
|
|
123
159
|
if (!rows.length) return <EmptyState title="No values" />;
|
|
124
160
|
return (
|
|
125
161
|
<div className="divide-y divide-border rounded-md border border-border">
|
|
@@ -144,8 +180,10 @@ export function StructuredView({
|
|
|
144
180
|
metaKey?: string;
|
|
145
181
|
limit?: number;
|
|
146
182
|
}) {
|
|
183
|
+
const mode = useAppStore((state) => state.mode);
|
|
184
|
+
if (mode === "basic") return <FriendlySummary value={value} titleKey={titleKey} metaKey={metaKey} limit={limit} />;
|
|
147
185
|
if (Array.isArray(value)) {
|
|
148
|
-
if (!value.length) return <EmptyState title="
|
|
186
|
+
if (!value.length) return <EmptyState title="Nothing here yet" detail="New items will appear here when Lattice has something to show." />;
|
|
149
187
|
if (value.every((item) => isRecord(item))) {
|
|
150
188
|
return <EntityList items={value} titleKey={titleKey} metaKey={metaKey} limit={limit} />;
|
|
151
189
|
}
|
|
@@ -164,6 +202,46 @@ export function StructuredView({
|
|
|
164
202
|
);
|
|
165
203
|
}
|
|
166
204
|
|
|
205
|
+
export function FriendlySummary({
|
|
206
|
+
value,
|
|
207
|
+
titleKey = "title",
|
|
208
|
+
metaKey = "status",
|
|
209
|
+
limit = 6,
|
|
210
|
+
}: {
|
|
211
|
+
value: unknown;
|
|
212
|
+
titleKey?: string;
|
|
213
|
+
metaKey?: string;
|
|
214
|
+
limit?: number;
|
|
215
|
+
}) {
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
if (!value.length) return <EmptyState title="Nothing here yet" detail="New items will appear here when Lattice has something to show." />;
|
|
218
|
+
if (value.every((item) => isRecord(item))) {
|
|
219
|
+
return <EntityList items={value} titleKey={titleKey} metaKey={metaKey} limit={limit} />;
|
|
220
|
+
}
|
|
221
|
+
return (
|
|
222
|
+
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-background/55 p-3">
|
|
223
|
+
{value.slice(0, limit).map((item, index) => <Badge key={`${String(item)}-${index}`} variant="muted">{humanText(item)}</Badge>)}
|
|
224
|
+
{value.length > limit ? <Badge variant="muted">+{value.length - limit}</Badge> : null}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (isRecord(value)) {
|
|
229
|
+
const list = firstRecordList(value);
|
|
230
|
+
if (list.length) return <EntityList items={list} titleKey={titleKey} metaKey={metaKey} limit={limit} />;
|
|
231
|
+
const friendly = Object.fromEntries(
|
|
232
|
+
Object.entries(value)
|
|
233
|
+
.filter(([key]) => !hideInBasic(key))
|
|
234
|
+
.map(([key, item]) => [key, Array.isArray(item) ? `${fmtNumber(item.length)} items` : isRecord(item) ? "available" : item]),
|
|
235
|
+
);
|
|
236
|
+
return <KeyValueList data={friendly} limit={limit} />;
|
|
237
|
+
}
|
|
238
|
+
return (
|
|
239
|
+
<div className="rounded-md border border-border bg-background/55 p-3 text-sm">
|
|
240
|
+
{humanText(value)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
167
245
|
export function OperationResult({
|
|
168
246
|
result,
|
|
169
247
|
successLabel = "Request completed",
|
|
@@ -171,6 +249,7 @@ export function OperationResult({
|
|
|
171
249
|
result?: ApiResult<unknown> | null;
|
|
172
250
|
successLabel?: string;
|
|
173
251
|
}) {
|
|
252
|
+
const mode = useAppStore((state) => state.mode);
|
|
174
253
|
if (!result) return null;
|
|
175
254
|
if (!result.ok) {
|
|
176
255
|
return <EmptyState title="Request unavailable" detail={result.error || <ValuePreview value={result.data} />} />;
|
|
@@ -178,7 +257,7 @@ export function OperationResult({
|
|
|
178
257
|
return (
|
|
179
258
|
<div className="space-y-2 rounded-md border border-border bg-background p-3">
|
|
180
259
|
<Badge variant="success">{successLabel}</Badge>
|
|
181
|
-
<StructuredView value={result.data} />
|
|
260
|
+
{mode === "basic" ? <FriendlySummary value={result.data} /> : <StructuredView value={result.data} />}
|
|
182
261
|
</div>
|
|
183
262
|
);
|
|
184
263
|
}
|
|
@@ -194,20 +273,21 @@ export function EntityList({
|
|
|
194
273
|
metaKey?: string;
|
|
195
274
|
limit?: number;
|
|
196
275
|
}) {
|
|
276
|
+
const mode = useAppStore((state) => state.mode);
|
|
197
277
|
const rows = asArray<Record<string, unknown>>(items).slice(0, limit);
|
|
198
|
-
if (!rows.length) return <EmptyState title="
|
|
278
|
+
if (!rows.length) return <EmptyState title="Nothing here yet" detail="New items will appear here when Lattice has something to show." />;
|
|
199
279
|
return (
|
|
200
280
|
<div className="grid gap-2">
|
|
201
281
|
{rows.map((item, index) => (
|
|
202
|
-
<div key={String(item.id || item.name || index)} className="rounded-
|
|
282
|
+
<div key={String(item.id || item.name || index)} className="rounded-lg border border-border bg-background/55 p-3">
|
|
203
283
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
204
|
-
<div className="font-medium">{String(item[titleKey] || item.name || item.id || `Record ${index + 1}`)}</div>
|
|
205
|
-
<Badge variant="muted">{String(item[metaKey] || item.status || item.state || "record")}</Badge>
|
|
284
|
+
<div className="font-medium">{mode === "basic" ? humanText(item[titleKey] || item.name || item.label || `Item ${index + 1}`) : String(item[titleKey] || item.name || item.id || `Record ${index + 1}`)}</div>
|
|
285
|
+
<Badge variant="muted">{mode === "basic" ? humanText(item[metaKey] || item.status || item.state || "ready") : String(item[metaKey] || item.status || item.state || "record")}</Badge>
|
|
206
286
|
</div>
|
|
207
287
|
{item.summary || item.description || item.path || (item.id && item[titleKey] !== item.id) ? (
|
|
208
288
|
<p className="mt-1 text-sm text-muted-foreground">{String(item.summary || item.description || item.path || item.id)}</p>
|
|
209
289
|
) : null}
|
|
210
|
-
{item.id && item[titleKey] !== item.id ? (
|
|
290
|
+
{mode !== "basic" && item.id && item[titleKey] !== item.id ? (
|
|
211
291
|
<div className="mt-1 text-xs text-muted-foreground">{shortId(item.id, 48)}</div>
|
|
212
292
|
) : null}
|
|
213
293
|
</div>
|
|
@@ -216,6 +296,32 @@ export function EntityList({
|
|
|
216
296
|
);
|
|
217
297
|
}
|
|
218
298
|
|
|
299
|
+
export function ModeGate({
|
|
300
|
+
title = "Advanced controls",
|
|
301
|
+
detail = "Switch modes when you want diagnostics or administrative controls. Basic mode keeps the product focused on everyday use.",
|
|
302
|
+
target = "advanced",
|
|
303
|
+
}: {
|
|
304
|
+
title?: string;
|
|
305
|
+
detail?: string;
|
|
306
|
+
target?: "advanced" | "admin";
|
|
307
|
+
}) {
|
|
308
|
+
const setMode = useAppStore((state) => state.setMode);
|
|
309
|
+
return (
|
|
310
|
+
<Card>
|
|
311
|
+
<CardContent className="flex flex-col items-start gap-3 p-6">
|
|
312
|
+
<div className="grid h-10 w-10 place-items-center rounded-md border border-border bg-background/70">
|
|
313
|
+
<LockKeyhole className="h-5 w-5 text-primary" />
|
|
314
|
+
</div>
|
|
315
|
+
<div>
|
|
316
|
+
<div className="text-lg font-semibold">{title}</div>
|
|
317
|
+
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{detail}</p>
|
|
318
|
+
</div>
|
|
319
|
+
<Button onClick={() => setMode(target)}>{target === "admin" ? "Switch to Admin" : "Switch to Advanced"}</Button>
|
|
320
|
+
</CardContent>
|
|
321
|
+
</Card>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
219
325
|
export function ActionButton({
|
|
220
326
|
label,
|
|
221
327
|
successLabel = "Done",
|
|
@@ -268,14 +374,14 @@ export function Tabs({
|
|
|
268
374
|
onChange: (id: string) => void;
|
|
269
375
|
}) {
|
|
270
376
|
return (
|
|
271
|
-
<div className="flex flex-wrap gap-1 rounded-
|
|
377
|
+
<div className="inline-flex max-w-full flex-wrap gap-1 rounded-lg border border-border bg-muted/28 p-1">
|
|
272
378
|
{tabs.map((tab) => (
|
|
273
379
|
<button
|
|
274
380
|
key={tab.id}
|
|
275
381
|
onClick={() => onChange(tab.id)}
|
|
276
382
|
className={cn(
|
|
277
|
-
"h-
|
|
278
|
-
value === tab.id ? "bg-
|
|
383
|
+
"h-9 rounded-md px-3.5 text-sm font-semibold transition",
|
|
384
|
+
value === tab.id ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:bg-card/60 hover:text-foreground",
|
|
279
385
|
)}
|
|
280
386
|
>
|
|
281
387
|
{tab.label}
|
|
@@ -9,7 +9,7 @@ const variants = {
|
|
|
9
9
|
default: "border-primary/25 bg-primary/12 text-primary",
|
|
10
10
|
success: "border-emerald-500/25 bg-emerald-500/12 text-emerald-300",
|
|
11
11
|
warning: "border-amber-500/25 bg-amber-500/12 text-amber-300",
|
|
12
|
-
muted: "border-border bg-muted text-muted-foreground",
|
|
12
|
+
muted: "border-border bg-muted/70 text-muted-foreground",
|
|
13
13
|
danger: "border-destructive/30 bg-destructive/12 text-destructive",
|
|
14
14
|
};
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ export function Badge({ className, variant = "default", ...props }: BadgeProps)
|
|
|
17
17
|
return (
|
|
18
18
|
<span
|
|
19
19
|
className={cn(
|
|
20
|
-
"inline-flex min-h-6 items-center rounded-md border px-2 py-0.5 text-xs font-
|
|
20
|
+
"inline-flex min-h-6 max-w-full items-center rounded-md border px-2 py-0.5 text-xs font-semibold leading-none",
|
|
21
21
|
variants[variant],
|
|
22
22
|
className,
|
|
23
23
|
)}
|
|
@@ -3,20 +3,20 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
const buttonVariants = cva(
|
|
6
|
-
"inline-flex h-
|
|
6
|
+
"inline-flex h-10 max-w-full items-center justify-center gap-2 rounded-md px-3.5 text-sm font-semibold leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-45",
|
|
7
7
|
{
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
10
|
-
default: "bg-primary text-primary-foreground hover:bg-primary/
|
|
11
|
-
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/
|
|
12
|
-
ghost: "hover:bg-muted
|
|
13
|
-
outline: "border border-border bg-
|
|
10
|
+
default: "bg-primary text-primary-foreground shadow-[0_10px_30px_hsl(var(--primary)/0.16)] hover:bg-primary/92",
|
|
11
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/82",
|
|
12
|
+
ghost: "text-foreground hover:bg-muted",
|
|
13
|
+
outline: "border border-border bg-card/70 hover:bg-muted/88",
|
|
14
14
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
15
15
|
},
|
|
16
16
|
size: {
|
|
17
17
|
sm: "h-8 px-2.5 text-xs",
|
|
18
|
-
md: "h-
|
|
19
|
-
icon: "h-
|
|
18
|
+
md: "h-10 px-3.5",
|
|
19
|
+
icon: "h-10 w-10 px-0",
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
defaultVariants: {
|
|
@@ -2,21 +2,21 @@ import * as React from "react";
|
|
|
2
2
|
import { cn } from "@/lib/utils";
|
|
3
3
|
|
|
4
4
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
5
|
-
return <section className={cn("rounded-lg
|
|
5
|
+
return <section className={cn("premium-surface rounded-lg text-card-foreground", className)} {...props} />;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
9
|
-
return <div className={cn("flex flex-col gap-1.5 p-
|
|
9
|
+
return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
|
13
|
-
return <h2 className={cn("text-
|
|
13
|
+
return <h2 className={cn("text-base font-semibold tracking-normal", className)} {...props} />;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
17
|
-
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
17
|
+
return <p className={cn("text-sm leading-6 text-muted-foreground", className)} {...props} />;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
21
|
-
return <div className={cn("p-
|
|
21
|
+
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
|
22
22
|
}
|
|
@@ -6,7 +6,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
|
|
|
6
6
|
<input
|
|
7
7
|
ref={ref}
|
|
8
8
|
className={cn(
|
|
9
|
-
"h-
|
|
9
|
+
"h-10 w-full rounded-md border border-input bg-background/70 px-3 text-sm outline-none transition placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring/35 disabled:cursor-not-allowed disabled:opacity-50",
|
|
10
10
|
className,
|
|
11
11
|
)}
|
|
12
12
|
{...props}
|
|
@@ -6,7 +6,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTML
|
|
|
6
6
|
<textarea
|
|
7
7
|
ref={ref}
|
|
8
8
|
className={cn(
|
|
9
|
-
"min-h-
|
|
9
|
+
"min-h-28 w-full resize-y rounded-md border border-input bg-background/70 px-3 py-3 text-sm leading-6 outline-none transition placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring/35 disabled:cursor-not-allowed disabled:opacity-50",
|
|
10
10
|
className,
|
|
11
11
|
)}
|
|
12
12
|
{...props}
|