ltcai 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/README.md +33 -24
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +84 -0
  4. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  5. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  6. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  7. package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +97 -0
  8. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +91 -0
  9. package/docs/V4_2_VALIDATION_REPORT.md +89 -0
  10. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +31 -26
  11. package/frontend/index.html +24 -0
  12. package/frontend/openapi.json +14436 -0
  13. package/frontend/src/App.tsx +184 -0
  14. package/frontend/src/api/client.ts +320 -0
  15. package/frontend/src/api/openapi.ts +16921 -0
  16. package/frontend/src/components/primitives.tsx +204 -0
  17. package/frontend/src/components/ui/badge.tsx +27 -0
  18. package/frontend/src/components/ui/button.tsx +37 -0
  19. package/frontend/src/components/ui/card.tsx +22 -0
  20. package/frontend/src/components/ui/input.tsx +16 -0
  21. package/frontend/src/components/ui/textarea.tsx +16 -0
  22. package/frontend/src/lib/utils.ts +33 -0
  23. package/frontend/src/main.tsx +23 -0
  24. package/frontend/src/pages/Act.tsx +245 -0
  25. package/frontend/src/pages/Ask.tsx +200 -0
  26. package/frontend/src/pages/Brain.tsx +267 -0
  27. package/frontend/src/pages/Capture.tsx +158 -0
  28. package/frontend/src/pages/Library.tsx +187 -0
  29. package/frontend/src/pages/System.tsx +378 -0
  30. package/frontend/src/routes.ts +85 -0
  31. package/frontend/src/store/appStore.ts +54 -0
  32. package/frontend/src/styles.css +107 -0
  33. package/kg_schema.py +1 -1
  34. package/knowledge_graph.py +4 -4
  35. package/lattice_brain/__init__.py +70 -0
  36. package/lattice_brain/_kg_common.py +1 -0
  37. package/lattice_brain/archive.py +133 -0
  38. package/lattice_brain/context.py +3 -0
  39. package/lattice_brain/conversations.py +3 -0
  40. package/lattice_brain/core.py +82 -0
  41. package/lattice_brain/discovery.py +1 -0
  42. package/lattice_brain/documents.py +1 -0
  43. package/lattice_brain/embeddings.py +82 -0
  44. package/lattice_brain/identity.py +13 -0
  45. package/lattice_brain/ingest.py +1 -0
  46. package/lattice_brain/memory.py +3 -0
  47. package/lattice_brain/network.py +1 -0
  48. package/lattice_brain/projection.py +1 -0
  49. package/lattice_brain/provenance.py +1 -0
  50. package/lattice_brain/retrieval.py +1 -0
  51. package/lattice_brain/schema.py +1 -0
  52. package/lattice_brain/storage/__init__.py +22 -0
  53. package/lattice_brain/storage/base.py +72 -0
  54. package/lattice_brain/storage/docker.py +105 -0
  55. package/lattice_brain/storage/factory.py +31 -0
  56. package/lattice_brain/storage/migration.py +190 -0
  57. package/lattice_brain/storage/postgres.py +123 -0
  58. package/lattice_brain/storage/sqlite.py +128 -0
  59. package/lattice_brain/store.py +3 -0
  60. package/lattice_brain/write_master.py +1 -0
  61. package/latticeai/__init__.py +1 -1
  62. package/latticeai/api/portability.py +69 -0
  63. package/latticeai/api/setup.py +5 -4
  64. package/latticeai/api/static_routes.py +4 -4
  65. package/latticeai/app_factory.py +17 -10
  66. package/latticeai/brain/__init__.py +6 -6
  67. package/latticeai/brain/_kg_common.py +1 -1
  68. package/latticeai/brain/network.py +1 -1
  69. package/latticeai/brain/retrieval.py +15 -0
  70. package/latticeai/brain/store.py +22 -6
  71. package/latticeai/core/config.py +8 -0
  72. package/latticeai/core/marketplace.py +1 -1
  73. package/latticeai/core/multi_agent.py +1 -1
  74. package/latticeai/core/workspace_os.py +1 -1
  75. package/latticeai/services/kg_portability.py +82 -1
  76. package/package.json +55 -15
  77. package/scripts/build_frontend_assets.mjs +38 -0
  78. package/scripts/bump_version.py +4 -1
  79. package/scripts/export_openapi.py +31 -0
  80. package/scripts/lint_frontend.mjs +91 -0
  81. package/scripts/migrate_brain_storage.py +53 -0
  82. package/scripts/run_python.mjs +47 -0
  83. package/scripts/wheel_smoke.py +3 -0
  84. package/src-tauri/Cargo.lock +4833 -0
  85. package/src-tauri/Cargo.toml +19 -0
  86. package/src-tauri/build.rs +3 -0
  87. package/src-tauri/capabilities/default.json +7 -0
  88. package/src-tauri/src/main.rs +78 -0
  89. package/src-tauri/tauri.conf.json +39 -0
  90. package/static/app/asset-manifest.json +32 -0
  91. package/static/app/assets/core-CwxXejkd.js +2 -0
  92. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  93. package/static/app/assets/index-CDjiH_se.css +2 -0
  94. package/static/app/assets/index-C_HAkbAg.js +333 -0
  95. package/static/app/assets/index-C_HAkbAg.js.map +1 -0
  96. package/static/app/index.html +25 -0
  97. package/static/manifest.json +2 -2
  98. package/static/sw.js +4 -4
  99. package/scripts/build_v3_assets.mjs +0 -170
  100. package/scripts/lint_v3.mjs +0 -120
  101. package/static/v3/asset-manifest.json +0 -63
  102. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  103. package/static/v3/css/lattice.base.css +0 -128
  104. package/static/v3/css/lattice.components.cde18231.css +0 -472
  105. package/static/v3/css/lattice.components.css +0 -472
  106. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  107. package/static/v3/css/lattice.shell.css +0 -452
  108. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  109. package/static/v3/css/lattice.tokens.css +0 -135
  110. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  111. package/static/v3/css/lattice.views.css +0 -360
  112. package/static/v3/index.html +0 -68
  113. package/static/v3/js/app.c5c80c46.js +0 -26
  114. package/static/v3/js/app.js +0 -26
  115. package/static/v3/js/core/api.ba0fbf14.js +0 -625
  116. package/static/v3/js/core/api.js +0 -625
  117. package/static/v3/js/core/components.f25b3b93.js +0 -230
  118. package/static/v3/js/core/components.js +0 -230
  119. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  120. package/static/v3/js/core/dom.js +0 -148
  121. package/static/v3/js/core/i18n.880e1fec.js +0 -575
  122. package/static/v3/js/core/i18n.js +0 -575
  123. package/static/v3/js/core/router.584570f2.js +0 -37
  124. package/static/v3/js/core/router.js +0 -37
  125. package/static/v3/js/core/routes.37522821.js +0 -101
  126. package/static/v3/js/core/routes.js +0 -101
  127. package/static/v3/js/core/shell.e3f6bbfa.js +0 -420
  128. package/static/v3/js/core/shell.js +0 -420
  129. package/static/v3/js/core/store.7b2aa044.js +0 -123
  130. package/static/v3/js/core/store.js +0 -123
  131. package/static/v3/js/views/account.eff40715.js +0 -143
  132. package/static/v3/js/views/account.js +0 -143
  133. package/static/v3/js/views/activity.0d271ef9.js +0 -67
  134. package/static/v3/js/views/activity.js +0 -67
  135. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  136. package/static/v3/js/views/admin-audit.js +0 -185
  137. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  138. package/static/v3/js/views/admin-permissions.js +0 -177
  139. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  140. package/static/v3/js/views/admin-policies.js +0 -102
  141. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  142. package/static/v3/js/views/admin-private-vpc.js +0 -135
  143. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  144. package/static/v3/js/views/admin-security.js +0 -180
  145. package/static/v3/js/views/admin-users.f7ac7b43.js +0 -166
  146. package/static/v3/js/views/admin-users.js +0 -166
  147. package/static/v3/js/views/agents.17c5288d.js +0 -564
  148. package/static/v3/js/views/agents.js +0 -564
  149. package/static/v3/js/views/chat.e250e2cc.js +0 -624
  150. package/static/v3/js/views/chat.js +0 -624
  151. package/static/v3/js/views/files.adad14c1.js +0 -365
  152. package/static/v3/js/views/files.js +0 -365
  153. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  154. package/static/v3/js/views/graph-canvas.js +0 -509
  155. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  156. package/static/v3/js/views/home.js +0 -200
  157. package/static/v3/js/views/hooks.37895880.js +0 -220
  158. package/static/v3/js/views/hooks.js +0 -220
  159. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  160. package/static/v3/js/views/hybrid-search.js +0 -194
  161. package/static/v3/js/views/knowledge-graph.4d09c537.js +0 -529
  162. package/static/v3/js/views/knowledge-graph.js +0 -529
  163. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  164. package/static/v3/js/views/marketplace.js +0 -141
  165. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  166. package/static/v3/js/views/mcp.js +0 -114
  167. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  168. package/static/v3/js/views/memory.js +0 -147
  169. package/static/v3/js/views/models.a1ffa147.js +0 -256
  170. package/static/v3/js/views/models.js +0 -256
  171. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  172. package/static/v3/js/views/my-computer.js +0 -463
  173. package/static/v3/js/views/network.52a4f181.js +0 -97
  174. package/static/v3/js/views/network.js +0 -97
  175. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  176. package/static/v3/js/views/pipeline.js +0 -157
  177. package/static/v3/js/views/planning.4876fd77.js +0 -174
  178. package/static/v3/js/views/planning.js +0 -174
  179. package/static/v3/js/views/runs.b63b2afa.js +0 -144
  180. package/static/v3/js/views/runs.js +0 -144
  181. package/static/v3/js/views/settings.b7140634.js +0 -317
  182. package/static/v3/js/views/settings.js +0 -317
  183. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  184. package/static/v3/js/views/skills.js +0 -109
  185. package/static/v3/js/views/snapshots.6f5db095.js +0 -135
  186. package/static/v3/js/views/snapshots.js +0 -135
  187. package/static/v3/js/views/tools.e4f11276.js +0 -108
  188. package/static/v3/js/views/tools.js +0 -108
  189. package/static/v3/js/views/workflows.7752225a.js +0 -213
  190. package/static/v3/js/views/workflows.js +0 -213
  191. package/static/v3/js/views/workspace-admin.c466029b.js +0 -156
  192. package/static/v3/js/views/workspace-admin.js +0 -156
