ltcai 3.2.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 (78) hide show
  1. package/README.md +87 -67
  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/build_v3_assets.mjs +7 -1
  38. package/scripts/capture/capture_v340.js +88 -0
  39. package/static/css/{tokens.5a595671.css → tokens.3ba22e37.css} +109 -109
  40. package/static/css/tokens.css +109 -109
  41. package/static/v3/asset-manifest.json +25 -25
  42. package/static/v3/css/{lattice.components.011e988b.css → lattice.components.9b49d614.css} +57 -32
  43. package/static/v3/css/lattice.components.css +57 -32
  44. package/static/v3/css/{lattice.shell.4920f42d.css → lattice.shell.6ceea7c8.css} +75 -31
  45. package/static/v3/css/lattice.shell.css +75 -31
  46. package/static/v3/css/lattice.tokens.css +13 -13
  47. package/static/v3/css/{lattice.tokens.c597ff81.css → lattice.tokens.e7018963.css} +13 -13
  48. package/static/v3/css/{lattice.views.3ee19d4e.css → lattice.views.22f69117.css} +98 -15
  49. package/static/v3/css/lattice.views.css +98 -15
  50. package/static/v3/js/{app.a5adc0f3.js → app.c4acfdd8.js} +1 -1
  51. package/static/v3/js/core/{api.603b978f.js → api.12b568ad.js} +126 -4
  52. package/static/v3/js/core/api.js +126 -4
  53. package/static/v3/js/core/{components.4c83e0a9.js → components.35f02e4c.js} +8 -0
  54. package/static/v3/js/core/components.js +8 -0
  55. package/static/v3/js/core/{routes.07ad6696.js → routes.d214b399.js} +16 -12
  56. package/static/v3/js/core/routes.js +16 -12
  57. package/static/v3/js/core/{shell.ea0b9ae5.js → shell.80a6ad82.js} +37 -9
  58. package/static/v3/js/core/shell.js +34 -6
  59. package/static/v3/js/views/agents.014d0b74.js +541 -0
  60. package/static/v3/js/views/agents.js +305 -57
  61. package/static/v3/js/views/{chat.718144ce.js → chat.e6dd7dd0.js} +162 -10
  62. package/static/v3/js/views/chat.js +162 -10
  63. package/static/v3/js/views/files.adad14c1.js +365 -0
  64. package/static/v3/js/views/files.js +269 -90
  65. package/static/v3/js/views/home.24f8b8ae.js +200 -0
  66. package/static/v3/js/views/home.js +96 -15
  67. package/static/v3/js/views/hooks.13845954.js +215 -0
  68. package/static/v3/js/views/hooks.js +117 -1
  69. package/static/v3/js/views/{memory.d2ed7a7c.js → memory.4ebdf474.js} +5 -4
  70. package/static/v3/js/views/memory.js +5 -4
  71. package/static/v3/js/views/{my-computer.1b2ff621.js → my-computer.c3ef5283.js} +224 -1
  72. package/static/v3/js/views/my-computer.js +224 -1
  73. package/static/v3/js/views/{settings.4f777210.js → settings.8631fa5e.js} +70 -2
  74. package/static/v3/js/views/settings.js +70 -2
  75. package/static/v3/js/views/agents.c373d48c.js +0 -293
  76. package/static/v3/js/views/files.4935197e.js +0 -186
  77. package/static/v3/js/views/home.cdde3b32.js +0 -119
  78. 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,45 +36,95 @@ 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
- }
39
+ /** Document types the backend accepts (latticeai/services/upload_service.py). */
40
+ const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv";
63
41
 
