ltcai 4.3.3 → 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.
Files changed (138) hide show
  1. package/README.md +53 -20
  2. package/docs/CHANGELOG.md +122 -0
  3. package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
  4. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
  5. package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
  6. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
  7. package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
  8. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
  9. package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
  10. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
  11. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
  12. package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
  13. package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
  14. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
  15. package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
  16. package/docs/V4_5_1_UX_REPORT.md +45 -0
  17. package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
  18. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
  19. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
  20. package/docs/architecture.md +8 -4
  21. package/frontend/src/App.tsx +152 -91
  22. package/frontend/src/api/client.ts +83 -1
  23. package/frontend/src/components/FirstRunGuide.tsx +99 -0
  24. package/frontend/src/components/primitives.tsx +131 -25
  25. package/frontend/src/components/ui/badge.tsx +2 -2
  26. package/frontend/src/components/ui/button.tsx +7 -7
  27. package/frontend/src/components/ui/card.tsx +5 -5
  28. package/frontend/src/components/ui/input.tsx +1 -1
  29. package/frontend/src/components/ui/textarea.tsx +1 -1
  30. package/frontend/src/pages/Act.tsx +58 -28
  31. package/frontend/src/pages/Ask.tsx +51 -19
  32. package/frontend/src/pages/Brain.tsx +60 -42
  33. package/frontend/src/pages/Capture.tsx +24 -24
  34. package/frontend/src/pages/Library.tsx +222 -32
  35. package/frontend/src/pages/System.tsx +56 -34
  36. package/frontend/src/routes.ts +15 -13
  37. package/frontend/src/store/appStore.ts +8 -1
  38. package/frontend/src/styles.css +666 -36
  39. package/lattice_brain/__init__.py +38 -23
  40. package/lattice_brain/_kg_common.py +11 -1
  41. package/lattice_brain/context.py +212 -2
  42. package/lattice_brain/conversations.py +234 -1
  43. package/lattice_brain/discovery.py +11 -1
  44. package/lattice_brain/documents.py +11 -1
  45. package/lattice_brain/graph/__init__.py +28 -0
  46. package/lattice_brain/graph/_kg_common.py +1123 -0
  47. package/lattice_brain/graph/curator.py +473 -0
  48. package/lattice_brain/graph/discovery.py +1455 -0
  49. package/lattice_brain/graph/documents.py +218 -0
  50. package/lattice_brain/graph/identity.py +175 -0
  51. package/lattice_brain/graph/ingest.py +644 -0
  52. package/lattice_brain/graph/network.py +205 -0
  53. package/lattice_brain/graph/projection.py +571 -0
  54. package/lattice_brain/graph/provenance.py +401 -0
  55. package/lattice_brain/graph/retrieval.py +1341 -0
  56. package/lattice_brain/graph/schema.py +640 -0
  57. package/lattice_brain/graph/store.py +237 -0
  58. package/lattice_brain/graph/write_master.py +225 -0
  59. package/lattice_brain/identity.py +11 -13
  60. package/lattice_brain/ingest.py +11 -1
  61. package/lattice_brain/ingestion.py +318 -0
  62. package/lattice_brain/memory.py +100 -1
  63. package/lattice_brain/network.py +11 -1
  64. package/lattice_brain/portability.py +431 -0
  65. package/lattice_brain/projection.py +11 -1
  66. package/lattice_brain/provenance.py +11 -1
  67. package/lattice_brain/retrieval.py +11 -1
  68. package/lattice_brain/runtime/__init__.py +32 -0
  69. package/lattice_brain/runtime/agent_runtime.py +569 -0
  70. package/lattice_brain/runtime/hooks.py +754 -0
  71. package/lattice_brain/runtime/multi_agent.py +795 -0
  72. package/lattice_brain/schema.py +11 -1
  73. package/lattice_brain/store.py +10 -2
  74. package/lattice_brain/workflow.py +461 -0
  75. package/lattice_brain/write_master.py +11 -1
  76. package/latticeai/__init__.py +1 -1
  77. package/latticeai/api/agents.py +2 -2
  78. package/latticeai/api/browser.py +1 -1
  79. package/latticeai/api/chat.py +1 -1
  80. package/latticeai/api/computer_use.py +1 -1
  81. package/latticeai/api/hooks.py +2 -2
  82. package/latticeai/api/mcp.py +1 -1
  83. package/latticeai/api/models.py +107 -18
  84. package/latticeai/api/tools.py +1 -1
  85. package/latticeai/api/workflow_designer.py +2 -2
  86. package/latticeai/app_factory.py +4 -4
  87. package/latticeai/brain/__init__.py +24 -6
  88. package/latticeai/brain/_kg_common.py +11 -1117
  89. package/latticeai/brain/context.py +12 -208
  90. package/latticeai/brain/conversations.py +12 -231
  91. package/latticeai/brain/discovery.py +13 -1451
  92. package/latticeai/brain/documents.py +13 -214
  93. package/latticeai/brain/identity.py +11 -169
  94. package/latticeai/brain/ingest.py +13 -640
  95. package/latticeai/brain/memory.py +12 -97
  96. package/latticeai/brain/network.py +12 -200
  97. package/latticeai/brain/projection.py +13 -567
  98. package/latticeai/brain/provenance.py +13 -397
  99. package/latticeai/brain/retrieval.py +13 -1337
  100. package/latticeai/brain/schema.py +12 -635
  101. package/latticeai/brain/store.py +13 -233
  102. package/latticeai/brain/write_master.py +13 -221
  103. package/latticeai/core/agent.py +1 -1
  104. package/latticeai/core/agent_registry.py +2 -2
  105. package/latticeai/core/builtin_hooks.py +2 -2
  106. package/latticeai/core/graph_curator.py +6 -468
  107. package/latticeai/core/hooks.py +6 -749
  108. package/latticeai/core/marketplace.py +1 -1
  109. package/latticeai/core/model_compat.py +250 -0
  110. package/latticeai/core/multi_agent.py +6 -790
  111. package/latticeai/core/workflow_engine.py +6 -456
  112. package/latticeai/core/workspace_os.py +1 -1
  113. package/latticeai/models/router.py +136 -32
  114. package/latticeai/services/agent_runtime.py +6 -564
  115. package/latticeai/services/ingestion.py +6 -313
  116. package/latticeai/services/kg_portability.py +6 -426
  117. package/latticeai/services/model_catalog.py +2 -2
  118. package/latticeai/services/model_recommendation.py +8 -1
  119. package/latticeai/services/model_runtime.py +18 -3
  120. package/latticeai/services/platform_runtime.py +3 -3
  121. package/latticeai/services/run_executor.py +1 -1
  122. package/latticeai/services/upload_service.py +1 -1
  123. package/p_reinforce.py +1 -1
  124. package/package.json +1 -1
  125. package/scripts/build_frontend_assets.mjs +12 -1
  126. package/scripts/bump_version.py +1 -1
  127. package/scripts/wheel_smoke.py +7 -0
  128. package/src-tauri/Cargo.lock +1 -1
  129. package/src-tauri/Cargo.toml +1 -1
  130. package/src-tauri/tauri.conf.json +1 -1
  131. package/static/app/asset-manifest.json +5 -5
  132. package/static/app/assets/index-3G8qcrIS.js +336 -0
  133. package/static/app/assets/index-3G8qcrIS.js.map +1 -0
  134. package/static/app/assets/index-C0wYZp7k.css +2 -0
  135. package/static/app/index.html +2 -2
  136. package/static/app/assets/index-CHHal8Zl.css +0 -2
  137. package/static/app/assets/index-pdzil9ac.js +0 -333
  138. package/static/app/assets/index-pdzil9ac.js.map +0 -1
