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