64
42
  export async function render(ctx) {
65
43
  const { h, icon, api, c, navigate, toast } = ctx;
66
44
 
67
- // Folder connection/watch needs the desktop local-agent connector, which is
68
- // not enabled in this build. Say so plainly rather than implying it's coming.
69
- const unavailableToast = () =>
70
- toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
71
-
72
45
  const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
73
46
  const srcSlot = h("span", c.sourceBadge("pending"));
74
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 }));
50
+
51
+ // ── Manual upload (works in this build; no desktop agent required) ─────────
52
+ let busy = false;
53
+ const fileInput = h("input", {
54
+ type: "file", multiple: true, accept: UPLOAD_ACCEPT,
55
+ style: { display: "none" }, "aria-hidden": "true",
56
+ on: { change: (e) => uploadFiles(e.target.files) },
57
+ });
58
+ const pickFiles = () => { if (!busy) fileInput.click(); };
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
+ }
80
+
81
+ async function uploadFiles(fileList) {
82
+ const files = Array.from(fileList || []);
83
+ if (!files.length || busy) return;
84
+ busy = true;
85
+ let ok = 0;
86
+ for (const file of files) {
87
+ toast(`Uploading “${file.name}”…`, "info");
88
+ const res = await api.uploadDocument(file);
89
+ if (res.ok && res.data && !res.data.detail && !res.data.error) {
90
+ ok++;
91
+ } else {
92
+ const detail = (res.data && (res.data.detail || res.data.error)) || "the backend is unavailable";
93
+ toast(`Could not ingest “${file.name}” — ${detail}.`, "warn");
94
+ }
95
+ }
96
+ fileInput.value = "";
97
+ busy = false;
98
+ if (ok) {
99
+ toast(`Indexed ${ok} document${ok === 1 ? "" : "s"} into the knowledge graph — now searchable in Chat and Hybrid Search.`, "ok");
100
+ }
101
+ hydrate(ctx, slots);
102
+ }
103
+
104
+ const dropZone = h("div.lt3-drop", {
105
+ on: {
106
+ dragover: (e) => { e.preventDefault(); dropZone.classList.add("is-dragover"); },
107
+ dragleave: () => dropZone.classList.remove("is-dragover"),
108
+ drop: (e) => { e.preventDefault(); dropZone.classList.remove("is-dragover"); uploadFiles(e.dataTransfer && e.dataTransfer.files); },
109
+ },
110
+ },
111
+ fileInput,
112
+ h("div.lt3-pillar__icon", icon("cloud-upload")),
113
+ h("div",
114
+ h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag documents here, or upload manually"),
115
+ h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
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."),
117
+ ),
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
+ ),
127
+ );
75
128
 
76
129
  const root = h("div.lt3-stack-6",
77
130
  c.viewHeader({
@@ -80,107 +133,233 @@ export async function render(ctx) {
80
133
  sub: "Connected sources and the documents Lattice has indexed for retrieval. Everything stays on this machine.",
81
134
  actions: [
82
135
  h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
83
- 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"),
136
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: pickFiles } }, icon("upload"), "Upload files"),
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"),
84
138
  ],
85
139
  }),
86
140
  statHost,
87
- h("div.lt3-drop",
88
- h("div.lt3-pillar__icon", icon("cloud-upload")),
89
- h("div",
90
- h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag files or connect a folder"),
91
- h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
92
- "Lattice watches the source, chunks it, embeds it, and links it into the knowledge graph."),
93
- ),
94
- h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Choose folder"),
95
- ),
141
+ dropZone,
96
142
  c.panel({
97
143
  head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
98
144
  h("div",
99
145
  h("div.lt3-eyebrow", "Index"),
100
- 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."),
101
148
  ),
102
149
  srcSlot,
103
150
  ),
104
151
  children: tableHost,
105
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
+ }),
106
164
  );
107
165
 
108
- hydrate(ctx, { statHost, srcSlot, tableHost });
166
+ hydrate(ctx, slots);
109
167
  return root;
110
168
  }
111
169
 
112
170
  async function hydrate(ctx, slots) {
113
- const { h, icon, api, c, toast } = ctx;
114
- const { statHost, srcSlot, tableHost } = 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
+ ]);
115
178
 