@@ -1,624 +0,0 @@
1
- /* ============================================================================
2
- * View: Chat — a first-class v3 surface (NOT a preview that links out).
3
- *
4
- * Native to the /app shell: shares the design system, tokens, command palette,
5
- * workspace switcher, and mode model. Talks to the REAL backend
6
- * (POST /chat SSE + /history/* ) through the v3 adapter. Missing endpoints
7
- * report unavailable state; a live "no model loaded" response stays a
8
- * user-facing setup message instead of pretending to generate.
9
- *
10
- * Layout (flush, 3-pane): conversations · thread+composer · retrieval context
11
- * (Knowledge Graph · Vector · Hybrid Search · indexed file references).
12
- * ========================================================================== */
13
-
14
- import { timeAgo } from "../core/dom.a2773eb0.js";
15
- import { t } from "../core/i18n.880e1fec.js";
16
-
17
- export const layout = "flush";
18
-
19
- export async function render(ctx) {
20
- const { h, icon, api, store, c, params, navigate, toast } = ctx;
21
-
22
- const state = {
23
- conversations: [], convSource: "pending",
24
- activeId: null, title: "New chat",
25
- messages: [], // { role: "user"|"ai", content, source?, error? }
26
- streaming: false, abort: null,
27
- grounding: { graph: true, vector: true },
28
- model: "", modelSource: "pending",
29
- lastQuery: "", lastTrace: null,
30
- graphCache: null,
31
- // VLM image input (per-message). state.image holds raw base64 with NO
32
- // "data:image/...;base64," prefix (what /chat expects); state.imagePreview
33
- // keeps the full data URL for the <img> thumbnail.
34
- image: null, imagePreview: null, visionEnabled: false,
35
- };
36
-
37
- /* ── element hosts ───────────────────────────────────────────────────── */
38
- const listItems = h("div.lt3-chatlist__items", c.loading({ lines: 4 }));
39
- const listSrc = h("span", c.sourceBadge("pending"));
40
- const threadInner = h("div.lt3-chat__thread-inner");
41
- const thread = h("div.lt3-chat__thread", { id: "lt3-chat-thread", role: "log", "aria-live": "polite", "aria-label": "Conversation" }, threadInner);
42
- const titleEl = h("div.lt3-chat__title", state.title);
43
- const modelPill = h("span", c.pill("model", "info", { dot: true }));
44
- const barSrc = h("span", c.sourceBadge("pending"));
45
- const ctxBody = h("div.lt3-chat__context-body", c.loading({ lines: 5 }));
46
- const ctxSrc = h("span", c.sourceBadge("pending"));
47
-
48
- const textarea = h("textarea", {
49
- rows: "1", placeholder: "Message your workspace… (Enter to send · Shift+Enter for newline)",
50
- "aria-label": "Message", autocomplete: "off",
51
- on: {
52
- input: autogrow,
53
- keydown: (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } },
54
- paste: onPaste,
55
- },
56
- });
57
- const sendBtn = h("button.lt3-btn.lt3-btn--primary", { "aria-label": "Send", on: { click: () => state.streaming ? stopStreaming() : send() } }, icon("arrow-up"));
58
-
59
- /* ── VLM image input (upload · drop · paste) ─────────────────────────── */
60
- // Vision-capability badge — reflects whether the loaded model can read
61
- // images. Honest by default ("Vision Disabled") until /models confirms.
62
- const visionPill = h("span", {
63
- title: "Load a vision-capable model to interpret images",
64
- }, c.pill("Vision Disabled", "warn", { dot: true }));
65
- // Hidden native picker, triggered by the "Attach image" button.
66
- const fileInput = h("input", {
67
- type: "file", accept: "image/*", style: { display: "none" },
68
- "aria-hidden": "true", tabindex: "-1",
69
- on: { change: (e) => { const f = e.target.files && e.target.files[0]; if (f) loadImageFile(f); e.target.value = ""; } },
70
- });
71
- const attachBtn = h("button.lt3-chip", {
72
- type: "button", title: "Attach an image for a vision-capable model to read",
73
- "aria-label": "Attach image", on: { click: () => fileInput.click() },
74
- }, icon("photo"), "Image");
75
- // Preview host, populated above the textarea inside .lt3-composer__inner.
76
- const imagePreviewHost = h("div", { style: { display: "none" } });
77
-
78
- const groundChip = (key, label, icn) => h("button.lt3-chip", {
79
- type: "button", dataset: { active: String(state.grounding[key]) }, "aria-pressed": String(state.grounding[key]),
80
- title: `Show the ${label} signal in the retrieval-context panel`,
81
- on: { click: (e) => { state.grounding[key] = !state.grounding[key]; const b = e.currentTarget; b.dataset.active = String(state.grounding[key]); b.setAttribute("aria-pressed", String(state.grounding[key])); } },
82
- }, icon(icn), label);
83
-
84
- /* ── assembled shell ─────────────────────────────────────────────────── */
85
- const chat = h("div.lt3-chat", { dataset: { list: "closed", context: "closed" } },
86
- h("div.lt3-chat__scrim", { on: { click: closePanes } }),
87
-
88
- // Conversations rail
89
- h("aside.lt3-chatlist", { "aria-label": "Conversations" },
90
- h("div.lt3-chatlist__head",
91
- h("div.lt3-row", { style: { "justify-content": "space-between" } },
92
- h("div.lt3-eyebrow", "Conversations"),
93
- h("button.lt3-iconbtn.lt3-iconbtn--sm.lt3-chat__pane-close", { "aria-label": "Close conversations", on: { click: closePanes } }, icon("x")),
94
- ),
95
- h("button.lt3-btn.lt3-btn--ghost.lt3-btn--block", { on: { click: () => startNew(true) } }, icon("message-plus"), "New chat"),
96
- h("div.lt3-row", { style: { "justify-content": "space-between" } }, h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "History"), listSrc),
97
- ),
98
- listItems,
99
- ),
100
-
101
- // Main thread + composer
102
- h("div.lt3-chat__main",
103
- h("div.lt3-chat__bar",
104
- h("button.lt3-iconbtn.lt3-chat__toggle-list", { "aria-label": "Conversations", on: { click: () => togglePane("list") } }, icon("layout-sidebar")),
105
- h("div.lt3-avatar", { style: { background: "transparent", color: "var(--accent)" } }, icon("message-2")),
106
- titleEl,
107
- h("div.lt3-spacer"),
108
- modelPill,
109
- barSrc,
110
- h("button.lt3-iconbtn.lt3-chat__toggle-context", { "aria-label": "Retrieval context", on: { click: () => togglePane("context") } }, icon("layout-sidebar-right")),
111
- ),
112
- thread,
113
- h("div.lt3-composer",
114
- h("div.lt3-composer__inner",
115
- imagePreviewHost,
116
- h("div.lt3-composer__box", {
117
- on: { dragover: onDragOver, dragleave: onDragLeave, drop: onDrop },
118
- }, textarea, sendBtn),
119
- h("div.lt3-composer__tools",
120
- groundChip("graph", "Knowledge Graph", "chart-dots-3"),
121
- groundChip("vector", "Vector", "grid-dots"),
122
- attachBtn,
123
- visionPill,
124
- fileInput,
125
- h("span.lt3-spacer"),
126
- h("span.lt3-kbd", "↵"),
127
- ),
128
- h("div.lt3-composer__hint", "Answers are grounded in your local workspace via hybrid retrieval. Nothing leaves this machine."),
129
- ),
130
- ),
131
- ),
132
-
133
- // Retrieval context
134
- h("aside.lt3-chat__context", { "aria-label": "Retrieval context" },
135
- h("div.lt3-chat__context-head",
136
- h("div.lt3-eyebrow", icon("stack-2"), "Retrieval context"),
137
- h("div.lt3-row-2", ctxSrc, h("button.lt3-iconbtn.lt3-iconbtn--sm.lt3-chat__pane-close", { "aria-label": "Close context", on: { click: closePanes } }, icon("x"))),
138
- ),
139
- ctxBody,
140
- ),
141
- );
142
-
143
- /* ── boot ────────────────────────────────────────────────────────────── */
144
- loadModel();
145
- loadConversations();
146
- renderContext(""); // index-based defaults until the first answer
147
- if (params.new) startNew(false); else loadInitial();
148
-
149
- return chat;
150
-
151
- /* ── conversations ───────────────────────────────────────────────────── */
152
- async function loadConversations() {
153
- const res = await api.chatHistory();
154
- state.conversations = normalizeConversations(res.data);
155
- state.convSource = res.source;
156
- listSrc.replaceChildren(c.sourceBadge(res.source));
157
- renderConversations();
158
- }
159
-
160
- function renderConversations() {
161
- if (!state.conversations.length) {
162
- listItems.replaceChildren(c.emptyState({ icon: "message-off", title: "No conversations", body: "Start a new chat to begin." }));
163
- return;
164
- }
165
- listItems.replaceChildren(...state.conversations.map((conv) =>
166
- h("button.lt3-convo", {
167
- dataset: { active: String(conv.id === state.activeId) },
168
- on: { click: () => selectConversation(conv.id) },
169
- },
170
- icon("message"),
171
- h("div.lt3-convo__body",
172
- h("div.lt3-convo__title", conv.title || "Untitled"),
173
- conv.updated_at && h("div.lt3-convo__meta", timeAgo(conv.updated_at)),
174
- ),
175
- h("span.lt3-iconbtn.lt3-iconbtn--sm.lt3-convo__del", {
176
- role: "button", tabindex: "0", "aria-label": "Delete conversation",
177
- on: { click: (e) => { e.stopPropagation(); removeConversation(conv.id); } },
178
- }, icon("trash")),
179
- ),
180
- ));
181
- }
182
-
183
- async function selectConversation(id) {
184
- if (state.streaming) stopStreaming();
185
- closePanes();
186
- clearImage();
187
- state.activeId = id;
188
- const conv = state.conversations.find((x) => x.id === id);
189
- state.title = conv ? conv.title : "Conversation";
190
- titleEl.textContent = state.title;
191
- state.lastTrace = null;
192
- renderConversations();
193
- threadInner.replaceChildren(c.loading({ lines: 4 }));
194
- const res = await api.conversation(id);
195
- if (state.activeId !== id) return;
196
- state.messages = (res.data || []).map((m) => ({
197
- role: m.role === "assistant" ? "ai" : "user",
198
- content: m.content || "",
199
- source: res.source,
200
- }));
201
- renderMessages();
202
- const lastUser = [...state.messages].reverse().find((m) => m.role === "user");
203
- renderContext(lastUser ? lastUser.content : "");
204
- }
205
-
206
- function startNew(userInitiated) {
207
- if (state.streaming) stopStreaming();
208
- closePanes();
209
- clearImage();
210
- state.activeId = null;
211
- state.title = "New chat";
212
- state.messages = [];
213
- state.lastTrace = null;
214
- titleEl.textContent = state.title;
215
- renderConversations();
216
- renderMessages();
217
- renderContext("");
218
- if (userInitiated) { try { textarea.focus(); } catch {} navigate("chat", { new: "1" }); }
219
- }
220
-
221
- async function removeConversation(id) {
222
- await api.deleteConversation(id);
223
- state.conversations = state.conversations.filter((x) => x.id !== id);
224
- if (state.activeId === id) startNew(false);
225
- else renderConversations();
226
- toast("Conversation removed", "ok");
227
- }
228
-
229
- /* ── messages / thread ───────────────────────────────────────────────── */
230
- function renderMessages() {
231
- if (!state.messages.length) {
232
- threadInner.replaceChildren(emptyThread());
233
- return;
234
- }
235
- threadInner.replaceChildren(...state.messages.map((m) => messageNode(m)));
236
- scrollToBottom();
237
- }
238
-
239
- function emptyThread() {
240
- return h("div.lt3-empty", { style: { margin: "auto 0" } },
241
- h("div.lt3-empty__icon", icon("sparkles")),
242
- h("div.lt3-empty__title", "Ask anything about your workspace"),
243
- h("div.lt3-empty__body", "Grounded in your knowledge graph and vector index via hybrid retrieval. Try a question, or pick a starter below."),
244
- h("div.lt3-cluster", { style: { "justify-content": "center", "margin-top": "var(--lt3-space-2)" } },
245
- ...["How does hybrid search rank results?", "What entities are in my notes?", "Summarize retrieval.md"].map((q) =>
246
- h("button.lt3-chip", { on: { click: () => { textarea.value = q; autogrow(); send(); } } }, icon("arrow-up-right"), q)),
247
- ),
248
- );
249
- }
250
-
251
- function messageNode(m) {
252
- const isUser = m.role === "user";
253
- const body = h("div.lt3-msg__body",
254
- h("div.lt3-msg__bubble",
255
- m.image && h("img", {
256
- src: m.image, alt: "Attached image",
257
- style: { display: "block", "max-height": "220px", "max-width": "100%", "border-radius": "var(--lt3-radius-2, 8px)", border: "1px solid var(--border)", "margin-bottom": m.content ? "var(--lt3-space-2)" : "0" },
258
- }),
259
- m.content,
260
- ),
261
- m.role === "ai" && m.source && h("div.lt3-row-2", c.sourceBadge(m.source)),
262
- );
263
- return h(`div.lt3-msg.lt3-msg--${isUser ? "user" : "ai"}`,
264
- h("div.lt3-msg__avatar", icon(isUser ? "user" : "sparkles")),
265
- body,
266
- );
267
- }
268
-
269
- function scrollToBottom() { requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; }); }
270
-
271
- function autogrow() {
272
- textarea.style.height = "auto";
273
- textarea.style.height = Math.min(200, textarea.scrollHeight) + "px";
274
- }
275
-
276
- /* ── image: read · preview · clear ───────────────────────────────────── */
277
- // Read an image File as a data URL, then split off the raw base64 payload
278
- // (/chat wants the bytes without the "data:...;base64," prefix).
279
- function loadImageFile(file) {
280
- if (!file || !/^image\//.test(file.type || "")) return;
281
- const reader = new FileReader();
282
- reader.onload = () => {
283
- const dataUrl = String(reader.result || "");
284
- const comma = dataUrl.indexOf(",");
285
- if (comma < 0) return;
286
- state.imagePreview = dataUrl;
287
- state.image = dataUrl.slice(comma + 1); // strip the data: prefix
288
- renderImagePreview();
289
- };
290
- reader.onerror = () => toast("Couldn't read that image", "err");
291
- reader.readAsDataURL(file);
292
- }
293
-
294
- function clearImage() {
295
- state.image = null;
296
- state.imagePreview = null;
297
- renderImagePreview();
298
- }
299
-
300
- function renderImagePreview() {
301
- if (!state.imagePreview) {
302
- imagePreviewHost.replaceChildren();
303
- imagePreviewHost.style.display = "none";
304
- return;
305
- }
306
- imagePreviewHost.style.display = "flex";
307
- imagePreviewHost.style.setProperty("align-items", "center");
308
- imagePreviewHost.style.setProperty("gap", "var(--lt3-space-2)");
309
- imagePreviewHost.style.setProperty("margin-bottom", "var(--lt3-space-2)");
310
- imagePreviewHost.replaceChildren(
311
- h("div", { style: { position: "relative", display: "inline-flex" } },
312
- h("img", {
313
- src: state.imagePreview, alt: "Attached image preview",
314
- style: { height: "64px", "border-radius": "var(--lt3-radius-2, 8px)", border: "1px solid var(--border)" },
315
- }),
316
- h("button.lt3-iconbtn.lt3-iconbtn--sm", {
317
- type: "button", "aria-label": "Remove image",
318
- title: "Remove image",
319
- style: {
320
- position: "absolute", top: "-8px", right: "-8px",
321
- background: "var(--surface-2)", border: "1px solid var(--border)",
322
- "border-radius": "var(--lt3-radius-pill, 999px)",
323
- },
324
- on: { click: clearImage },
325
- }, icon("x")),
326
- ),
327
- h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } },
328
- state.visionEnabled ? "Image attached" : "Image attached · load a vision-capable model to interpret it"),
329
- );
330
- }
331
-
332
- /* ── drag & drop ─────────────────────────────────────────────────────── */
333
- function hasImageDrag(e) {
334
- const dt = e.dataTransfer;
335
- return !!dt && Array.from(dt.types || []).includes("Files");
336
- }
337
- function onDragOver(e) {
338
- if (!hasImageDrag(e)) return;
339
- e.preventDefault();
340
- e.currentTarget.style.setProperty("outline", "2px dashed var(--accent)");
341
- e.currentTarget.style.setProperty("outline-offset", "2px");
342
- }
343
- function onDragLeave(e) {
344
- e.currentTarget.style.removeProperty("outline");
345
- e.currentTarget.style.removeProperty("outline-offset");
346
- }
347
- function onDrop(e) {
348
- e.currentTarget.style.removeProperty("outline");
349
- e.currentTarget.style.removeProperty("outline-offset");
350
- const dt = e.dataTransfer;
351
- if (!dt || !dt.files || !dt.files.length) return;
352
- const file = Array.from(dt.files).find((f) => /^image\//.test(f.type || ""));
353
- if (!file) return;
354
- e.preventDefault();
355
- loadImageFile(file);
356
- }
357
-
358
- /* ── paste ───────────────────────────────────────────────────────────── */
359
- function onPaste(e) {
360
- const items = e.clipboardData && e.clipboardData.items;
361
- if (!items) return;
362
- for (const item of items) {
363
- if (item.kind === "file" && /^image\//.test(item.type || "")) {
364
- const file = item.getAsFile();
365
- if (file) { e.preventDefault(); loadImageFile(file); }
366
- return;
367
- }
368
- }
369
- }
370
-
371
- /* ── send + stream ───────────────────────────────────────────────────── */
372
- async function send() {
373
- const text = textarea.value.trim();
374
- // An image alone is a valid message — only bail when there's nothing at all.
375
- if ((!text && !state.image) || state.streaming) return;
376
- textarea.value = ""; autogrow();
377
-
378
- // Snapshot the image for this message, then clear the composer (per-message).
379
- const imageData = state.image || null;
380
- const imagePreview = state.imagePreview || null;
381
- clearImage();
382
-
383
- if (!state.messages.length) threadInner.replaceChildren();
384
- const userMsg = { role: "user", content: text, image: imagePreview };
385
- state.messages.push(userMsg);
386
- threadInner.append(messageNode(userMsg));
387
- state.lastQuery = text;
388
- if (!state.activeId) {
389
- const seed = text || "Image";
390
- state.title = seed.slice(0, 48); titleEl.textContent = state.title;
391
- }
392
-
393
- // streaming AI bubble
394
- const bubble = h("div.lt3-msg__bubble");
395
- const srcRow = h("div.lt3-row-2");
396
- const aiNode = h("div.lt3-msg.lt3-msg--ai",
397
- h("div.lt3-msg__avatar", icon("sparkles")),
398
- h("div.lt3-msg__body", bubble, srcRow),
399
- );
400
- bubble.append(typingIndicator());
401
- threadInner.append(aiNode);
402
- scrollToBottom();
403
-
404
- state.streaming = true;
405
- state.abort = new AbortController();
406
- setComposerStreaming(true);
407
- let started = false;
408
-
409
- const result = await api.streamChat(
410
- { message: text, conversation_id: state.activeId, grounding: state.grounding, image_data: imageData || undefined },
411
- {
412
- signal: state.abort.signal,
413
- onChunk: (_delta, full) => {
414
- if (!started) { started = true; bubble.replaceChildren(); }
415
- bubble.textContent = full;
416
- scrollToBottom();
417
- },
418
- onTrace: (trace) => { state.lastTrace = trace; renderContext(text); },
419
- },
420
- );
421
-
422
- state.streaming = false;
423
- state.abort = null;
424
- setComposerStreaming(false);
425
-
426
- if (result.aborted) {
427
- if (!result.text) { bubble.textContent = "(stopped)"; bubble.classList.add("lt3-faint"); }
428
- return;
429
- }
430
- if (result.error === "no_model_loaded") {
431
- aiNode.replaceWith(errorNode(text, result));
432
- return;
433
- }
434
- if (!result.text) {
435
- aiNode.replaceWith(errorNode(text, result));
436
- return;
437
- }
438
- bubble.textContent = result.text;
439
- state.messages.push({ role: "ai", content: result.text, source: result.source });
440
- srcRow.replaceChildren(c.sourceBadge(result.source));
441
- if (!state.lastTrace) renderContext(text);
442
- refreshConversationMeta();
443
- scrollToBottom();
444
- }
445
-
446
- function errorNode(retryText, result = {}) {
447
- const noModel = result.error === "no_model_loaded";
448
- return h("div.lt3-msg.lt3-msg--ai",
449
- h("div.lt3-msg__avatar", icon("alert-triangle")),
450
- h("div.lt3-msg__body",
451
- h("div.lt3-banner.lt3-banner--err",
452
- icon("alert-triangle"),
453
- h("div", h("div", { style: { fontWeight: 600 } }, noModel ? "No local model loaded" : "Couldn't reach the model"),
454
- h("div.lt3-faint", noModel
455
- ? "Load a local or OpenAI-compatible model from Models, then retry this message."
456
- : "The chat backend isn't responding. Check the local runtime and retry.")),
457
- h("div.lt3-row-2", { style: { "margin-left": "auto" } },
458
- noModel && h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => navigate("models") } }, icon("cpu"), "Models"),
459
- h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { textarea.value = retryText; autogrow(); send(); } } }, icon("refresh"), "Retry"),
460
- ),
461
- ),
462
- ),
463
- );
464
- }
465
-
466
- function typingIndicator() { return h("span.lt3-typing", { "aria-label": "Assistant is typing" }, h("i"), h("i"), h("i")); }
467
-
468
- function setComposerStreaming(on) {
469
- if (on) {
470
- sendBtn.replaceChildren(icon("player-stop"));
471
- sendBtn.setAttribute("aria-label", "Stop");
472
- } else {
473
- sendBtn.replaceChildren(icon("arrow-up"));
474
- sendBtn.setAttribute("aria-label", "Send");
475
- }
476
- }
477
-
478
- function stopStreaming() { if (state.abort) { try { state.abort.abort(); } catch {} } }
479
-
480
- function refreshConversationMeta() {
481
- // New, unsaved conversation — reload the list so the backend-assigned id /
482
- // title appears once persisted.
483
- if (!state.activeId && state.messages.length) loadConversations();
484
- }
485
-
486
- /* ── retrieval context (KG · Vector · Hybrid · files) ────────────────── */
487
- async function renderContext(query) {
488
- const q = (query || "").trim();
489
- let hybrid = [], hybridSource = "pending";
490
- if (q) { const hs = await api.hybridSearch(q, { mode: groundingMode() }); hybrid = hs.data || []; hybridSource = hs.source; }
491
-
492
- if (!state.graphCache) state.graphCache = await api.graph();
493
- const graphNodes = (state.lastTrace && state.lastTrace.graph_nodes) ||
494
- ((state.graphCache.data.nodes || []).slice(0, 5).map((n) => ({ id: n.id, title: n.label || n.title, type: n.type })));
495
- const vectorMatches = (state.lastTrace && state.lastTrace.vector_matches) ||
496
- hybrid.map((r) => ({ path: r.path, score: r.vector }));
497
- const fileRefs = (state.lastTrace && state.lastTrace.source_files && state.lastTrace.source_files.map((s) => s.source)) ||
498
- [...new Set(hybrid.map((r) => r.path))];
499
-
500
- const overall = q ? hybridSource : state.graphCache.source;
501
- ctxSrc.replaceChildren(c.sourceBadge(overall));
502
-
503
- ctxBody.replaceChildren(
504
- ctxSection("Knowledge graph", "chart-dots-3",
505
- graphNodes.length
506
- ? graphNodes.slice(0, 6).map((n) => ctxItem("var(--lt3-pillar-graph)", n.title || n.id, n.type))
507
- : [ctxEmpty("No linked entities yet")]),
508
-
509
- ctxSection("Vector matches", "grid-dots",
510
- vectorMatches.length
511
- ? vectorMatches.slice(0, 5).map((v) => ctxItem("var(--lt3-pillar-vector)", v.path, v.score != null ? v.score.toFixed(2) : null))
512
- : [ctxEmpty("Run a query to see vector matches")]),
513
-
514
- ctxSection("Hybrid search", "arrows-join",
515
- hybrid.length
516
- ? hybrid.slice(0, 4).map((r) => ctxItem("var(--lt3-pillar-hybrid)", r.title || r.path, r.score != null ? r.score.toFixed(2) : null))
517
- : [ctxEmpty("Ask a question to fuse graph + vector")]),
518
-
519
- ctxSection("Indexed files", "files",
520
- fileRefs.length
521
- ? fileRefs.slice(0, 6).map((p) => ctxItem("var(--faint)", p, null))
522
- : [ctxEmpty("No file references yet")]),
523
-
524
- ctxSection(t("chat.whyContext"), "route",
525
- traceItems(state.lastTrace, q)),
526
-
527
- h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm.lt3-btn--block", { on: { click: () => navigate("hybrid-search", q ? { q } : undefined) } }, icon("arrows-join"), "Open Hybrid Search"),
528
- );
529
- }
530
-
531
- function traceItems(trace, query) {
532
- if (!trace) return [ctxEmpty(t("chat.traceUnavailable"))];
533
- const items = [];
534
- const meta = trace.retrieval_metadata || {};
535
- items.push(ctxItem("var(--lt3-pillar-hybrid)", t("chat.traceQuestion"), trace.question || query || "—"));
536
- if (trace.confidence != null) items.push(ctxItem("var(--lt3-pillar-vector)", t("chat.traceConfidence"), Number(trace.confidence).toFixed(2)));
537
- if (meta.mode || meta.strategy) items.push(ctxItem("var(--lt3-pillar-hybrid)", "retrieval", meta.mode || meta.strategy));
538
- if (Array.isArray(trace.graph_nodes) && trace.graph_nodes.length) {
539
- items.push(ctxItem("var(--lt3-pillar-graph)", t("chat.traceReasons"), `${trace.graph_nodes.length} graph node(s)`));
540
- }
541
- if (Array.isArray(trace.vector_matches) && trace.vector_matches.length) {
542
- items.push(ctxItem("var(--lt3-pillar-vector)", "vector", `${trace.vector_matches.length} match(es)`));
543
- }
544
- if (Array.isArray(trace.source_files) && trace.source_files.length) {
545
- items.push(ctxItem("var(--faint)", "files", `${trace.source_files.length} source file(s)`));
546
- }
547
- return items.length ? items : [ctxEmpty(t("chat.traceUnavailable"))];
548
- }
549
-
550
- function ctxSection(title, icn, children) {
551
- return h("section",
552
- h("div.lt3-ctx-sec__title", icon(icn), title),
553
- h("div", children),
554
- );
555
- }
556
- function ctxItem(color, label, score) {
557
- return h("div.lt3-ctx-item",
558
- h("span.lt3-ctx-item__dot", { style: { background: color } }),
559
- h("span.lt3-ctx-item__label", { title: String(label) }, String(label)),
560
- score != null && h("span.lt3-ctx-item__score", String(score)),
561
- );
562
- }
563
- function ctxEmpty(text) { return h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)", padding: "var(--lt3-space-1) 0" } }, text); }
564
-
565
- function groundingMode() {
566
- if (state.grounding.graph && state.grounding.vector) return "hybrid";
567
- if (state.grounding.vector) return "vector";
568
- if (state.grounding.graph) return "graph";
569
- return "hybrid";
570
- }
571
-
572
- /* ── misc ────────────────────────────────────────────────────────────── */
573
- async function loadModel() {
574
- const res = await api.models();
575
- state.model = (res.data && res.data.current) || "";
576
- state.modelSource = res.source;
577
- modelPill.replaceChildren(c.pill(state.model ? shortModel(state.model) : "No model", state.model ? "info" : "warn", { dot: true }));
578
- barSrc.replaceChildren(c.sourceBadge(res.source));
579
-
580
- // Vision capability — driven by the real /models.vision contract; stays
581
- // honestly "Disabled" when the field is absent or the call is unavailable.
582
- state.visionEnabled = !!(res.data && res.data.vision && res.data.vision.enabled);
583
- visionPill.title = state.visionEnabled
584
- ? "The loaded model can interpret attached images"
585
- : "Load a vision-capable model to interpret images";
586
- visionPill.replaceChildren(
587
- c.pill(state.visionEnabled ? "Vision Enabled" : "Vision Disabled", state.visionEnabled ? "ok" : "warn", { dot: true }),
588
- );
589
- // Keep any open preview's helper text in sync with capability.
590
- if (state.imagePreview) renderImagePreview();
591
- }
592
-
593
- function loadInitial() {
594
- // Land on the most recent conversation if one exists, else an empty thread.
595
- api.chatHistory().then((res) => {
596
- const list = normalizeConversations(res.data);
597
- if (list.length) selectConversation(list[0].id);
598
- else renderMessages();
599
- });
600
- }
601
-
602
- function togglePane(which) {
603
- const other = which === "list" ? "context" : "list";
604
- chat.dataset[other] = "closed";
605
- chat.dataset[which] = chat.dataset[which] === "open" ? "closed" : "open";
606
- }
607
- function closePanes() { chat.dataset.list = "closed"; chat.dataset.context = "closed"; }
608
- }
609
-
610
- /* ── helpers ─────────────────────────────────────────────────────────────── */
611
- function normalizeConversations(data) {
612
- const list = Array.isArray(data) ? data : (data && Array.isArray(data.conversations) ? data.conversations : []);
613
- return list.map((conv, i) => ({
614
- id: conv.id || conv.conversation_id || `conv-${i}`,
615
- title: conv.title || conv.name || "Untitled",
616
- updated_at: conv.updated_at || conv.last_message_at || conv.timestamp || null,
617
- }));
618
- }
619
-
620
- function shortModel(id) {
621
- const s = String(id || "model");
622
- const tail = s.includes("/") ? s.split("/").pop() : s;
623
- return tail.length > 22 ? tail.slice(0, 21) + "…" : tail;
624
- }