ltcai 3.3.0 → 3.4.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.
Files changed (75) hide show
  1. package/README.md +85 -66
  2. package/docs/CHANGELOG.md +36 -0
  3. package/docs/architecture.md +2 -1
  4. package/docs/assets/v3.4.0/agent-run.png +0 -0
  5. package/docs/assets/v3.4.0/agents.png +0 -0
  6. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  7. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  8. package/docs/assets/v3.4.0/chat.png +0 -0
  9. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  10. package/docs/assets/v3.4.0/files.png +0 -0
  11. package/docs/assets/v3.4.0/home.png +0 -0
  12. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  13. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  14. package/docs/assets/v3.4.0/local-agent.png +0 -0
  15. package/docs/assets/v3.4.0/memory.png +0 -0
  16. package/docs/assets/v3.4.0/settings.png +0 -0
  17. package/docs/assets/v3.4.0/vision-input.png +0 -0
  18. package/docs/assets/v3.4.0/workflows.png +0 -0
  19. package/knowledge_graph.py +45 -0
  20. package/knowledge_graph_api.py +10 -0
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/agents.py +3 -0
  23. package/latticeai/api/hooks.py +39 -0
  24. package/latticeai/api/local_files.py +41 -0
  25. package/latticeai/api/models.py +36 -1
  26. package/latticeai/api/tools.py +16 -1
  27. package/latticeai/api/workflow_designer.py +2 -1
  28. package/latticeai/core/hooks.py +398 -2
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/multi_agent.py +1 -1
  31. package/latticeai/core/workflow_engine.py +21 -1
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/server_app.py +40 -0
  34. package/latticeai/services/agent_runtime.py +46 -1
  35. package/latticeai/services/upload_service.py +17 -0
  36. package/package.json +1 -1
  37. package/scripts/capture/capture_v340.js +88 -0
  38. package/static/css/{tokens.8b8e31bd.css → tokens.3ba22e37.css} +109 -109
  39. package/static/css/tokens.css +109 -109
  40. package/static/v3/asset-manifest.json +24 -24
  41. package/static/v3/css/{lattice.components.011e988b.css → lattice.components.9b49d614.css} +57 -32
  42. package/static/v3/css/lattice.components.css +57 -32
  43. package/static/v3/css/{lattice.shell.4920f42d.css → lattice.shell.6ceea7c8.css} +75 -31
  44. package/static/v3/css/lattice.shell.css +75 -31
  45. package/static/v3/css/lattice.tokens.css +13 -13
  46. package/static/v3/css/{lattice.tokens.c597ff81.css → lattice.tokens.e7018963.css} +13 -13
  47. package/static/v3/css/{lattice.views.1d326beb.css → lattice.views.22f69117.css} +93 -15
  48. package/static/v3/css/lattice.views.css +93 -15
  49. package/static/v3/js/{app.cf5bb712.js → app.c4acfdd8.js} +1 -1
  50. package/static/v3/js/core/{api.113660c5.js → api.12b568ad.js} +67 -0
  51. package/static/v3/js/core/api.js +67 -0
  52. package/static/v3/js/core/{components.4c83e0a9.js → components.35f02e4c.js} +8 -0
  53. package/static/v3/js/core/components.js +8 -0
  54. package/static/v3/js/core/{routes.07ad6696.js → routes.d214b399.js} +16 -12
  55. package/static/v3/js/core/routes.js +16 -12
  56. package/static/v3/js/core/{shell.9e707234.js → shell.80a6ad82.js} +37 -9
  57. package/static/v3/js/core/shell.js +34 -6
  58. package/static/v3/js/views/agents.014d0b74.js +541 -0
  59. package/static/v3/js/views/agents.js +305 -57
  60. package/static/v3/js/views/{chat.c48fd9e2.js → chat.e6dd7dd0.js} +161 -9
  61. package/static/v3/js/views/chat.js +161 -9
  62. package/static/v3/js/views/files.adad14c1.js +365 -0
  63. package/static/v3/js/views/files.js +212 -79
  64. package/static/v3/js/views/home.24f8b8ae.js +200 -0
  65. package/static/v3/js/views/home.js +96 -15
  66. package/static/v3/js/views/hooks.13845954.js +215 -0
  67. package/static/v3/js/views/hooks.js +117 -1
  68. package/static/v3/js/views/{my-computer.1b2ff621.js → my-computer.c3ef5283.js} +224 -1
  69. package/static/v3/js/views/my-computer.js +224 -1
  70. package/static/v3/js/views/{settings.c7b0cc05.js → settings.8631fa5e.js} +54 -0
  71. package/static/v3/js/views/settings.js +54 -0
  72. package/static/v3/js/views/agents.c373d48c.js +0 -293
  73. package/static/v3/js/views/files.8464634a.js +0 -232
  74. package/static/v3/js/views/home.cdde3b32.js +0 -119
  75. package/static/v3/js/views/hooks.f3edebca.js +0 -99