116
- const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
117
- const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
118
- const source = probe.source || (liveFiles ? "live" : "unavailable");
119
- const files = liveFiles || [];
179
+ hydrateDocuments(ctx, { statHost, srcSlot, tableHost, pickFiles }, docsRes);
180
+ hydrateFolders(ctx, { foldersSrc, foldersHost, slots }, sourcesRes);
181
+ }
182
+
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");
120
188
  srcSlot.replaceChildren(c.sourceBadge(source));
121
189
 
122
- // ── Stat roll-up ──────────────────────────────────────────────────────────
123
- 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;
124
192
  const sourceCount = new Set(
125
- 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()}`)),
126
194
  ).size;
127
- const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
195
+ const totalBytes = docs.reduce((sum, d) => sum + (Number(d.bytes) || 0), 0);
128
196
  statHost.replaceChildren(
129
- 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" }),
130
198
  c.stat({ label: "Indexed", value: c.fmtNum(indexedCount), icon: "circle-check" }),
131
199
  c.stat({ label: "Sources", value: c.fmtNum(sourceCount), icon: "database" }),
132
200
  c.stat({ label: "Total size", value: humanSize(totalBytes), icon: "weight" }),
133
201
  );
134
202
 
135
- // ── Empty state ─────────────────────────────────────────────────────────────
136
- 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
+ }
137
211
  tableHost.replaceChildren(c.emptyState({
138
212
  icon: "folder-off",
139
213
  title: "No documents indexed yet",
140
- body: "Connect a folder and Lattice will index it for hybrid retrieval.",
141
- action: h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm",
142
- { title: "Requires the desktop local agent (not in this build)",
143
- on: { click: () => toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn") } },
144
- icon("folder-plus"), "Connect folder"),
214
+ body: "Upload a document and Lattice will parse, embed, and link it into the knowledge graph for hybrid retrieval.",
215
+ action: h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm",
216
+ { on: { click: () => (pickFiles ? pickFiles() : null) } },
217
+ icon("upload"), "Upload files"),
145
218
  }));
146
219
  return;
147
220
  }
148
221
 
149
- // ── Table ───────────────────────────────────────────────────────────────────
222
+ // ── Table ─────────────────────────────────────────────────────────────────
150
223
  const columns = [
151
224
  {
152
- key: "name", label: "Name",
225
+ key: "filename", label: "Name",
153
226
  render: (row) => h("div.lt3-row-2",
154
- h("span.lt3-filerow__icon", icon(iconForKind(row.kind))),
155
- 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"),
156
229
  ),
157
230
  },
158
231
  {
159
- key: "path", label: "Path", width: "30%",
160
- 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)),
161
239
  },
162
240
  {
163
- key: "count", label: "Indexed", width: "92px",
164
- 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) : "—"),
165
243
  },
166
244
  {
167
- key: "status", label: "Status", width: "120px",
168
- 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")),
169
248
  },
170
249
  {
171
- key: "updated", label: "Updated", width: "104px",
250
+ key: "updated_at", label: "Updated", width: "104px",
172
251
  render: (row) => h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } },
173
- row.updated ? timeAgo(row.updated) : "—"),
252
+ (row.updated_at || row.created_at) ? timeAgo(row.updated_at || row.created_at) : "—"),
174
253
  },
175
254
  {
176
255
  key: "_actions", label: "", width: "44px",
256
+ // Per-file management is limited — say so honestly rather than implying delete/re-index.
177
257
  render: (row) => h("button.lt3-iconbtn.lt3-iconbtn--sm", {
178
- "aria-label": `Actions for ${row.name}`,
179
- title: "Requires the desktop local agent (not in this build)",
180
- 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") },
181
261
  }, icon("dots-vertical")),
182
262
  },
183
263
  ];
184
264
 
185
- 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);
186
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
+ }