@@ -1,10 +1,11 @@
1
1
  import * as React from "react";
2
2
  import { useQuery } from "@tanstack/react-query";
3
- import { Command, Menu, Moon, Search, Sun, X } from "lucide-react";
3
+ import { BrainCircuit, CheckCircle2, Command, Menu, Moon, Search, Sparkles, Sun, X } from "lucide-react";
4
4
  import { latticeApi } from "@/api/client";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Input } from "@/components/ui/input";
7
- import { useAppStore } from "@/store/appStore";
7
+ import { FirstRunGuide } from "@/components/FirstRunGuide";
8
+ import { useAppStore, WorkspaceMode } from "@/store/appStore";
8
9
  import { commandRoutes, go, parseHash, primaryRoutes, PrimaryRoute } from "@/routes";
9
10
  import { BrainPage } from "@/pages/Brain";
10
11
  import { AskPage } from "@/pages/Ask";
@@ -14,6 +15,12 @@ import { LibraryPage } from "@/pages/Library";
14
15
  import { SystemPage } from "@/pages/System";
15
16
  import { cn } from "@/lib/utils";
16
17
 
18
+ const modes: Array<{ id: WorkspaceMode; label: string }> = [
19
+ { id: "basic", label: "Calm" },
20
+ { id: "advanced", label: "Deep" },
21
+ { id: "admin", label: "Admin" },
22
+ ];
23
+
17
24
  function useRoute() {
18
25
  const [route, setRoute] = React.useState(parseHash);
19
26
  React.useEffect(() => {
@@ -33,9 +40,26 @@ function Page({ primary, tab }: { primary: PrimaryRoute; tab?: string }) {
33
40
  return <BrainPage initialTab={tab} />;
34
41
  }
35
42
 
43
+ function AmbientBrain() {
44
+ return (
45
+ <div className="ambient-brain" aria-hidden="true">
46
+ <span className="signal-line signal-line-a" />
47
+ <span className="signal-line signal-line-b" />
48
+ <span className="signal-line signal-line-c" />
49
+ <span className="signal-tile signal-tile-a" />
50
+ <span className="signal-tile signal-tile-b" />
51
+ <span className="signal-tile signal-tile-c" />
52
+ </div>
53
+ );
54
+ }
55
+
36
56
  function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
37
57
  const [query, setQuery] = React.useState("");
38
- const matches = commandRoutes.filter((route) => route.label.toLowerCase().includes(query.toLowerCase()) || route.key.includes(query.toLowerCase()));
58
+ const normalized = query.trim().toLowerCase();
59
+ const matches = commandRoutes.filter((route) => (
60
+ route.label.toLowerCase().includes(normalized) || route.key.includes(normalized)
61
+ ));
62
+
39
63
  React.useEffect(() => {
40
64
  if (!open) return;
41
65
  const onKey = (event: KeyboardEvent) => {
@@ -44,16 +68,17 @@ function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void
44
68
  window.addEventListener("keydown", onKey);
45
69
  return () => window.removeEventListener("keydown", onKey);
46
70
  }, [open, onClose]);
71
+
47
72
  if (!open) return null;
48
73
  return (
49
- <div className="fixed inset-0 z-50 bg-background/80 p-4 backdrop-blur-sm" role="dialog" aria-modal="true">
50
- <div className="mx-auto mt-16 max-w-xl rounded-lg border border-border bg-card shadow-xl">
51
- <div className="flex items-center gap-2 border-b border-border p-3">
74
+ <div className="command-scrim" role="dialog" aria-modal="true" aria-label="Lattice command palette">
75
+ <div className="command-panel">
76
+ <div className="command-search">
52
77
  <Search className="h-4 w-4 text-muted-foreground" />
53
- <Input value={query} onChange={(e) => setQuery(e.target.value)} autoFocus placeholder="Jump to a capability" />
54
- <Button variant="ghost" size="icon" onClick={onClose}><X className="h-4 w-4" /></Button>
78
+ <Input value={query} onChange={(event) => setQuery(event.target.value)} autoFocus placeholder="Jump to anything in Lattice" />
79
+ <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close command palette"><X className="h-4 w-4" /></Button>
55
80
  </div>
56
- <div className="max-h-96 overflow-auto p-2">
81
+ <div className="command-list soft-scrollbar">
57
82
  {matches.map((route) => {
58
83
  const Icon = route.icon;
59
84
  return (
@@ -63,10 +88,13 @@ function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void
63
88
  go(route.key);
64
89
  onClose();
65
90
  }}
66
- className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-muted"
91
+ className="command-row"
67
92
  >
68
- <Icon className="h-4 w-4 text-primary" />
69
- {route.label}
93
+ <span className="command-icon"><Icon className="h-4 w-4" /></span>
94
+ <span>
95
+ <span className="block text-sm font-semibold">{route.label}</span>
96
+ <span className="block text-xs text-muted-foreground">Open {route.key.replace(/[-/]/g, " ")}</span>
97
+ </span>
70
98
  </button>
71
99
  );
72
100
  })}
@@ -76,6 +104,48 @@ function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void
76
104
  );
77
105
  }
78
106
 
107
+ function PrimaryDock({ active, onNavigate }: { active: PrimaryRoute; onNavigate?: () => void }) {
108
+ return (
109
+ <nav className="primary-dock" aria-label="Primary navigation">
110
+ {primaryRoutes.map((item) => {
111
+ const Icon = item.icon;
112
+ const selected = active === item.id;
113
+ return (
114
+ <button
115
+ key={item.id}
116
+ className={cn("dock-button", selected && "is-active")}
117
+ onClick={() => {
118
+ go(item.id);
119
+ onNavigate?.();
120
+ }}
121
+ aria-current={selected ? "page" : undefined}
122
+ >
123
+ <Icon className="h-4 w-4" />
124
+ <span>{item.label}</span>
125
+ </button>
126
+ );
127
+ })}
128
+ </nav>
129
+ );
130
+ }
131
+
132
+ function ModeSwitch({ mode, setMode }: { mode: WorkspaceMode; setMode: (mode: WorkspaceMode) => void }) {
133
+ return (
134
+ <div className="mode-switch" aria-label="Experience mode">
135
+ {modes.map((item) => (
136
+ <button
137
+ key={item.id}
138
+ className={cn(mode === item.id && "is-active")}
139
+ onClick={() => setMode(item.id)}
140
+ aria-pressed={mode === item.id}
141
+ >
142
+ {item.label}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ );
147
+ }
148
+
79
149
  export default function App() {
80
150
  const route = useRoute();
81
151
  const { theme, setTheme, mode, setMode } = useAppStore();
@@ -93,6 +163,7 @@ export default function App() {
93
163
  React.useEffect(() => {
94
164
  document.documentElement.dataset.theme = theme;
95
165
  }, [theme]);
166
+
96
167
  React.useEffect(() => {
97
168
  const onKey = (event: KeyboardEvent) => {
98
169
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -105,94 +176,84 @@ export default function App() {
105
176
  }, []);
106
177
 
107
178
  const healthData = (health.data?.data || {}) as Record<string, unknown>;
108
- const appVersion = typeof healthData.version === "string" ? healthData.version : null;
179
+ const workspaceData = (workspace.data?.data || {}) as Record<string, unknown>;
109
180
  const desktopData = (desktop.data?.data || {}) as Record<string, unknown>;
110
- const desktopError = typeof desktopData.last_error === "string" ? desktopData.last_error : desktop.data?.error;
111
-
112
- const rail = (
113
- <aside className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-card">
114
- <div className="flex h-16 items-center gap-3 border-b border-border px-4">
115
- <div className="grid h-9 w-9 place-items-center rounded-md bg-primary text-primary-foreground font-bold">LA</div>
116
- <div>
117
- <div className="font-semibold">Lattice AI</div>
118
- <div className="text-xs text-muted-foreground">Digital Brain Desktop</div>
119
- </div>
120
- </div>
121
- <nav className="flex-1 space-y-1 overflow-auto p-3">
122
- {primaryRoutes.map((item) => {
123
- const Icon = item.icon;
124
- const active = route.primary === item.id;
125
- return (
126
- <button
127
- key={item.id}
128
- onClick={() => {
129
- go(item.id);
130
- setDrawer(false);
131
- }}
132
- className={cn(
133
- "flex min-h-14 w-full items-center gap-3 rounded-md px-3 py-2 text-left transition",
134
- active ? "bg-primary/14 text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
135
- )}
136
- >
137
- <Icon className="h-5 w-5" />
138
- <span>
139
- <span className="block text-sm font-medium">{item.label}</span>
140
- <span className="block text-xs">{item.description}</span>
141
- </span>
142
- </button>
143
- );
144
- })}
145
- </nav>
146
- <div className="border-t border-border p-3 text-xs text-muted-foreground">
147
- <div>Server: {health.data?.ok ? "online" : "unavailable"}</div>
148
- {window.__TAURI_INTERNALS__ ? (
149
- <div>Sidecar: {desktopData.running ? "running" : desktopError ? `unavailable (${desktopError})` : "starting"}</div>
150
- ) : null}
151
- <div>Workspace: {String((workspace.data?.data as Record<string, unknown>)?.active_workspace || "local")}</div>
152
- </div>
153
- </aside>
154
- );
181
+ const appVersion = typeof healthData.version === "string" ? healthData.version : null;
182
+ const activeRoute = primaryRoutes.find((item) => item.id === route.primary);
183
+ const workspaceName = String(workspaceData.active_workspace || "Personal space");
184
+ const backendReady = Boolean(health.data?.ok);
185
+ const desktopReady = !window.__TAURI_INTERNALS__ || Boolean(desktopData.running);
155
186
 
156
187
  return (
157
- <div className="min-h-screen bg-background text-foreground">
188
+ <div className="app-backdrop min-h-screen text-foreground">
189
+ <AmbientBrain />
158
190
  <CommandPalette open={palette} onClose={() => setPalette(false)} />
159
- <div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:block">{rail}</div>
191
+
192
+ <header className="app-chrome">
193
+ <div className="brand-lockup">
194
+ <button className="mobile-menu" onClick={() => setDrawer(true)} aria-label="Open navigation"><Menu className="h-5 w-5" /></button>
195
+ <button className="brand-mark" onClick={() => go("brain")} aria-label="Open Lattice home">
196
+ <BrainCircuit className="h-5 w-5" />
197
+ </button>
198
+ <div className="brand-copy">
199
+ <div className="brand-name">Lattice</div>
200
+ <div className="brand-subtitle">Digital Brain</div>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="desktop-dock">
205
+ <PrimaryDock active={route.primary} />
206
+ </div>
207
+
208
+ <div className="chrome-actions">
209
+ <button className="status-chip" onClick={() => go("settings")}>
210
+ <span className={cn("status-light", backendReady && desktopReady ? "is-ready" : "is-waiting")} />
211
+ <span>{backendReady && desktopReady ? "Ready" : "Starting"}</span>
212
+ </button>
213
+ <Button variant="outline" onClick={() => setPalette(true)}><Command className="h-4 w-4" /> Find</Button>
214
+ <Button variant="outline" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")} aria-label="Toggle theme">
215
+ {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
216
+ </Button>
217
+ </div>
218
+ </header>
219
+
160
220
  {drawer ? (
161
- <div className="fixed inset-0 z-40 lg:hidden">
162
- <button className="absolute inset-0 bg-background/70" aria-label="Close navigation" onClick={() => setDrawer(false)} />
163
- <div className="relative h-full">{rail}</div>
221
+ <div className="mobile-drawer">
222
+ <button className="drawer-scrim" aria-label="Close navigation" onClick={() => setDrawer(false)} />
223
+ <div className="drawer-panel">
224
+ <div className="drawer-header">
225
+ <div>
226
+ <div className="font-semibold">Lattice</div>
227
+ <div className="text-xs text-muted-foreground">Choose a room</div>
228
+ </div>
229
+ <Button variant="ghost" size="icon" onClick={() => setDrawer(false)} aria-label="Close navigation"><X className="h-4 w-4" /></Button>
230
+ </div>
231
+ <PrimaryDock active={route.primary} onNavigate={() => setDrawer(false)} />
232
+ </div>
164
233
  </div>
165
234
  ) : null}
166
- <div className="lg:pl-64">
167
- <header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-3 border-b border-border bg-background/95 px-4 backdrop-blur">
168
- <div className="flex min-w-0 items-center gap-2">
169
- <Button variant="ghost" size="icon" className="lg:hidden" onClick={() => setDrawer(true)}><Menu className="h-5 w-5" /></Button>
170
- <div className="min-w-0">
171
- <div className="truncate text-sm text-muted-foreground">{appVersion ? `v${appVersion}` : "Version unavailable"}</div>
172
- <div className="truncate font-medium">{primaryRoutes.find((item) => item.id === route.primary)?.label}</div>
173
- </div>
235
+
236
+ <main className="page-shell">
237
+ <section className="workspace-ribbon" aria-label="Current workspace">
238
+ <div className="min-w-0">
239
+ <div className="ribbon-kicker"><Sparkles className="h-4 w-4" /> {activeRoute?.label || "Home"}</div>
240
+ <h1>{activeRoute?.description || "A calm place to think with your knowledge."}</h1>
174
241
  </div>
175
- <div className="flex items-center gap-2">
176
- <Button variant="outline" onClick={() => setPalette(true)}><Command className="h-4 w-4" /> Search</Button>
177
- <select
178
- value={mode}
179
- onChange={(e) => setMode(e.target.value as "basic" | "advanced" | "admin")}
180
- className="h-9 rounded-md border border-border bg-background px-2 text-sm"
181
- aria-label="Workspace mode"
182
- >
183
- <option value="basic">Basic</option>
184
- <option value="advanced">Advanced</option>
185
- <option value="admin">Admin</option>
186
- </select>
187
- <Button variant="outline" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")} aria-label="Toggle theme">
188
- {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
189
- </Button>
242
+ <div className="ribbon-meta">
243
+ <div className="meta-card">
244
+ <CheckCircle2 className="h-4 w-4 text-primary" />
245
+ <span>{workspaceName}</span>
246
+ </div>
247
+ <div className="meta-card">
248
+ <span>{appVersion ? `v${appVersion}` : "Version checking"}</span>
249
+ </div>
250
+ <ModeSwitch mode={mode} setMode={setMode} />
190
251
  </div>
191
- </header>
192
- <main className="p-4 lg:p-6">
193
- <Page primary={route.primary} tab={route.tab} />
194
- </main>
195
- </div>
252
+ </section>
253
+
254
+ <FirstRunGuide />
255
+ <Page primary={route.primary} tab={route.tab} />
256
+ </main>
196
257
  </div>
197
258
  );
198
259
  }
@@ -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: error ? JSON.stringify(error) : response.statusText,
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
+ }