@@ -1,8 +1,11 @@
1
1
  /* ============================================================================
2
- * View: Files — connected sources & indexed documents.
3
- * Lists the sources the workspace has indexed, with a human-readable status
4
- * roll-up. Data comes from /workspace/indexing (live); when indexing is
5
- * unavailable, the table renders an empty unavailable state.
2
+ * View: Files — uploaded documents, connected folders & folder watch.
3
+ * The headline table lists the documents Lattice has actually ingested
4
+ * (/knowledge-graph/documents, live) with a per-doc index-state pill. A manual
5
+ * upload drop zone ingests files on-device, and Connect Folder indexes a local
6
+ * directory (+ watches it for changes) over the on-device runtime. When a
7
+ * surface is unavailable its panel renders an honest empty/unavailable state —
8
+ * no counts or statuses are fabricated.
6
9
  *
7
10
  * View contract (shared by all views):
8
11
  * export async function render(ctx) -> single DOM node
@@ -11,15 +14,15 @@
11
14
 
12
15
  import { timeAgo } from "../core/dom.js";
13
16
 
14
- /** Tabler glyph per file kind — keeps the table scannable. */
15
- const KIND_ICON = {
16
- markdown: "file-text",
17
- config: "settings",
18
- image: "photo",
19
- data: "table",
20
- default: "file",
17
+ /** Tabler glyph per uploaded-document extension. */
18
+ const EXT_ICON = {
19
+ pdf: "file-type-pdf", docx: "file-type-docx", doc: "file-text",
20
+ xlsx: "file-spreadsheet", xls: "file-spreadsheet", csv: "table",
21
+ pptx: "presentation", ppt: "presentation",
22
+ md: "file-text", txt: "file-text", json: "file-code",
23
+ png: "photo", jpg: "photo", jpeg: "photo", gif: "photo",
21
24
  };
22
- const iconForKind = (k) => KIND_ICON[k] || KIND_ICON.default;
25
+ const iconForExt = (ext) => EXT_ICON[String(ext || "").replace(/^\./, "").toLowerCase()] || "file";
23
26
 
24
27
  /** Bytes → compact human string (1.0 KB / 4.7 KB / 180 KB / 1.2 MB). */
25
28
  function humanSize(bytes) {
@@ -33,49 +36,17 @@ function humanSize(bytes) {
33
36
  return `${v.toFixed(v >= 100 || Number.isInteger(v) ? 0 : 1)} ${units[i]}`;
34
37
  }
35
38
 
36
- /** Live shape is {sources:[...]}; legacy {files:[...]} payloads normalize too. */
37
- function normalize(data) {
38
- if (data && Array.isArray(data.sources)) {
39
- return data.sources.map((source) => ({
40
- name: source.label || source.id || "local source",
41
- kind: "default",
42
- size: null,
43
- path: source.root_path || source.id || "",
44
- indexed: Number(source.success_count || 0) > 0,
45
- updated: source.last_run_at || source.updated_at || null,
46
- count: Number(source.success_count || 0),
47
- status: source.status || (source.watch_active ? "watching" : "idle"),
48
- }));
49
- }
50
- const list = Array.isArray(data) ? data : (data && Array.isArray(data.files) ? data.files : null);
51
- if (!list) return null;
52
- return list.map((f) => ({
53
- name: f.name || (f.path ? String(f.path).split("/").pop() : "untitled"),
54
- kind: f.kind || "default",
55
- size: Number(f.size) || 0,
56
- path: f.path || f.name || "",
57
- indexed: f.indexed === true,
58
- updated: f.updated || f.modified || f.mtime || null,
59
- count: Number(f.count || 0),
60
- status: f.status || null,
61
- }));
62
- }
63
-
64
39
  /** Document types the backend accepts (latticeai/services/upload_service.py). */
65
40
  const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv";
66
41
 
67
42
  export async function render(ctx) {
68
43
  const { h, icon, api, c, navigate, toast } = ctx;
69
44
 
70
- // Connecting/watching a *folder* needs the desktop local-agent connector,
71
- // which is not enabled in this build. Say so plainly. Manual document upload
72
- // below works without it.
73
- const unavailableToast = () =>
74
- toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
75
-
76
45
  const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
77
46
  const srcSlot = h("span", c.sourceBadge("pending"));
78
47
  const tableHost = h("div", c.loading({ lines: 4 }));
48
+ const foldersSrc = h("span", c.sourceBadge("pending"));
49
+ const foldersHost = h("div", c.loading({ lines: 3 }));
79
50
 
80
51
  // ── Manual upload (works in this build; no desktop agent required) ─────────
81
52
  let busy = false;
@@ -85,7 +56,27 @@ export async function render(ctx) {
85
56
  on: { change: (e) => uploadFiles(e.target.files) },
86
57
  });
87
58
  const pickFiles = () => { if (!busy) fileInput.click(); };
88
- const slots = { statHost, srcSlot, tableHost, pickFiles };
59
+ const slots = { statHost, srcSlot, tableHost, foldersSrc, foldersHost, pickFiles, connectFolder };
60
+
61
+ // ── Connect Folder — index a local directory on-device and watch it ────────
62
+ // Available now via the on-device runtime (one call does
63
+ // request → self-approve → index + watch). No desktop agent required.
64
+ async function connectFolder() {
65
+ if (busy) return;
66
+ const path = window.prompt("Connect a local folder to index (absolute path)", "~/Documents");
67
+ if (!path || !String(path).trim()) return;
68
+ const target = String(path).trim();
69
+ busy = true;
70
+ toast(`Connecting “${target}” — indexing on-device…`, "info");
71
+ const res = await api.connectFolder(target, { watch: true });
72
+ busy = false;
73
+ if (res.ok) {
74
+ toast(`Connected and indexing ${target} — now watched for changes.`, "ok");
75
+ hydrate(ctx, slots);
76
+ } else {
77
+ toast(res.error || "Could not connect the folder.", "warn");
78
+ }
79
+ }
89
80
 
90
81
  async function uploadFiles(fileList) {
91
82
  const files = Array.from(fileList || []);
@@ -124,7 +115,15 @@ export async function render(ctx) {
124
115
  h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
125
116
  "Lattice parses each file, chunks it, embeds it, and links it into the knowledge graph. PDF · DOCX · XLSX · PPTX · TXT · MD · CSV, up to 10 MB each."),
126
117
  ),
127
- h("button.lt3-btn.lt3-btn--primary", { type: "button", on: { click: pickFiles } }, icon("upload"), "Upload files"),
118
+ h("div.lt3-drop__meta",
119
+ c.pill("Manual upload available", "ok", { dot: true }),
120
+ c.pill("Connect a local folder — indexed & watched on-device", "info", { dot: true }),
121
+ c.pill("Search + Chat ready after indexing", "info", { dot: true }),
122
+ ),
123
+ h("div.lt3-row-2",
124
+ h("button.lt3-btn.lt3-btn--primary", { type: "button", on: { click: pickFiles } }, icon("upload"), "Upload files"),
125
+ h("button.lt3-btn.lt3-btn--ghost", { type: "button", on: { click: connectFolder } }, icon("folder-plus"), "Connect folder"),
126
+ ),
128
127
  );
129
128
 
130
129
  const root = h("div.lt3-stack-6",
@@ -135,7 +134,7 @@ export async function render(ctx) {
135
134
  actions: [
136
135
  h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
137
136
  h("button.lt3-btn.lt3-btn--primary", { on: { click: pickFiles } }, icon("upload"), "Upload files"),
138
- h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Connect folder"),
137
+ h("button.lt3-btn.lt3-btn--ghost", { title: "Index a local folder on-device and watch it for changes", on: { click: connectFolder } }, icon("folder-plus"), "Connect folder"),
139
138
  ],
140
139
  }),
141
140
  statHost,
@@ -144,12 +143,24 @@ export async function render(ctx) {
144
143
  head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
145
144
  h("div",
146
145
  h("div.lt3-eyebrow", "Index"),
147
- h("h3.lt3-panel__title", "Indexed documents"),
146
+ h("h3.lt3-panel__title", "Uploaded documents"),
147
+ h("p.lt3-panel__sub", "Every file Lattice has parsed, chunked, embedded and linked into the knowledge graph."),
148
148
  ),
149
149
  srcSlot,
150
150
  ),
151
151
  children: tableHost,
152
152
  }),
