ltcai 2.2.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -27
- package/codex_telegram_bot.py +6 -2
- package/docs/CHANGELOG.md +154 -0
- package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
- package/docs/V3_FRONTEND.md +136 -0
- package/knowledge_graph.py +649 -21
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +47 -0
- package/latticeai/api/agents.py +54 -31
- package/latticeai/api/auth.py +1 -1
- package/latticeai/api/chat.py +10 -2
- package/latticeai/api/search.py +236 -0
- package/latticeai/api/static_routes.py +21 -2
- package/latticeai/core/config.py +16 -0
- package/latticeai/core/embedding_providers.py +502 -0
- package/latticeai/core/local_embeddings.py +86 -0
- package/latticeai/core/logging_safety.py +62 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +49 -1
- package/latticeai/services/agent_runtime.py +245 -0
- package/latticeai/services/search_service.py +346 -0
- package/package.json +8 -4
- package/static/account.html +9 -4
- package/static/activity.html +4 -4
- package/static/admin.html +8 -3
- package/static/agents.html +4 -4
- package/static/chat.html +16 -11
- package/static/css/reference/account.css +439 -0
- package/static/css/reference/admin.css +610 -0
- package/static/css/reference/base.css +1658 -0
- package/static/{lattice-reference.css → css/reference/chat.css} +271 -3633
- package/static/css/reference/graph.css +1016 -0
- package/static/css/responsive.css +248 -1
- package/static/css/tokens.css +132 -126
- package/static/favicon.ico +0 -0
- package/static/graph.html +9 -4
- package/static/manifest.json +3 -3
- package/static/platform.css +1 -1
- package/static/plugins.html +4 -4
- package/static/scripts/account.js +4 -4
- package/static/scripts/chat.js +227 -77
- package/static/scripts/workspace.js +78 -0
- package/static/sw.js +5 -3
- package/static/v3/css/lattice.base.css +128 -0
- package/static/v3/css/lattice.components.css +447 -0
- package/static/v3/css/lattice.shell.css +407 -0
- package/static/v3/css/lattice.tokens.css +132 -0
- package/static/v3/css/lattice.views.css +277 -0
- package/static/v3/index.html +40 -0
- package/static/v3/js/app.js +26 -0
- package/static/v3/js/core/api.js +327 -0
- package/static/v3/js/core/components.js +215 -0
- package/static/v3/js/core/dom.js +148 -0
- package/static/v3/js/core/fixtures.js +171 -0
- package/static/v3/js/core/router.js +37 -0
- package/static/v3/js/core/routes.js +73 -0
- package/static/v3/js/core/shell.js +363 -0
- package/static/v3/js/core/store.js +113 -0
- package/static/v3/js/views/admin-audit.js +185 -0
- package/static/v3/js/views/admin-permissions.js +178 -0
- package/static/v3/js/views/admin-policies.js +103 -0
- package/static/v3/js/views/admin-private-vpc.js +138 -0
- package/static/v3/js/views/admin-security.js +181 -0
- package/static/v3/js/views/admin-users.js +168 -0
- package/static/v3/js/views/agents.js +194 -0
- package/static/v3/js/views/chat.js +450 -0
- package/static/v3/js/views/files.js +180 -0
- package/static/v3/js/views/home.js +119 -0
- package/static/v3/js/views/hybrid-search.js +195 -0
- package/static/v3/js/views/knowledge-graph.js +238 -0
- package/static/v3/js/views/models.js +247 -0
- package/static/v3/js/views/my-computer.js +237 -0
- package/static/v3/js/views/pipeline.js +161 -0
- package/static/v3/js/views/settings.js +258 -0
- package/static/workflows.html +4 -4
- package/static/workspace.css +408 -14
- package/static/workspace.html +43 -24
- package/telegram_bot.py +18 -14
|
@@ -0,0 +1,450 @@
|
|
|
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
|
+
* degrade to a clearly-badged sample stream; a live "no model loaded" response
|
|
8
|
+
* stays a 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.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
|
+
};
|
|
31
|
+
|
|
32
|
+
/* ── element hosts ───────────────────────────────────────────────────── */
|
|
33
|
+
const listItems = h("div.lt3-chatlist__items", c.loading({ lines: 4 }));
|
|
34
|
+
const listSrc = h("span", c.sourceBadge("pending"));
|
|
35
|
+
const threadInner = h("div.lt3-chat__thread-inner");
|
|
36
|
+
const thread = h("div.lt3-chat__thread", { id: "lt3-chat-thread", role: "log", "aria-live": "polite", "aria-label": "Conversation" }, threadInner);
|
|
37
|
+
const titleEl = h("div.lt3-chat__title", state.title);
|
|
38
|
+
const modelPill = h("span", c.pill("model", "info", { dot: true }));
|
|
39
|
+
const barSrc = h("span", c.sourceBadge("pending"));
|
|
40
|
+
const ctxBody = h("div.lt3-chat__context-body", c.loading({ lines: 5 }));
|
|
41
|
+
const ctxSrc = h("span", c.sourceBadge("pending"));
|
|
42
|
+
|
|
43
|
+
const textarea = h("textarea", {
|
|
44
|
+
rows: "1", placeholder: "Message your workspace… (Enter to send · Shift+Enter for newline)",
|
|
45
|
+
"aria-label": "Message", autocomplete: "off",
|
|
46
|
+
on: {
|
|
47
|
+
input: autogrow,
|
|
48
|
+
keydown: (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const sendBtn = h("button.lt3-btn.lt3-btn--primary", { "aria-label": "Send", on: { click: send } }, icon("arrow-up"));
|
|
52
|
+
|
|
53
|
+
const groundChip = (key, label, icn) => h("button.lt3-chip", {
|
|
54
|
+
type: "button", dataset: { active: String(state.grounding[key]) }, "aria-pressed": String(state.grounding[key]),
|
|
55
|
+
title: `Toggle ${label} grounding`,
|
|
56
|
+
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])); } },
|
|
57
|
+
}, icon(icn), label);
|
|
58
|
+
|
|
59
|
+
/* ── assembled shell ─────────────────────────────────────────────────── */
|
|
60
|
+
const chat = h("div.lt3-chat", { dataset: { list: "closed", context: "closed" } },
|
|
61
|
+
h("div.lt3-chat__scrim", { on: { click: closePanes } }),
|
|
62
|
+
|
|
63
|
+
// Conversations rail
|
|
64
|
+
h("aside.lt3-chatlist", { "aria-label": "Conversations" },
|
|
65
|
+
h("div.lt3-chatlist__head",
|
|
66
|
+
h("div.lt3-row", { style: { "justify-content": "space-between" } },
|
|
67
|
+
h("div.lt3-eyebrow", "Conversations"),
|
|
68
|
+
h("button.lt3-iconbtn.lt3-iconbtn--sm.lt3-chat__pane-close", { "aria-label": "Close conversations", on: { click: closePanes } }, icon("x")),
|
|
69
|
+
),
|
|
70
|
+
h("button.lt3-btn.lt3-btn--ghost.lt3-btn--block", { on: { click: () => startNew(true) } }, icon("message-plus"), "New chat"),
|
|
71
|
+
h("div.lt3-row", { style: { "justify-content": "space-between" } }, h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "History"), listSrc),
|
|
72
|
+
),
|
|
73
|
+
listItems,
|
|
74
|
+
),
|
|
75
|
+
|
|
76
|
+
// Main thread + composer
|
|
77
|
+
h("div.lt3-chat__main",
|
|
78
|
+
h("div.lt3-chat__bar",
|
|
79
|
+
h("button.lt3-iconbtn.lt3-chat__toggle-list", { "aria-label": "Conversations", on: { click: () => togglePane("list") } }, icon("layout-sidebar")),
|
|
80
|
+
h("div.lt3-avatar", { style: { background: "transparent", color: "var(--accent)" } }, icon("message-2")),
|
|
81
|
+
titleEl,
|
|
82
|
+
h("div.lt3-spacer"),
|
|
83
|
+
modelPill,
|
|
84
|
+
barSrc,
|
|
85
|
+
h("button.lt3-iconbtn.lt3-chat__toggle-context", { "aria-label": "Retrieval context", on: { click: () => togglePane("context") } }, icon("layout-sidebar-right")),
|
|
86
|
+
h("button.lt3-iconbtn", { "aria-label": "Open classic chat", title: "Open classic chat", on: { click: () => { window.location.href = "/chat"; } } }, icon("external-link")),
|
|
87
|
+
),
|
|
88
|
+
thread,
|
|
89
|
+
h("div.lt3-composer",
|
|
90
|
+
h("div.lt3-composer__inner",
|
|
91
|
+
h("div.lt3-composer__box", textarea, sendBtn),
|
|
92
|
+
h("div.lt3-composer__tools",
|
|
93
|
+
groundChip("graph", "Knowledge Graph", "chart-dots-3"),
|
|
94
|
+
groundChip("vector", "Vector", "grid-dots"),
|
|
95
|
+
h("span.lt3-spacer"),
|
|
96
|
+
h("span.lt3-kbd", "↵"),
|
|
97
|
+
),
|
|
98
|
+
h("div.lt3-composer__hint", "Answers are grounded in your local workspace via hybrid retrieval. Nothing leaves this machine."),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
|
|
103
|
+
// Retrieval context
|
|
104
|
+
h("aside.lt3-chat__context", { "aria-label": "Retrieval context" },
|
|
105
|
+
h("div.lt3-chat__context-head",
|
|
106
|
+
h("div.lt3-eyebrow", icon("stack-2"), "Retrieval context"),
|
|
107
|
+
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"))),
|
|
108
|
+
),
|
|
109
|
+
ctxBody,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/* ── boot ────────────────────────────────────────────────────────────── */
|
|
114
|
+
loadModel();
|
|
115
|
+
loadConversations();
|
|
116
|
+
renderContext(""); // index-based defaults until the first answer
|
|
117
|
+
if (params.new) startNew(false); else loadInitial();
|
|
118
|
+
|
|
119
|
+
return chat;
|
|
120
|
+
|
|
121
|
+
/* ── conversations ───────────────────────────────────────────────────── */
|
|
122
|
+
async function loadConversations() {
|
|
123
|
+
const res = await api.chatHistory();
|
|
124
|
+
state.conversations = normalizeConversations(res.data);
|
|
125
|
+
state.convSource = res.source;
|
|
126
|
+
listSrc.replaceChildren(c.sourceBadge(res.source));
|
|
127
|
+
renderConversations();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderConversations() {
|
|
131
|
+
if (!state.conversations.length) {
|
|
132
|
+
listItems.replaceChildren(c.emptyState({ icon: "message-off", title: "No conversations", body: "Start a new chat to begin." }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
listItems.replaceChildren(...state.conversations.map((conv) =>
|
|
136
|
+
h("button.lt3-convo", {
|
|
137
|
+
dataset: { active: String(conv.id === state.activeId) },
|
|
138
|
+
on: { click: () => selectConversation(conv.id) },
|
|
139
|
+
},
|
|
140
|
+
icon("message"),
|
|
141
|
+
h("div.lt3-convo__body",
|
|
142
|
+
h("div.lt3-convo__title", conv.title || "Untitled"),
|
|
143
|
+
conv.updated_at && h("div.lt3-convo__meta", timeAgo(conv.updated_at)),
|
|
144
|
+
),
|
|
145
|
+
h("span.lt3-iconbtn.lt3-iconbtn--sm.lt3-convo__del", {
|
|
146
|
+
role: "button", tabindex: "0", "aria-label": "Delete conversation",
|
|
147
|
+
on: { click: (e) => { e.stopPropagation(); removeConversation(conv.id); } },
|
|
148
|
+
}, icon("trash")),
|
|
149
|
+
),
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function selectConversation(id) {
|
|
154
|
+
if (state.streaming) stopStreaming();
|
|
155
|
+
closePanes();
|
|
156
|
+
state.activeId = id;
|
|
157
|
+
const conv = state.conversations.find((x) => x.id === id);
|
|
158
|
+
state.title = conv ? conv.title : "Conversation";
|
|
159
|
+
titleEl.textContent = state.title;
|
|
160
|
+
state.lastTrace = null;
|
|
161
|
+
renderConversations();
|
|
162
|
+
threadInner.replaceChildren(c.loading({ lines: 4 }));
|
|
163
|
+
const res = await api.conversation(id);
|
|
164
|
+
if (state.activeId !== id) return;
|
|
165
|
+
state.messages = (res.data || []).map((m) => ({
|
|
166
|
+
role: m.role === "assistant" ? "ai" : "user",
|
|
167
|
+
content: m.content || "",
|
|
168
|
+
source: res.source,
|
|
169
|
+
}));
|
|
170
|
+
renderMessages();
|
|
171
|
+
const lastUser = [...state.messages].reverse().find((m) => m.role === "user");
|
|
172
|
+
renderContext(lastUser ? lastUser.content : "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function startNew(userInitiated) {
|
|
176
|
+
if (state.streaming) stopStreaming();
|
|
177
|
+
closePanes();
|
|
178
|
+
state.activeId = null;
|
|
179
|
+
state.title = "New chat";
|
|
180
|
+
state.messages = [];
|
|
181
|
+
state.lastTrace = null;
|
|
182
|
+
titleEl.textContent = state.title;
|
|
183
|
+
renderConversations();
|
|
184
|
+
renderMessages();
|
|
185
|
+
renderContext("");
|
|
186
|
+
if (userInitiated) { try { textarea.focus(); } catch {} navigate("chat", { new: "1" }); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function removeConversation(id) {
|
|
190
|
+
await api.deleteConversation(id);
|
|
191
|
+
state.conversations = state.conversations.filter((x) => x.id !== id);
|
|
192
|
+
if (state.activeId === id) startNew(false);
|
|
193
|
+
else renderConversations();
|
|
194
|
+
toast("Conversation removed", "ok");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── messages / thread ───────────────────────────────────────────────── */
|
|
198
|
+
function renderMessages() {
|
|
199
|
+
if (!state.messages.length) {
|
|
200
|
+
threadInner.replaceChildren(emptyThread());
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
threadInner.replaceChildren(...state.messages.map((m) => messageNode(m)));
|
|
204
|
+
scrollToBottom();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function emptyThread() {
|
|
208
|
+
return h("div.lt3-empty", { style: { margin: "auto 0" } },
|
|
209
|
+
h("div.lt3-empty__icon", icon("sparkles")),
|
|
210
|
+
h("div.lt3-empty__title", "Ask anything about your workspace"),
|
|
211
|
+
h("div.lt3-empty__body", "Grounded in your knowledge graph and vector index via hybrid retrieval. Try a question, or pick a starter below."),
|
|
212
|
+
h("div.lt3-cluster", { style: { "justify-content": "center", "margin-top": "var(--lt3-space-2)" } },
|
|
213
|
+
...["How does hybrid search rank results?", "What entities are in my notes?", "Summarize retrieval.md"].map((q) =>
|
|
214
|
+
h("button.lt3-chip", { on: { click: () => { textarea.value = q; autogrow(); send(); } } }, icon("arrow-up-right"), q)),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function messageNode(m) {
|
|
220
|
+
const isUser = m.role === "user";
|
|
221
|
+
const body = h("div.lt3-msg__body",
|
|
222
|
+
h("div.lt3-msg__bubble", m.content),
|
|
223
|
+
m.role === "ai" && m.source && h("div.lt3-row-2", c.sourceBadge(m.source)),
|
|
224
|
+
);
|
|
225
|
+
return h(`div.lt3-msg.lt3-msg--${isUser ? "user" : "ai"}`,
|
|
226
|
+
h("div.lt3-msg__avatar", icon(isUser ? "user" : "sparkles")),
|
|
227
|
+
body,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function scrollToBottom() { requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; }); }
|
|
232
|
+
|
|
233
|
+
function autogrow() {
|
|
234
|
+
textarea.style.height = "auto";
|
|
235
|
+
textarea.style.height = Math.min(200, textarea.scrollHeight) + "px";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ── send + stream ───────────────────────────────────────────────────── */
|
|
239
|
+
async function send() {
|
|
240
|
+
const text = textarea.value.trim();
|
|
241
|
+
if (!text || state.streaming) return;
|
|
242
|
+
textarea.value = ""; autogrow();
|
|
243
|
+
|
|
244
|
+
if (!state.messages.length) threadInner.replaceChildren();
|
|
245
|
+
const userMsg = { role: "user", content: text };
|
|
246
|
+
state.messages.push(userMsg);
|
|
247
|
+
threadInner.append(messageNode(userMsg));
|
|
248
|
+
state.lastQuery = text;
|
|
249
|
+
if (!state.activeId) { state.title = text.slice(0, 48); titleEl.textContent = state.title; }
|
|
250
|
+
|
|
251
|
+
// streaming AI bubble
|
|
252
|
+
const bubble = h("div.lt3-msg__bubble");
|
|
253
|
+
const srcRow = h("div.lt3-row-2");
|
|
254
|
+
const aiNode = h("div.lt3-msg.lt3-msg--ai",
|
|
255
|
+
h("div.lt3-msg__avatar", icon("sparkles")),
|
|
256
|
+
h("div.lt3-msg__body", bubble, srcRow),
|
|
257
|
+
);
|
|
258
|
+
bubble.append(typingIndicator());
|
|
259
|
+
threadInner.append(aiNode);
|
|
260
|
+
scrollToBottom();
|
|
261
|
+
|
|
262
|
+
state.streaming = true;
|
|
263
|
+
state.abort = new AbortController();
|
|
264
|
+
setComposerStreaming(true);
|
|
265
|
+
let started = false;
|
|
266
|
+
|
|
267
|
+
const result = await api.streamChat(
|
|
268
|
+
{ message: text, conversation_id: state.activeId, grounding: state.grounding },
|
|
269
|
+
{
|
|
270
|
+
signal: state.abort.signal,
|
|
271
|
+
onChunk: (_delta, full) => {
|
|
272
|
+
if (!started) { started = true; bubble.replaceChildren(); }
|
|
273
|
+
bubble.textContent = full;
|
|
274
|
+
scrollToBottom();
|
|
275
|
+
},
|
|
276
|
+
onTrace: (trace) => { state.lastTrace = trace; renderContext(text); },
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
state.streaming = false;
|
|
281
|
+
state.abort = null;
|
|
282
|
+
setComposerStreaming(false);
|
|
283
|
+
|
|
284
|
+
if (result.aborted) {
|
|
285
|
+
if (!result.text) { bubble.textContent = "(stopped)"; bubble.classList.add("lt3-faint"); }
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (result.error === "no_model_loaded") {
|
|
289
|
+
aiNode.replaceWith(errorNode(text, result));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!result.text) {
|
|
293
|
+
aiNode.replaceWith(errorNode(text, result));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
bubble.textContent = result.text;
|
|
297
|
+
state.messages.push({ role: "ai", content: result.text, source: result.source });
|
|
298
|
+
srcRow.replaceChildren(c.sourceBadge(result.source));
|
|
299
|
+
if (!state.lastTrace) renderContext(text);
|
|
300
|
+
refreshConversationMeta();
|
|
301
|
+
scrollToBottom();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function errorNode(retryText, result = {}) {
|
|
305
|
+
const noModel = result.error === "no_model_loaded";
|
|
306
|
+
return h("div.lt3-msg.lt3-msg--ai",
|
|
307
|
+
h("div.lt3-msg__avatar", icon("alert-triangle")),
|
|
308
|
+
h("div.lt3-msg__body",
|
|
309
|
+
h("div.lt3-banner.lt3-banner--err",
|
|
310
|
+
icon("alert-triangle"),
|
|
311
|
+
h("div", h("div", { style: { fontWeight: 600 } }, noModel ? "No local model loaded" : "Couldn't reach the model"),
|
|
312
|
+
h("div.lt3-faint", noModel
|
|
313
|
+
? "Load a local or OpenAI-compatible model from Models, then retry this message."
|
|
314
|
+
: "The chat backend isn't responding. Check the local runtime and retry.")),
|
|
315
|
+
h("div.lt3-row-2", { style: { "margin-left": "auto" } },
|
|
316
|
+
noModel && h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => navigate("models") } }, icon("cpu"), "Models"),
|
|
317
|
+
h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => { textarea.value = retryText; autogrow(); send(); } } }, icon("refresh"), "Retry"),
|
|
318
|
+
),
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function typingIndicator() { return h("span.lt3-typing", { "aria-label": "Assistant is typing" }, h("i"), h("i"), h("i")); }
|
|
325
|
+
|
|
326
|
+
function setComposerStreaming(on) {
|
|
327
|
+
if (on) {
|
|
328
|
+
sendBtn.replaceChildren(icon("player-stop"));
|
|
329
|
+
sendBtn.setAttribute("aria-label", "Stop");
|
|
330
|
+
sendBtn.onclick = stopStreaming;
|
|
331
|
+
} else {
|
|
332
|
+
sendBtn.replaceChildren(icon("arrow-up"));
|
|
333
|
+
sendBtn.setAttribute("aria-label", "Send");
|
|
334
|
+
sendBtn.onclick = send;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function stopStreaming() { if (state.abort) { try { state.abort.abort(); } catch {} } }
|
|
339
|
+
|
|
340
|
+
function refreshConversationMeta() {
|
|
341
|
+
// New, unsaved conversation — reload the list so the backend-assigned id /
|
|
342
|
+
// title appears once persisted.
|
|
343
|
+
if (!state.activeId && state.messages.length) loadConversations();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* ── retrieval context (KG · Vector · Hybrid · files) ────────────────── */
|
|
347
|
+
async function renderContext(query) {
|
|
348
|
+
const q = (query || "").trim();
|
|
349
|
+
let hybrid = [], hybridSource = "pending";
|
|
350
|
+
if (q) { const hs = await api.hybridSearch(q, { mode: groundingMode() }); hybrid = hs.data || []; hybridSource = hs.source; }
|
|
351
|
+
|
|
352
|
+
if (!state.graphCache) state.graphCache = await api.graph();
|
|
353
|
+
const graphNodes = (state.lastTrace && state.lastTrace.graph_nodes) ||
|
|
354
|
+
((state.graphCache.data.nodes || []).slice(0, 5).map((n) => ({ id: n.id, title: n.label || n.title, type: n.type })));
|
|
355
|
+
const vectorMatches = (state.lastTrace && state.lastTrace.vector_matches) ||
|
|
356
|
+
hybrid.map((r) => ({ path: r.path, score: r.vector }));
|
|
357
|
+
const fileRefs = (state.lastTrace && state.lastTrace.source_files && state.lastTrace.source_files.map((s) => s.source)) ||
|
|
358
|
+
[...new Set(hybrid.map((r) => r.path))];
|
|
359
|
+
|
|
360
|
+
const overall = q ? hybridSource : state.graphCache.source;
|
|
361
|
+
ctxSrc.replaceChildren(c.sourceBadge(overall));
|
|
362
|
+
|
|
363
|
+
ctxBody.replaceChildren(
|
|
364
|
+
ctxSection("Knowledge graph", "chart-dots-3",
|
|
365
|
+
graphNodes.length
|
|
366
|
+
? graphNodes.slice(0, 6).map((n) => ctxItem("var(--lt3-pillar-graph)", n.title || n.id, n.type))
|
|
367
|
+
: [ctxEmpty("No linked entities yet")]),
|
|
368
|
+
|
|
369
|
+
ctxSection("Vector matches", "grid-dots",
|
|
370
|
+
vectorMatches.length
|
|
371
|
+
? vectorMatches.slice(0, 5).map((v) => ctxItem("var(--lt3-pillar-vector)", v.path, v.score != null ? v.score.toFixed(2) : null))
|
|
372
|
+
: [ctxEmpty("Run a query to see vector matches")]),
|
|
373
|
+
|
|
374
|
+
ctxSection("Hybrid search", "arrows-join",
|
|
375
|
+
hybrid.length
|
|
376
|
+
? hybrid.slice(0, 4).map((r) => ctxItem("var(--lt3-pillar-hybrid)", r.title || r.path, r.score != null ? r.score.toFixed(2) : null))
|
|
377
|
+
: [ctxEmpty("Ask a question to fuse graph + vector")]),
|
|
378
|
+
|
|
379
|
+
ctxSection("Indexed files", "files",
|
|
380
|
+
fileRefs.length
|
|
381
|
+
? fileRefs.slice(0, 6).map((p) => ctxItem("var(--faint)", p, null))
|
|
382
|
+
: [ctxEmpty("No file references yet")]),
|
|
383
|
+
|
|
384
|
+
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"),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function ctxSection(title, icn, children) {
|
|
389
|
+
return h("section",
|
|
390
|
+
h("div.lt3-ctx-sec__title", icon(icn), title),
|
|
391
|
+
h("div", children),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
function ctxItem(color, label, score) {
|
|
395
|
+
return h("div.lt3-ctx-item",
|
|
396
|
+
h("span.lt3-ctx-item__dot", { style: { background: color } }),
|
|
397
|
+
h("span.lt3-ctx-item__label", { title: String(label) }, String(label)),
|
|
398
|
+
score != null && h("span.lt3-ctx-item__score", String(score)),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
function ctxEmpty(text) { return h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)", padding: "var(--lt3-space-1) 0" } }, text); }
|
|
402
|
+
|
|
403
|
+
function groundingMode() {
|
|
404
|
+
if (state.grounding.graph && state.grounding.vector) return "hybrid";
|
|
405
|
+
if (state.grounding.vector) return "vector";
|
|
406
|
+
if (state.grounding.graph) return "graph";
|
|
407
|
+
return "hybrid";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* ── misc ────────────────────────────────────────────────────────────── */
|
|
411
|
+
async function loadModel() {
|
|
412
|
+
const res = await api.models();
|
|
413
|
+
state.model = (res.data && res.data.current) || "";
|
|
414
|
+
state.modelSource = res.source;
|
|
415
|
+
modelPill.replaceChildren(c.pill(state.model ? shortModel(state.model) : "No model", state.model ? "info" : "warn", { dot: true }));
|
|
416
|
+
barSrc.replaceChildren(c.sourceBadge(res.source));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function loadInitial() {
|
|
420
|
+
// Land on the most recent conversation if one exists, else an empty thread.
|
|
421
|
+
api.chatHistory().then((res) => {
|
|
422
|
+
const list = normalizeConversations(res.data);
|
|
423
|
+
if (list.length) selectConversation(list[0].id);
|
|
424
|
+
else renderMessages();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function togglePane(which) {
|
|
429
|
+
const other = which === "list" ? "context" : "list";
|
|
430
|
+
chat.dataset[other] = "closed";
|
|
431
|
+
chat.dataset[which] = chat.dataset[which] === "open" ? "closed" : "open";
|
|
432
|
+
}
|
|
433
|
+
function closePanes() { chat.dataset.list = "closed"; chat.dataset.context = "closed"; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
|
437
|
+
function normalizeConversations(data) {
|
|
438
|
+
const list = Array.isArray(data) ? data : (data && Array.isArray(data.conversations) ? data.conversations : []);
|
|
439
|
+
return list.map((conv, i) => ({
|
|
440
|
+
id: conv.id || conv.conversation_id || `conv-${i}`,
|
|
441
|
+
title: conv.title || conv.name || "Untitled",
|
|
442
|
+
updated_at: conv.updated_at || conv.last_message_at || conv.timestamp || null,
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function shortModel(id) {
|
|
447
|
+
const s = String(id || "model");
|
|
448
|
+
const tail = s.includes("/") ? s.split("/").pop() : s;
|
|
449
|
+
return tail.length > 22 ? tail.slice(0, 21) + "…" : tail;
|
|
450
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* View: Files — connected sources & indexed documents.
|
|
3
|
+
* Lists the documents the workspace has ingested, with a human-readable size
|
|
4
|
+
* roll-up and per-file index state. Data comes from /local/list (live) and
|
|
5
|
+
* degrades to clearly-badged sample files when the local agent isn't reachable.
|
|
6
|
+
*
|
|
7
|
+
* View contract (shared by all views):
|
|
8
|
+
* export async function render(ctx) -> single DOM node
|
|
9
|
+
* ctx = { h, icon, api, store, c, route, params, navigate, toast }
|
|
10
|
+
* ========================================================================== */
|
|
11
|
+
|
|
12
|
+
import * as fx from "../core/fixtures.js";
|
|
13
|
+
import { timeAgo } from "../core/dom.js";
|
|
14
|
+
|
|
15
|
+
/** Tabler glyph per file kind — keeps the table scannable. */
|
|
16
|
+
const KIND_ICON = {
|
|
17
|
+
markdown: "file-text",
|
|
18
|
+
config: "settings",
|
|
19
|
+
image: "photo",
|
|
20
|
+
data: "table",
|
|
21
|
+
default: "file",
|
|
22
|
+
};
|
|
23
|
+
const iconForKind = (k) => KIND_ICON[k] || KIND_ICON.default;
|
|
24
|
+
|
|
25
|
+
/** Bytes → compact human string (1.0 KB / 4.7 KB / 180 KB / 1.2 MB). */
|
|
26
|
+
function humanSize(bytes) {
|
|
27
|
+
const n = Number(bytes);
|
|
28
|
+
if (!Number.isFinite(n) || n < 0) return "—";
|
|
29
|
+
if (n < 1024) return `${n} B`;
|
|
30
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
31
|
+
let v = n / 1024, i = 0;
|
|
32
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
33
|
+
return `${v.toFixed(v >= 100 || Number.isInteger(v) ? 0 : 1)} ${units[i]}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Live shape may be {files:[...]} or a bare array — normalize defensively. */
|
|
37
|
+
function normalize(data) {
|
|
38
|
+
const list = Array.isArray(data) ? data : (data && Array.isArray(data.files) ? data.files : null);
|
|
39
|
+
if (!list) return null;
|
|
40
|
+
return list.map((f) => ({
|
|
41
|
+
name: f.name || (f.path ? String(f.path).split("/").pop() : "untitled"),
|
|
42
|
+
kind: f.kind || "default",
|
|
43
|
+
size: Number(f.size) || 0,
|
|
44
|
+
path: f.path || f.name || "",
|
|
45
|
+
indexed: f.indexed === true,
|
|
46
|
+
updated: f.updated || f.modified || f.mtime || null,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function render(ctx) {
|
|
51
|
+
const { h, icon, api, c, navigate, toast } = ctx;
|
|
52
|
+
|
|
53
|
+
// Folder connection/watch needs the desktop local-agent connector, which is
|
|
54
|
+
// not enabled in this build. Say so plainly rather than implying it's coming.
|
|
55
|
+
const unavailableToast = () =>
|
|
56
|
+
toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
|
|
57
|
+
|
|
58
|
+
const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
|
|
59
|
+
const srcSlot = h("span", c.sourceBadge("pending"));
|
|
60
|
+
const tableHost = h("div", c.loading({ lines: 4 }));
|
|
61
|
+
|
|
62
|
+
const root = h("div.lt3-stack-6",
|
|
63
|
+
c.viewHeader({
|
|
64
|
+
eyebrow: "Data",
|
|
65
|
+
title: "Files",
|
|
66
|
+
sub: "Connected sources and the documents Lattice has indexed for retrieval. Everything stays on this machine.",
|
|
67
|
+
actions: [
|
|
68
|
+
h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
|
|
69
|
+
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"),
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
statHost,
|
|
73
|
+
h("div.lt3-drop",
|
|
74
|
+
h("div.lt3-pillar__icon", icon("cloud-upload")),
|
|
75
|
+
h("div",
|
|
76
|
+
h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag files or connect a folder"),
|
|
77
|
+
h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
|
|
78
|
+
"Lattice watches the source, chunks it, embeds it, and links it into the knowledge graph."),
|
|
79
|
+
),
|
|
80
|
+
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"),
|
|
81
|
+
),
|
|
82
|
+
c.panel({
|
|
83
|
+
head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
|
|
84
|
+
h("div",
|
|
85
|
+
h("div.lt3-eyebrow", "Index"),
|
|
86
|
+
h("h3.lt3-panel__title", "Indexed documents"),
|
|
87
|
+
),
|
|
88
|
+
srcSlot,
|
|
89
|
+
),
|
|
90
|
+
children: tableHost,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
hydrate(ctx, { statHost, srcSlot, tableHost });
|
|
95
|
+
return root;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function hydrate(ctx, slots) {
|
|
99
|
+
const { h, icon, api, c, toast } = ctx;
|
|
100
|
+
const { statHost, srcSlot, tableHost } = slots;
|
|
101
|
+
|
|
102
|
+
// /local/list is permission-gated: it requires a `path` query param and, in
|
|
103
|
+
// the browser, returns a permission-request object rather than a bare file
|
|
104
|
+
// list. Probe it with the required param (avoids a 422) and only treat an
|
|
105
|
+
// actual listing as live — otherwise show clearly-badged sample documents.
|
|
106
|
+
const probe = await api.raw("/local/list?path=" + encodeURIComponent("."));
|
|
107
|
+
const liveFiles = probe.ok && probe.data && !probe.data.permission_required
|
|
108
|
+
? normalize(probe.data)
|
|
109
|
+
: null;
|
|
110
|
+
const source = liveFiles ? "live" : "placeholder";
|
|
111
|
+
const files = liveFiles || normalize(fx.FILES) || [];
|
|
112
|
+
srcSlot.replaceChildren(c.sourceBadge(source));
|
|
113
|
+
|
|
114
|
+
// ── Stat roll-up ──────────────────────────────────────────────────────────
|
|
115
|
+
const indexedCount = files.filter((f) => f.indexed).length;
|
|
116
|
+
const sourceCount = new Set(
|
|
117
|
+
files.map((f) => (f.path.includes("/") ? f.path.split("/")[0] : "root")),
|
|
118
|
+
).size;
|
|
119
|
+
const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
120
|
+
statHost.replaceChildren(
|
|
121
|
+
c.stat({ label: "Total files", value: c.fmtNum(files.length), icon: "files" }),
|
|
122
|
+
c.stat({ label: "Indexed", value: c.fmtNum(indexedCount), icon: "circle-check" }),
|
|
123
|
+
c.stat({ label: "Sources", value: c.fmtNum(sourceCount), icon: "database" }),
|
|
124
|
+
c.stat({ label: "Total size", value: humanSize(totalBytes), icon: "weight" }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// ── Empty state ─────────────────────────────────────────────────────────────
|
|
128
|
+
if (!files.length) {
|
|
129
|
+
tableHost.replaceChildren(c.emptyState({
|
|
130
|
+
icon: "folder-off",
|
|
131
|
+
title: "No documents indexed yet",
|
|
132
|
+
body: "Connect a folder and Lattice will index it for hybrid retrieval.",
|
|
133
|
+
action: h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm",
|
|
134
|
+
{ title: "Requires the desktop local agent (not in this build)",
|
|
135
|
+
on: { click: () => toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn") } },
|
|
136
|
+
icon("folder-plus"), "Connect folder"),
|
|
137
|
+
}));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Table ───────────────────────────────────────────────────────────────────
|
|
142
|
+
const columns = [
|
|
143
|
+
{
|
|
144
|
+
key: "name", label: "Name",
|
|
145
|
+
render: (row) => h("div.lt3-row-2",
|
|
146
|
+
h("span.lt3-filerow__icon", icon(iconForKind(row.kind))),
|
|
147
|
+
h("span", { style: { "font-weight": "var(--lt3-weight-medium)" } }, row.name),
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: "path", label: "Path", width: "30%",
|
|
152
|
+
render: (row) => h("span.lt3-mono.lt3-faint", row.path || "—"),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
key: "size", label: "Size", width: "92px",
|
|
156
|
+
render: (row) => h("span.lt3-mono", humanSize(row.size)),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: "indexed", label: "Indexed", width: "120px",
|
|
160
|
+
render: (row) => row.indexed
|
|
161
|
+
? c.statePill("indexed")
|
|
162
|
+
: c.statePill("pending"),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
key: "updated", label: "Updated", width: "104px",
|
|
166
|
+
render: (row) => h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } },
|
|
167
|
+
row.updated ? timeAgo(row.updated) : "—"),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
key: "_actions", label: "", width: "44px",
|
|
171
|
+
render: (row) => h("button.lt3-iconbtn.lt3-iconbtn--sm", {
|
|
172
|
+
"aria-label": `Actions for ${row.name}`,
|
|
173
|
+
title: "Requires the desktop local agent (not in this build)",
|
|
174
|
+
on: { click: () => toast(`Per-file actions require the Lattice desktop local agent — not available in this build.`, "warn") },
|
|
175
|
+
}, icon("dots-vertical")),
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
tableHost.replaceChildren(c.table(columns, files));
|
|
180
|
+
}
|