153
+ c.panel({
154
+ head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
155
+ h("div",
156
+ h("div.lt3-eyebrow", "Local sources"),
157
+ h("h3.lt3-panel__title", "Connected folders & folder watch"),
158
+ h("p.lt3-panel__sub", "Local directories Lattice indexes on-device and re-indexes when their files change."),
159
+ ),
160
+ foldersSrc,
161
+ ),
162
+ children: foldersHost,
163
+ }),
153
164
  );
154
165
 
155
166
  hydrate(ctx, slots);
@@ -157,30 +168,46 @@ export async function render(ctx) {
157
168
  }
158
169
 
159
170
  async function hydrate(ctx, slots) {
160
- const { h, icon, api, c, toast } = ctx;
161
- const { statHost, srcSlot, tableHost, pickFiles } = slots;
171
+ const { statHost, srcSlot, tableHost, foldersSrc, foldersHost, pickFiles } = slots;
172
+
173
+ // Fetch the documents (headline table) and connected local sources in parallel.
174
+ const [docsRes, sourcesRes] = await Promise.all([
175
+ ctx.api.documents(200),
176
+ ctx.api.localSources(),
177
+ ]);
178
+
179
+ hydrateDocuments(ctx, { statHost, srcSlot, tableHost, pickFiles }, docsRes);
180
+ hydrateFolders(ctx, { foldersSrc, foldersHost, slots }, sourcesRes);
181
+ }
162
182
 
163
- const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
164
- const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
165
- const source = probe.source || (liveFiles ? "live" : "unavailable");
166
- const files = liveFiles || [];
183
+ /** Headline "Uploaded documents" table + stat roll-up. Data: api.documents(). */
184
+ function hydrateDocuments(ctx, { statHost, srcSlot, tableHost, pickFiles }, docsRes) {
185
+ const { h, icon, c, toast } = ctx;
186
+ const docs = Array.isArray(docsRes.data) ? docsRes.data : [];
187
+ const source = docsRes.source || (docsRes.ok ? "live" : "unavailable");
167
188
  srcSlot.replaceChildren(c.sourceBadge(source));
168
189
 
169
- // ── Stat roll-up ──────────────────────────────────────────────────────────
170
- const indexedCount = files.filter((f) => f.indexed).length;
190
+ // ── Stat roll-up (driven by the real documents list) ──────────────────────
191
+ const indexedCount = docs.filter((d) => d.indexed === true || d.ingest_state === "indexed").length;
171
192
  const sourceCount = new Set(
172
- files.map((f) => (f.path.includes("/") ? f.path.split("/")[0] : "root")),
193
+ docs.map((d) => (d.uploader ? `u:${d.uploader}` : `e:${String(d.ext || "").toLowerCase()}`)),
173
194
  ).size;
174
- const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
195
+ const totalBytes = docs.reduce((sum, d) => sum + (Number(d.bytes) || 0), 0);
175
196
  statHost.replaceChildren(
176
- c.stat({ label: "Total files", value: c.fmtNum(files.length), icon: "files" }),
197
+ c.stat({ label: "Total files", value: c.fmtNum(docs.length), icon: "files" }),
177
198
  c.stat({ label: "Indexed", value: c.fmtNum(indexedCount), icon: "circle-check" }),
178
199
  c.stat({ label: "Sources", value: c.fmtNum(sourceCount), icon: "database" }),
179
200
  c.stat({ label: "Total size", value: humanSize(totalBytes), icon: "weight" }),
180
201
  );
181
202
 
182
- // ── Empty state ─────────────────────────────────────────────────────────────
183
- if (!files.length) {
203
+ // ── Empty / unavailable state ─────────────────────────────────────────────
204
+ if (!docs.length) {
205
+ if (!docsRes.ok) {
206
+ tableHost.replaceChildren(c.errorState(
207
+ docsRes.error || "The document index is unavailable. Start the backend with the knowledge graph enabled.",
208
+ ));
209
+ return;
210
+ }
184
211
  tableHost.replaceChildren(c.emptyState({
185
212
  icon: "folder-off",
186
213
  title: "No documents indexed yet",
@@ -192,41 +219,147 @@ async function hydrate(ctx, slots) {
192
219
  return;
193
220
  }
194
221
 
195
- // ── Table ───────────────────────────────────────────────────────────────────
222
+ // ── Table ─────────────────────────────────────────────────────────────────
196
223
  const columns = [
197
224
  {
198
- key: "name", label: "Name",
225
+ key: "filename", label: "Name",
199
226
  render: (row) => h("div.lt3-row-2",
200
- h("span.lt3-filerow__icon", icon(iconForKind(row.kind))),
201
- h("span", { style: { "font-weight": "var(--lt3-weight-medium)" } }, row.name),
227
+ h("span.lt3-filerow__icon", icon(iconForExt(row.ext))),
228
+ h("span", { style: { "font-weight": "var(--lt3-weight-medium)" } }, row.filename || "untitled"),
202
229
  ),
203
230
  },
204
231
  {
205
- key: "path", label: "Path", width: "30%",
206
- render: (row) => h("span.lt3-mono.lt3-faint", row.path || "—"),
232
+ key: "uploader", label: "Uploaded by", width: "26%",
233
+ render: (row) => h("span.lt3-mono.lt3-faint", row.uploader || "—"),
234
+ },
235
+ {
236
+ key: "chars", label: "Size", width: "100px",
237
+ render: (row) => h("span.lt3-mono",
238
+ Number(row.chars) > 0 ? `${c.fmtNum(row.chars)} chars` : humanSize(row.bytes)),
207
239
  },
208
240
  {
209
- key: "count", label: "Indexed", width: "92px",
210
- render: (row) => h("span.lt3-mono", row.count ? c.fmtNum(row.count) : humanSize(row.size)),
241
+ key: "chunks", label: "Chunks", width: "84px",
242
+ render: (row) => h("span.lt3-mono", Number(row.chunks) > 0 ? c.fmtNum(row.chunks) : "—"),
211
243
  },
212
244
  {
213
- key: "status", label: "Status", width: "120px",
214
- render: (row) => c.statePill(row.indexed ? "indexed" : (row.status || "pending")),
245
+ key: "ingest_state", label: "Index", width: "120px",
246
+ // "indexed" green, "ingested" warn (via components STATE_VARIANT).
247
+ render: (row) => c.statePill(row.ingest_state || (row.indexed ? "indexed" : "ingested")),
215
248
  },
216
249
  {
217
- key: "updated", label: "Updated", width: "104px",
250
+ key: "updated_at", label: "Updated", width: "104px",
218
251
  render: (row) => h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } },
219
- row.updated ? timeAgo(row.updated) : "—"),
252
+ (row.updated_at || row.created_at) ? timeAgo(row.updated_at || row.created_at) : "—"),
220
253
  },
221
254
  {
222
255
  key: "_actions", label: "", width: "44px",
256
+ // Per-file management is limited — say so honestly rather than implying delete/re-index.
223
257
  render: (row) => h("button.lt3-iconbtn.lt3-iconbtn--sm", {
224
- "aria-label": `Actions for ${row.name}`,
225
- title: "Requires the desktop local agent (not in this build)",
226
- on: { click: () => toast(`Per-file actions require the Lattice desktop local agentnot available in this build.`, "warn") },
258
+ "aria-label": `Document info for ${row.filename || "file"}`,
259
+ title: "Per-document management isn't available yet",
260
+ on: { click: () => toast("Per-document management (delete / re-index) isn't available yetre-upload to refresh a file.", "info") },
227
261
  }, icon("dots-vertical")),
228
262
  },
229
263
  ];
230
264
 
231
- tableHost.replaceChildren(c.table(columns, files));
265
+ tableHost.replaceChildren(c.table(columns, docs));
266
+ }
267
+
268
+ /** Connected local folders + folder-watch state. Data: api.localSources(). */
269
+ function hydrateFolders(ctx, { foldersSrc, foldersHost, slots }, res) {
270
+ const { h, icon, c, toast } = ctx;
271
+ const data = res.data || {};
272
+ const sources = Array.isArray(data.sources) ? data.sources : [];
273
+ const watch = data.watch || {};
274
+ const source = res.source || (res.ok ? "live" : "unavailable");
275
+ foldersSrc.replaceChildren(c.sourceBadge(source));
276
+
277
+ const kids = [];
278
+
279
+ // Honest note when filesystem watching can't run (watchdog dependency missing).
280
+ if (watch.available === false) {
281
+ kids.push(c.banner(
282
+ watch.error
283
+ ? `Folder watch is off: ${watch.error}`
284
+ : "Folder watch needs the watchdog dependency — connected folders index once but won't re-index automatically until it's installed.",
285
+ "warn",
286
+ "alert-triangle",
287
+ ));
288
+ }
289
+
290
+ if (!sources.length) {
291
+ if (!res.ok) {
292
+ kids.push(c.errorState("Local sources are unavailable — the on-device runtime isn't reachable."));
293
+ } else {
294
+ kids.push(c.emptyState({
295
+ icon: "folder-plus",
296
+ title: "No folders connected",
297
+ body: "Connect a local folder — Lattice indexes it on-device and watches it for changes.",
298
+ action: h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm",
299
+ { on: { click: () => (slots.connectFolder ? slots.connectFolder() : null) } },
300
+ icon("folder-plus"), "Connect folder"),
301
+ }));
302
+ }
303
+ foldersHost.replaceChildren(...kids);
304
+ return;
305
+ }
306
+
307
+ // ── Connected-folders table ───────────────────────────────────────────────
308
+ async function stopWatching(id) {
309
+ toast("Stopping folder watch…", "info");
310
+ const stop = await ctx.api.localWatchStop(id);
311
+ if (stop.ok && stop.data && !stop.data.detail && !stop.data.error) {
312
+ toast("Stopped watching that folder.", "ok");
313
+ } else {
314
+ const detail = (stop.data && (stop.data.detail || stop.data.error)) || "the runtime is unavailable";
315
+ toast(`Could not stop watching — ${detail}.`, "warn");
316
+ }
317
+ hydrate(ctx, slots);
318
+ }
319
+
320
+ const columns = [
321
+ {
322
+ key: "label", label: "Folder",
323
+ render: (row) => h("div",
324
+ h("div.lt3-row-2",
325
+ h("span.lt3-filerow__icon", icon("folder")),
326
+ h("span", { style: { "font-weight": "var(--lt3-weight-medium)" } }, row.label || "Local folder"),
327
+ ),
328
+ h("div.lt3-mono.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)", "margin-top": "var(--lt3-space-1)" } },
329
+ row.root_path || row.id || "—"),
330
+ ),
331
+ },
332
+ {
333
+ key: "success_count", label: "Indexed", width: "92px",
334
+ render: (row) => h("span.lt3-mono", c.fmtNum(Number(row.success_count) || 0)),
335
+ },
336
+ {
337
+ key: "watch_active", label: "Watch", width: "120px",
338
+ render: (row) => c.statePill(row.watch_active ? "watching" : "idle"),
339
+ },
340
+ {
341
+ key: "watch_status", label: "Last activity", width: "150px",
342
+ render: (row) => {
343
+ const ws = row.watch_status || {};
344
+ if (ws.last_error) {
345
+ return h("span", { style: { color: "var(--danger)", "font-size": "var(--lt3-text-xs)" } }, ws.last_error);
346
+ }
347
+ const at = ws.last_event_at || ws.last_indexed_at;
348
+ const label = ws.last_event_at ? "event" : (ws.last_indexed_at ? "indexed" : "");
349
+ return h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } },
350
+ at ? `${timeAgo(at)}${label ? ` · ${label}` : ""}` : "—");
351
+ },
352
+ },
353
+ {
354
+ key: "_stop", label: "", width: "120px",
355
+ render: (row) => row.watch_active
356
+ ? h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", {
357
+ on: { click: () => stopWatching(row.id) },
358
+ }, icon("player-stop"), "Stop watching")
359
+ : h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } }, "Not watching"),
360
+ },
361
+ ];
362
+
363
+ kids.push(c.table(columns, sources));
364
+ foldersHost.replaceChildren(...kids);
232
365
  }
@@ -0,0 +1,200 @@
1
+ /* ============================================================================
2
+ * View: Home — the workspace command center.
3
+ * Leads with the product identity (the retrieval lattice: Knowledge Graph +
4
+ * Vector Index + Hybrid Search) and routes into every primary area.
5
+ *
6
+ * View contract (shared by all views):
7
+ * export async function render(ctx) -> single DOM node
8
+ * ctx = { h, icon, api, store, c, route, params, navigate, toast }
9
+ * ========================================================================== */
10
+
11
+ export async function render(ctx) {
12
+ const { h, icon, api, store, c, navigate } = ctx;
13
+ const ws = store.activeWorkspace();
14
+ const readinessHost = h("div.lt3-readiness", c.loading({ lines: 4 }));
15
+ const activityHost = h("div", c.loading({ lines: 3 }));
16
+
17
+ const root = h("div.lt3-stack-6",
18
+ c.viewHeader({
19
+ eyebrow: "Local-first AI workspace",
20
+ title: `Welcome to ${ws.name}`,
21
+ sub: "Everything you index stays on this machine. Ask questions, explore the graph, and fuse structure with semantics — no data leaves your computer.",
22
+ actions: [
23
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("hybrid-search") } }, icon("arrows-join"), "Hybrid search"),
24
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "New chat"),
25
+ ],
26
+ }),
27
+ buildHero(ctx, readinessHost),
28
+ h("section",
29
+ c.sectionHead("Retrieval lattice", h("span", { id: "home-idx-src" }, c.sourceBadge("pending"))),
30
+ h("div", { id: "home-pillars" }, c.loading({ lines: 2, block: true })),
31
+ ),
32
+ h("section",
33
+ c.sectionHead("Jump back in"),
34
+ buildQuickGrid(ctx),
35
+ ),
36
+ h("div.lt3-grid-2",
37
+ c.panel({ eyebrow: "Index", title: "Connected sources", children: h("div", { id: "home-sources" }, c.loading({ lines: 3 })) }),
38
+ c.panel({ eyebrow: "Workspace", title: "At a glance", children: h("div", { id: "home-stats" }, c.loading({ lines: 3 })) }),
39
+ ),
40
+ c.panel({
41
+ eyebrow: "Activity",
42
+ title: "Recent activity",
43
+ sub: "Shown only when the local backend provides trace history.",
44
+ children: activityHost,
45
+ className: "lt3-panel--activity",
46
+ }),
47
+ );
48
+
49
+ hydrate(ctx, root, { readinessHost, activityHost });
50
+ return root;
51
+ }
52
+
53
+ function buildHero({ h, icon, navigate }, readinessHost) {
54
+ return h("div.lt3-hero",
55
+ h("div",
56
+ h("div.lt3-eyebrow.lt3-hero__eyebrow", icon("sparkles"), "Knowledge Graph · Vector Index · Hybrid Search"),
57
+ h("h2.lt3-hero__title", "Local workspace status, without pretending."),
58
+ h("p.lt3-hero__sub", "Lattice shows what is ready, what is unavailable, and what needs a local runtime before Chat, Search, Knowledge, and Memory can work together."),
59
+ h("div.lt3-hero__actions",
60
+ h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "Start chat"),
61
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("files") } }, icon("upload"), "Upload files"),
62
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("models") } }, icon("cpu"), "Check models"),
63
+ ),
64
+ h("div.lt3-mini-lattice", { style: { "margin-top": "var(--lt3-space-6)" } },
65
+ h("div.lt3-mini-lattice__node", h("b", "Knowledge"), h("span", "Entities and relations")),
66
+ h("div.lt3-mini-lattice__node", h("b", "Vectors"), h("span", "Local semantic recall")),
67
+ h("div.lt3-mini-lattice__node", h("b", "Hybrid"), h("span", "Fused answer grounding")),
68
+ ),
69
+ ),
70
+ h("aside.lt3-hero__aside",
71
+ h("div.lt3-eyebrow", "Readiness"),
72
+ readinessHost,
73
+ ),
74
+ );
75
+ }
76
+
77
+ const QUICK = [
78
+ { key: "chat", icon: "message-2", title: "Chat", desc: "Grounded conversation." },
79
+ { key: "files", icon: "folders", title: "Files", desc: "Sources and indexing." },
80
+ { key: "hybrid-search", icon: "arrows-join", title: "Search", desc: "Fuse graph + vector recall." },
81
+ { key: "knowledge-graph", icon: "chart-dots-3", title: "Knowledge", desc: "Browse entities and relations." },
82
+ { key: "memory", icon: "brain", title: "Memory", desc: "Inspect long-term recall." },
83
+ { key: "models", icon: "cpu", title: "Models", desc: "Local MLX runtime." },
84
+ ];
85
+
86
+ function buildQuickGrid({ h, icon, navigate }) {
87
+ return h("div.lt3-quickgrid",
88
+ QUICK.map((q) => h("button.lt3-quick", { style: { "text-align": "left" }, on: { click: () => navigate(q.key) } },
89
+ h("div.lt3-quick__icon", icon(q.icon)),
90
+ h("div.lt3-quick__title", q.title),
91
+ h("div.lt3-quick__desc", q.desc),
92
+ )),
93
+ );
94
+ }
95
+
96
+ async function hydrate(ctx, root, hosts) {
97
+ const { h, icon, api, store, c } = ctx;
98
+ const { readinessHost, activityHost } = hosts;
99
+ const numFmt = c.fmtNum;
100
+
101
+ // Index status → pillars + sources + topbar chip.
102
+ const idx = store.get().indexStatus
103
+ ? { data: store.get().indexStatus, source: "live" }
104
+ : await api.indexStatus().then((r) => { store.setIndexStatus(r.data); return r; });
105
+
106
+ root.querySelector("#home-idx-src")?.replaceChildren(c.sourceBadge(idx.source));
107
+ root.querySelector("#home-pillars")?.replaceChildren(c.pillars(idx.data));
108
+
109
+ const sources = (idx.data && idx.data.sources) || [];
110
+ const srcHost = root.querySelector("#home-sources");
111
+ if (srcHost) {
112
+ srcHost.replaceChildren(
113
+ sources.length
114
+ ? h("div.lt3-stack-3", sources.map((s) => h("div.lt3-stack-2",
115
+ h("div.lt3-row", { style: { "justify-content": "space-between" } },
116
+ h("div.lt3-row-2", icon("database"), h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, s.label)),
117
+ c.statePill(s.state),
118
+ ),
119
+ c.meter(s.progress ?? (s.state === "indexed" ? 1 : 0.5), s.state === "indexing" ? "warn" : "vector"),
120
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${numFmt(s.files)} files`),
121
+ )))
122
+ : c.emptyState({ icon: "database-off", title: "No sources connected", body: "Upload documents to start indexing. Folder watching requires the desktop local agent." }),
123
+ );
124
+ }
125
+
126
+ const [os, models, memory, traces] = await Promise.all([
127
+ api.workspaceOs(),
128
+ api.models(),
129
+ api.memoryManager(),
130
+ api.get("/workspace/traces", { traces: [] }),
131
+ ]);
132
+ renderReadiness({ h, icon, c, readinessHost, idx, models, os, memory });
133
+
134
+ // Workspace counts.
135
+ const counts = (os.data && os.data.counts) || {};
136
+ const statHost = root.querySelector("#home-stats");
137
+ if (statHost) {
138
+ statHost.replaceChildren(
139
+ h("div.lt3-statrow",
140
+ c.stat({ label: "Memories", value: numFmt(counts.memories), icon: "brain" }),
141
+ c.stat({ label: "Traces", value: numFmt(counts.traces), icon: "route" }),
142
+ c.stat({ label: "Workflows", value: numFmt(counts.workflows), icon: "git-branch" }),
143
+ c.stat({ label: "Skills", value: numFmt(counts.skills), icon: "puzzle" }),
144
+ ),
145
+ h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.sourceBadge(os.source)),
146
+ );
147
+ }
148
+
149
+ renderActivity({ h, icon, c, activityHost, traces });
150
+ }
151
+
152
+ function renderReadiness({ h, icon, c, readinessHost, idx, models, os, memory }) {
153
+ const pipes = (idx.data && idx.data.pipelines) || {};
154
+ const vectorReady = String(pipes.vector_index?.state || "").toLowerCase() === "ready";
155
+ const graphReady = String(pipes.knowledge_graph?.state || "").toLowerCase() === "ready";
156
+ const modelName = models.data && models.data.current;
157
+ const counts = (os.data && os.data.counts) || {};
158
+ const memSources = (memory.data && memory.data.sources) || [];
159
+ readinessHost.replaceChildren(
160
+ readinessRow({ h, icon, c, ic: "server", title: "Backend", meta: idx.source === "live" ? "Live local API" : "Local API unavailable", state: idx.source === "live" ? "ready" : "pending" }),
161
+ readinessRow({ h, icon, c, ic: "cpu", title: "Model", meta: modelName ? shortModel(modelName) : "No model loaded", state: modelName ? "ready" : "pending" }),
162
+ readinessRow({ h, icon, c, ic: "database", title: "Retrieval", meta: graphReady && vectorReady ? "Graph and vector ready" : "Index needs data or rebuild", state: graphReady && vectorReady ? "ready" : "pending" }),
163
+ readinessRow({ h, icon, c, ic: "brain", title: "Memory", meta: memSources.length ? `${c.fmtNum(counts.memories)} memories across ${memSources.length} tiers` : "Memory backend unavailable or empty", state: memSources.length ? "ready" : "idle" }),
164
+ );
165
+ }
166
+
167
+ function readinessRow({ h, icon, c, ic, title, meta, state }) {
168
+ return h("div.lt3-readiness__row",
169
+ h("div.lt3-readiness__icon", icon(ic)),
170
+ h("div", h("div.lt3-readiness__title", title), h("div.lt3-readiness__meta", meta)),
171
+ c.statePill(state),
172
+ );
173
+ }
174
+
175
+ function renderActivity({ h, icon, c, activityHost, traces }) {
176
+ const rows = (traces.data && Array.isArray(traces.data.traces)) ? traces.data.traces : [];
177
+ if (!rows.length) {
178
+ activityHost.replaceChildren(c.emptyState({
179
+ icon: "history-off",
180
+ title: "No recent activity available",
181
+ body: traces.source === "live" ? "The backend returned no trace history yet." : "Start the backend to show recent local workspace activity.",
182
+ }));
183
+ return;
184
+ }
185
+ activityHost.replaceChildren(h("div.lt3-list", rows.slice(0, 6).map((tr) =>
186
+ h("div.lt3-list__item",
187
+ h("span.lt3-avatar", { style: { width: "28px", height: "28px" } }, icon("route")),
188
+ h("div.lt3-list__body",
189
+ h("div.lt3-list__title", tr.question || tr.event_type || "Workspace event"),
190
+ h("div.lt3-list__meta", [tr.confidence != null ? `${Math.round(Number(tr.confidence) * 100)}% confidence` : null, tr.created_at || tr.timestamp || null].filter(Boolean).join(" · ")),
191
+ ),
192
+ c.sourceBadge(traces.source),
193
+ ))));
194
+ }
195
+
196
+ function shortModel(id) {
197
+ const s = String(id || "");
198
+ const tail = s.includes("/") ? s.split("/").pop() : s;
199
+ return tail.length > 28 ? tail.slice(0, 27) + "…" : tail;
200
+ }