agent-knowledge 1.0.4 → 1.0.5
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/dist/ui/app.js +953 -953
- package/dist/ui/styles.css +1508 -1509
- package/package.json +1 -1
package/dist/ui/app.js
CHANGED
|
@@ -1,953 +1,953 @@
|
|
|
1
|
-
/* agent-knowledge dashboard — vanilla JS SPA */
|
|
2
|
-
(function () {
|
|
3
|
-
'use strict';
|
|
4
|
-
|
|
5
|
-
// ── State ──────────────────────────────────────────────────────────────────
|
|
6
|
-
const state = {
|
|
7
|
-
activeTab: 'knowledge',
|
|
8
|
-
knowledge: { entries: [], activeCategory: 'all' },
|
|
9
|
-
search: {
|
|
10
|
-
query: '',
|
|
11
|
-
results: [],
|
|
12
|
-
role: 'all',
|
|
13
|
-
scope: 'all',
|
|
14
|
-
ranked: true,
|
|
15
|
-
semantic: false,
|
|
16
|
-
loading: false,
|
|
17
|
-
},
|
|
18
|
-
sessions: { list: [], projectFilter: '', loading: false },
|
|
19
|
-
embeddings: { stats: null, loading: false },
|
|
20
|
-
panel: { open: false, type: null, data: null },
|
|
21
|
-
stats: { knowledgeCount: 0, sessionCount: 0 },
|
|
22
|
-
connected: false,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// ── DOM refs ───────────────────────────────────────────────────────────────
|
|
26
|
-
const $ = (id) => document.getElementById(id);
|
|
27
|
-
const el = {
|
|
28
|
-
tabs: {
|
|
29
|
-
knowledge: $('tab-knowledge'),
|
|
30
|
-
search: $('tab-search'),
|
|
31
|
-
sessions: $('tab-sessions'),
|
|
32
|
-
embeddings: $('tab-embeddings'),
|
|
33
|
-
},
|
|
34
|
-
views: {
|
|
35
|
-
knowledge: $('view-knowledge'),
|
|
36
|
-
search: $('view-search'),
|
|
37
|
-
sessions: $('view-sessions'),
|
|
38
|
-
embeddings: $('view-embeddings'),
|
|
39
|
-
},
|
|
40
|
-
knowledgeGrid: $('knowledge-grid'),
|
|
41
|
-
knowledgeEmpty: $('knowledge-empty'),
|
|
42
|
-
knowledgeCategories: $('knowledge-categories'),
|
|
43
|
-
searchInput: $('search-input'),
|
|
44
|
-
searchResults: $('search-results'),
|
|
45
|
-
searchEmpty: $('search-empty'),
|
|
46
|
-
searchRoleFilters: $('search-role-filters'),
|
|
47
|
-
modeRanked: $('mode-ranked'),
|
|
48
|
-
modeSemantic: $('mode-semantic'),
|
|
49
|
-
modeRegex: $('mode-regex'),
|
|
50
|
-
sessionsList: $('sessions-list'),
|
|
51
|
-
sessionsEmpty: $('sessions-empty'),
|
|
52
|
-
sessionProjectFilter: $('session-project-filter'),
|
|
53
|
-
searchScopes: $('search-scopes'),
|
|
54
|
-
sidePanel: $('side-panel'),
|
|
55
|
-
panelTitle: $('panel-title'),
|
|
56
|
-
panelBody: $('panel-body'),
|
|
57
|
-
panelClose: $('panel-close'),
|
|
58
|
-
connectionStatus: $('connection-status'),
|
|
59
|
-
statKnowledge: $('stat-knowledge'),
|
|
60
|
-
statSessions: $('stat-sessions'),
|
|
61
|
-
statVectors: $('stat-vectors'),
|
|
62
|
-
embeddingsStatsGrid: $('embedding-stats-grid'),
|
|
63
|
-
embeddingsEmpty: $('embeddings-empty'),
|
|
64
|
-
embeddingsStatus: $('embeddings-status'),
|
|
65
|
-
themeToggle: $('theme-toggle'),
|
|
66
|
-
version: $('version'),
|
|
67
|
-
loadingOverlay: $('loading-overlay'),
|
|
68
|
-
toastContainer: $('toast-container'),
|
|
69
|
-
contentWrapper: $('content-wrapper'),
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// ── Utilities ──────────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
function esc(str) {
|
|
75
|
-
const d = document.createElement('div');
|
|
76
|
-
d.textContent = str;
|
|
77
|
-
return d.innerHTML;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function debounce(fn, ms) {
|
|
81
|
-
let t;
|
|
82
|
-
return (...args) => {
|
|
83
|
-
clearTimeout(t);
|
|
84
|
-
t = setTimeout(() => fn(...args), ms);
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function relativeTime(dateStr) {
|
|
89
|
-
if (!dateStr) return '';
|
|
90
|
-
const now = Date.now();
|
|
91
|
-
const then = new Date(dateStr).getTime();
|
|
92
|
-
const diff = now - then;
|
|
93
|
-
if (diff < 0) return 'just now';
|
|
94
|
-
const secs = Math.floor(diff / 1000);
|
|
95
|
-
if (secs < 60) return 'just now';
|
|
96
|
-
const mins = Math.floor(secs / 60);
|
|
97
|
-
if (mins < 60) return `${mins}m ago`;
|
|
98
|
-
const hrs = Math.floor(mins / 60);
|
|
99
|
-
if (hrs < 24) return `${hrs}h ago`;
|
|
100
|
-
const days = Math.floor(hrs / 24);
|
|
101
|
-
if (days < 30) return `${days}d ago`;
|
|
102
|
-
const months = Math.floor(days / 30);
|
|
103
|
-
if (months < 12) return `${months}mo ago`;
|
|
104
|
-
return `${Math.floor(months / 12)}y ago`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function renderMd(raw) {
|
|
108
|
-
if (!raw) return '';
|
|
109
|
-
try {
|
|
110
|
-
const html = marked.parse(raw, { breaks: true, gfm: true });
|
|
111
|
-
const clean = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'] });
|
|
112
|
-
const wrapper = document.createElement('div');
|
|
113
|
-
wrapper.innerHTML = clean;
|
|
114
|
-
wrapper.querySelectorAll('pre code').forEach((block) => {
|
|
115
|
-
try {
|
|
116
|
-
hljs.highlightElement(block);
|
|
117
|
-
} catch (_) {
|
|
118
|
-
/* noop */
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
return wrapper.innerHTML;
|
|
122
|
-
} catch {
|
|
123
|
-
return esc(raw);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function highlightExcerpt(text, query) {
|
|
128
|
-
if (!text || !query) return esc(text || '');
|
|
129
|
-
try {
|
|
130
|
-
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
131
|
-
const re = new RegExp(`(${escaped})`, 'gi');
|
|
132
|
-
return esc(text).replace(re, '<mark>$1</mark>');
|
|
133
|
-
} catch {
|
|
134
|
-
return esc(text);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function formatScore(score, metadata) {
|
|
139
|
-
if (score == null) return '';
|
|
140
|
-
const pct = Math.min(Math.max(score * 100, 0), 100);
|
|
141
|
-
const tfidf = metadata?.tfidfScore;
|
|
142
|
-
const semantic = metadata?.semanticScore;
|
|
143
|
-
const recency = metadata?.recencyMultiplier;
|
|
144
|
-
const alpha = metadata?.blendAlpha;
|
|
145
|
-
|
|
146
|
-
let tooltip = `Score: ${score.toFixed(3)}`;
|
|
147
|
-
if (tfidf != null && semantic != null) {
|
|
148
|
-
tooltip += ` (TF-IDF: ${tfidf.toFixed(2)} \u00d7 ${((alpha || 0.3) * 100).toFixed(0)}% + Semantic: ${semantic.toFixed(2)} \u00d7 ${((1 - (alpha || 0.3)) * 100).toFixed(0)}%)`;
|
|
149
|
-
}
|
|
150
|
-
if (recency != null) tooltip += ` \u00d7 recency ${(recency * 100).toFixed(0)}%`;
|
|
151
|
-
|
|
152
|
-
const recencyTag =
|
|
153
|
-
recency != null && recency < 0.95
|
|
154
|
-
? `<span class="recency-tag" title="Recency: ${(recency * 100).toFixed(0)}%">${(recency * 100).toFixed(0)}%</span>`
|
|
155
|
-
: '';
|
|
156
|
-
const scoreType =
|
|
157
|
-
tfidf != null && semantic != null
|
|
158
|
-
? '<span class="score-type hybrid">hybrid</span>'
|
|
159
|
-
: tfidf != null
|
|
160
|
-
? '<span class="score-type tfidf">tf-idf</span>'
|
|
161
|
-
: '';
|
|
162
|
-
return `<div class="score-bar" title="${tooltip}">
|
|
163
|
-
<div class="score-fill" style="width:${pct}%"></div>
|
|
164
|
-
<span class="score-label">${score.toFixed(2)}${recencyTag}${scoreType}</span>
|
|
165
|
-
</div>`;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function toast(msg, type = 'info', duration = 4000) {
|
|
169
|
-
const t = document.createElement('div');
|
|
170
|
-
t.className = `toast toast-${type}`;
|
|
171
|
-
const icons = { info: 'info', success: 'check_circle', error: 'error', warning: 'warning' };
|
|
172
|
-
t.innerHTML = `<span class="material-symbols-outlined toast-icon">${icons[type] || 'info'}</span>
|
|
173
|
-
<span class="toast-msg">${esc(msg)}</span>`;
|
|
174
|
-
el.toastContainer.appendChild(t);
|
|
175
|
-
requestAnimationFrame(() => t.classList.add('show'));
|
|
176
|
-
setTimeout(() => {
|
|
177
|
-
t.classList.remove('show');
|
|
178
|
-
t.addEventListener('transitionend', () => t.remove());
|
|
179
|
-
}, duration);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── API ────────────────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
async function api(path) {
|
|
185
|
-
const res = await fetch(`/api${path}`);
|
|
186
|
-
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
187
|
-
return res.json();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
let ws = null;
|
|
193
|
-
let wsRetry = null;
|
|
194
|
-
|
|
195
|
-
function wsConnect() {
|
|
196
|
-
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
197
|
-
ws = new WebSocket(`${proto}://${location.host}`);
|
|
198
|
-
|
|
199
|
-
ws.addEventListener('open', () => {
|
|
200
|
-
state.connected = true;
|
|
201
|
-
updateConnectionStatus();
|
|
202
|
-
el.loadingOverlay.classList.add('hidden');
|
|
203
|
-
if (wsRetry) {
|
|
204
|
-
clearTimeout(wsRetry);
|
|
205
|
-
wsRetry = null;
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
ws.addEventListener('message', (evt) => {
|
|
210
|
-
try {
|
|
211
|
-
const msg = JSON.parse(evt.data);
|
|
212
|
-
handleWsMessage(msg);
|
|
213
|
-
} catch {
|
|
214
|
-
/* ignore non-json */
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
ws.addEventListener('close', () => {
|
|
219
|
-
state.connected = false;
|
|
220
|
-
updateConnectionStatus();
|
|
221
|
-
scheduleReconnect();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
ws.addEventListener('error', () => {
|
|
225
|
-
state.connected = false;
|
|
226
|
-
updateConnectionStatus();
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function scheduleReconnect() {
|
|
231
|
-
if (wsRetry) return;
|
|
232
|
-
wsRetry = setTimeout(() => {
|
|
233
|
-
wsRetry = null;
|
|
234
|
-
wsConnect();
|
|
235
|
-
}, 3000);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function handleWsMessage(msg) {
|
|
239
|
-
switch (msg.type) {
|
|
240
|
-
case 'reload':
|
|
241
|
-
location.reload();
|
|
242
|
-
return;
|
|
243
|
-
case 'state':
|
|
244
|
-
if (msg.knowledge) {
|
|
245
|
-
state.knowledge.entries = msg.knowledge;
|
|
246
|
-
renderKnowledge();
|
|
247
|
-
}
|
|
248
|
-
if (msg.sessions) {
|
|
249
|
-
state.sessions.list = msg.sessions;
|
|
250
|
-
renderSessions();
|
|
251
|
-
}
|
|
252
|
-
if (msg.stats) {
|
|
253
|
-
state.stats.knowledgeCount = msg.stats.knowledge_entries || 0;
|
|
254
|
-
state.stats.sessionCount = msg.stats.session_count || 0;
|
|
255
|
-
if (msg.stats.version) el.version.textContent = 'v' + msg.stats.version;
|
|
256
|
-
updateStats();
|
|
257
|
-
}
|
|
258
|
-
el.loadingOverlay.classList.add('hidden');
|
|
259
|
-
break;
|
|
260
|
-
case 'knowledge:update':
|
|
261
|
-
case 'knowledge:change':
|
|
262
|
-
loadKnowledge();
|
|
263
|
-
break;
|
|
264
|
-
case 'session:update':
|
|
265
|
-
case 'session:new':
|
|
266
|
-
loadSessions();
|
|
267
|
-
break;
|
|
268
|
-
case 'stats':
|
|
269
|
-
if (msg.data) {
|
|
270
|
-
if (msg.data.knowledgeCount != null) state.stats.knowledgeCount = msg.data.knowledgeCount;
|
|
271
|
-
if (msg.data.sessionCount != null) state.stats.sessionCount = msg.data.sessionCount;
|
|
272
|
-
updateStats();
|
|
273
|
-
}
|
|
274
|
-
break;
|
|
275
|
-
case 'version':
|
|
276
|
-
if (msg.data) el.version.textContent = msg.data;
|
|
277
|
-
break;
|
|
278
|
-
default:
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function updateConnectionStatus() {
|
|
284
|
-
const s = el.connectionStatus;
|
|
285
|
-
if (state.connected) {
|
|
286
|
-
s.className = 'status-badge connected';
|
|
287
|
-
s.textContent = 'Connected';
|
|
288
|
-
} else {
|
|
289
|
-
s.className = 'status-badge disconnected';
|
|
290
|
-
s.textContent = 'Disconnected';
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Stats ──────────────────────────────────────────────────────────────────
|
|
295
|
-
|
|
296
|
-
function updateStats() {
|
|
297
|
-
el.statKnowledge.querySelector('.stat-value').textContent = state.stats.knowledgeCount;
|
|
298
|
-
el.statSessions.querySelector('.stat-value').textContent = state.stats.sessionCount;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ── Theme ──────────────────────────────────────────────────────────────────
|
|
302
|
-
|
|
303
|
-
function initTheme() {
|
|
304
|
-
const saved = localStorage.getItem('agent-knowledge-theme');
|
|
305
|
-
const theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
306
|
-
applyTheme(theme);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function applyTheme(theme) {
|
|
310
|
-
document.documentElement.setAttribute('data-theme', theme);
|
|
311
|
-
localStorage.setItem('agent-knowledge-theme', theme);
|
|
312
|
-
const icon = el.themeToggle.querySelector('.theme-icon');
|
|
313
|
-
if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function toggleTheme() {
|
|
317
|
-
const cur = document.documentElement.getAttribute('data-theme') || 'light';
|
|
318
|
-
applyTheme(cur === 'dark' ? 'light' : 'dark');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ── Tabs ───────────────────────────────────────────────────────────────────
|
|
322
|
-
|
|
323
|
-
function switchTab(name) {
|
|
324
|
-
if (state.activeTab === name) return;
|
|
325
|
-
state.activeTab = name;
|
|
326
|
-
|
|
327
|
-
Object.keys(el.tabs).forEach((k) => {
|
|
328
|
-
const active = k === name;
|
|
329
|
-
el.tabs[k].classList.toggle('active', active);
|
|
330
|
-
el.tabs[k].setAttribute('aria-selected', active);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
Object.keys(el.views).forEach((k) => {
|
|
334
|
-
const active = k === name;
|
|
335
|
-
el.views[k].classList.toggle('active', active);
|
|
336
|
-
el.views[k].hidden = !active;
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
// Close side panel on tab switch
|
|
340
|
-
if (state.panel.open) closePanel();
|
|
341
|
-
|
|
342
|
-
if (name === 'knowledge' && state.knowledge.entries.length === 0) loadKnowledge();
|
|
343
|
-
if (name === 'sessions' && state.sessions.list.length === 0) loadSessions();
|
|
344
|
-
if (name === 'embeddings') loadEmbeddingStats();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ── Knowledge ──────────────────────────────────────────────────────────────
|
|
348
|
-
|
|
349
|
-
async function loadKnowledge() {
|
|
350
|
-
try {
|
|
351
|
-
const data = await api('/knowledge');
|
|
352
|
-
const entries = Array.isArray(data) ? data : data.entries || [];
|
|
353
|
-
state.knowledge.entries = entries;
|
|
354
|
-
state.stats.knowledgeCount = entries.length;
|
|
355
|
-
updateStats();
|
|
356
|
-
renderKnowledge();
|
|
357
|
-
} catch (err) {
|
|
358
|
-
toast(`Failed to load knowledge: ${err.message}`, 'error');
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function renderKnowledge() {
|
|
363
|
-
const cat = state.knowledge.activeCategory;
|
|
364
|
-
const filtered =
|
|
365
|
-
cat === 'all'
|
|
366
|
-
? state.knowledge.entries
|
|
367
|
-
: state.knowledge.entries.filter((e) => e.category === cat);
|
|
368
|
-
|
|
369
|
-
if (filtered.length === 0) {
|
|
370
|
-
el.knowledgeGrid.innerHTML = '';
|
|
371
|
-
el.knowledgeEmpty.classList.remove('hidden');
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
el.knowledgeEmpty.classList.add('hidden');
|
|
376
|
-
el.knowledgeGrid.innerHTML = filtered
|
|
377
|
-
.map((entry) => {
|
|
378
|
-
const cat = entry.category || 'notes';
|
|
379
|
-
const catIcons = {
|
|
380
|
-
projects: 'code',
|
|
381
|
-
people: 'group',
|
|
382
|
-
decisions: 'gavel',
|
|
383
|
-
workflows: 'account_tree',
|
|
384
|
-
notes: 'sticky_note_2',
|
|
385
|
-
};
|
|
386
|
-
const icon = catIcons[cat] || 'article';
|
|
387
|
-
const title = entry.title || entry.path || entry.id || 'Untitled';
|
|
388
|
-
const preview = entry.preview || entry.excerpt || '';
|
|
389
|
-
const tags = (entry.tags || [])
|
|
390
|
-
.slice(0, 3)
|
|
391
|
-
.map((t) => `<span class="card-tag">${esc(t)}</span>`)
|
|
392
|
-
.join('');
|
|
393
|
-
const time = relativeTime(entry.updated || entry.created);
|
|
394
|
-
|
|
395
|
-
return `<div class="knowledge-card" data-path="${esc(entry.path || entry.id || '')}" tabindex="0" role="button">
|
|
396
|
-
<span class="card-category" data-cat="${esc(cat)}">
|
|
397
|
-
<span class="material-symbols-outlined" style="font-size:14px">${icon}</span>
|
|
398
|
-
${esc(cat)}
|
|
399
|
-
</span>
|
|
400
|
-
${time ? `<span class="card-date">${time}</span>` : ''}
|
|
401
|
-
<div class="card-title">${esc(title)}</div>
|
|
402
|
-
${tags ? `<div class="card-tags">${tags}</div>` : ''}
|
|
403
|
-
</div>`;
|
|
404
|
-
})
|
|
405
|
-
.join('');
|
|
406
|
-
|
|
407
|
-
el.knowledgeGrid.querySelectorAll('.knowledge-card').forEach((card) => {
|
|
408
|
-
card.addEventListener('click', () => openKnowledgePanel(card.dataset.path));
|
|
409
|
-
card.addEventListener('keydown', (e) => {
|
|
410
|
-
if (e.key === 'Enter') openKnowledgePanel(card.dataset.path);
|
|
411
|
-
});
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async function openKnowledgePanel(path) {
|
|
416
|
-
if (!path) return;
|
|
417
|
-
try {
|
|
418
|
-
const data = await api(`/knowledge/${encodeURIComponent(path)}`);
|
|
419
|
-
const title = data.title || data.path || path;
|
|
420
|
-
const content = data.content || data.body || '';
|
|
421
|
-
openPanel('knowledge', { title, content, meta: data });
|
|
422
|
-
} catch (err) {
|
|
423
|
-
toast(`Failed to load entry: ${err.message}`, 'error');
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// ── Search ─────────────────────────────────────────────────────────────────
|
|
428
|
-
|
|
429
|
-
const doSearch = debounce(async () => {
|
|
430
|
-
const q = state.search.query.trim();
|
|
431
|
-
if (!q) {
|
|
432
|
-
state.search.results = [];
|
|
433
|
-
renderSearchResults();
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
state.search.loading = true;
|
|
437
|
-
renderSearchResults();
|
|
438
|
-
try {
|
|
439
|
-
const params = new URLSearchParams({ q });
|
|
440
|
-
let endpoint;
|
|
441
|
-
if (state.search.scope !== 'all') {
|
|
442
|
-
params.set('scope', state.search.scope);
|
|
443
|
-
params.set('semantic', state.search.semantic);
|
|
444
|
-
endpoint = `/sessions/recall?${params}`;
|
|
445
|
-
} else {
|
|
446
|
-
if (state.search.role !== 'all') params.set('role', state.search.role);
|
|
447
|
-
params.set('ranked', state.search.ranked);
|
|
448
|
-
params.set('semantic', state.search.semantic);
|
|
449
|
-
endpoint = `/sessions/search?${params}`;
|
|
450
|
-
}
|
|
451
|
-
const data = await api(endpoint);
|
|
452
|
-
state.search.results = Array.isArray(data) ? data : data.results || [];
|
|
453
|
-
} catch (err) {
|
|
454
|
-
toast(`Search failed: ${err.message}`, 'error');
|
|
455
|
-
state.search.results = [];
|
|
456
|
-
}
|
|
457
|
-
state.search.loading = false;
|
|
458
|
-
renderSearchResults();
|
|
459
|
-
}, 300);
|
|
460
|
-
|
|
461
|
-
function renderSearchResults() {
|
|
462
|
-
const { results, loading, query } = state.search;
|
|
463
|
-
|
|
464
|
-
if (loading) {
|
|
465
|
-
el.searchResults.innerHTML =
|
|
466
|
-
'<div class="loading-inline"><div class="loading-spinner small"></div><span>Searching...</span></div>';
|
|
467
|
-
el.searchEmpty.classList.add('hidden');
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (!query.trim()) {
|
|
472
|
-
el.searchResults.innerHTML = '';
|
|
473
|
-
el.searchEmpty.classList.remove('hidden');
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (results.length === 0) {
|
|
478
|
-
el.searchResults.innerHTML = '';
|
|
479
|
-
el.searchEmpty.querySelector('.empty-text').textContent = 'No results found';
|
|
480
|
-
el.searchEmpty.querySelector('.empty-hint').textContent = `No matches for "${query}"`;
|
|
481
|
-
el.searchEmpty.classList.remove('hidden');
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
el.searchEmpty.classList.add('hidden');
|
|
486
|
-
el.searchResults.innerHTML = results
|
|
487
|
-
.map((r) => {
|
|
488
|
-
const sessionId = r.id || r.sessionId || r.session_id || '';
|
|
489
|
-
const excerpt = r.excerpt || r.text || r.content || '';
|
|
490
|
-
const role = r.role || '';
|
|
491
|
-
const score = r.score;
|
|
492
|
-
const meta = r.metadata;
|
|
493
|
-
const project = r.project || '';
|
|
494
|
-
const time = relativeTime(r.timestamp || r.date);
|
|
495
|
-
const roleIcon = role === 'user' ? 'person' : role === 'assistant' ? 'smart_toy' : 'chat';
|
|
496
|
-
|
|
497
|
-
return `<div class="result-item" data-session-id="${esc(sessionId)}" tabindex="0" role="button">
|
|
498
|
-
<div class="result-meta">
|
|
499
|
-
<span class="role-badge" data-role="${esc(role)}"><span class="material-symbols-outlined" style="font-size:12px">${roleIcon}</span> ${esc(role)}</span>
|
|
500
|
-
${project ? `<span class="result-project">${esc(project)}</span>` : ''}
|
|
501
|
-
${time ? `<span class="result-date">${time}</span>` : ''}
|
|
502
|
-
${score != null ? `<span class="score-container">${formatScore(score, meta)}</span>` : ''}
|
|
503
|
-
</div>
|
|
504
|
-
<div class="result-excerpt">${highlightExcerpt(excerpt, query)}</div>
|
|
505
|
-
</div>`;
|
|
506
|
-
})
|
|
507
|
-
.join('');
|
|
508
|
-
|
|
509
|
-
el.searchResults.querySelectorAll('.result-item').forEach((card) => {
|
|
510
|
-
card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
|
|
511
|
-
card.addEventListener('keydown', (e) => {
|
|
512
|
-
if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// ── Sessions ───────────────────────────────────────────────────────────────
|
|
518
|
-
|
|
519
|
-
async function loadSessions() {
|
|
520
|
-
if (state.sessions.loading) return;
|
|
521
|
-
state.sessions.loading = true;
|
|
522
|
-
try {
|
|
523
|
-
const data = await api('/sessions');
|
|
524
|
-
const list = Array.isArray(data) ? data : data.sessions || [];
|
|
525
|
-
state.sessions.list = list;
|
|
526
|
-
state.stats.sessionCount = list.length;
|
|
527
|
-
updateStats();
|
|
528
|
-
populateProjectFilter(list);
|
|
529
|
-
renderSessions();
|
|
530
|
-
} catch (err) {
|
|
531
|
-
toast(`Failed to load sessions: ${err.message}`, 'error');
|
|
532
|
-
}
|
|
533
|
-
state.sessions.loading = false;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function populateProjectFilter(sessions) {
|
|
537
|
-
const projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))].sort();
|
|
538
|
-
const sel = el.sessionProjectFilter;
|
|
539
|
-
const current = sel.value;
|
|
540
|
-
sel.innerHTML =
|
|
541
|
-
'<option value="">All projects</option>' +
|
|
542
|
-
projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
|
|
543
|
-
sel.value = current;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function renderSessions() {
|
|
547
|
-
const filter = state.sessions.projectFilter;
|
|
548
|
-
const list = filter
|
|
549
|
-
? state.sessions.list.filter((s) => s.project === filter)
|
|
550
|
-
: state.sessions.list;
|
|
551
|
-
|
|
552
|
-
if (list.length === 0) {
|
|
553
|
-
el.sessionsList.innerHTML = '';
|
|
554
|
-
el.sessionsEmpty.classList.remove('hidden');
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
el.sessionsEmpty.classList.add('hidden');
|
|
559
|
-
el.sessionsList.innerHTML = list
|
|
560
|
-
.map((s) => {
|
|
561
|
-
const id = s.sessionId || s.id || '';
|
|
562
|
-
const project = s.project || '';
|
|
563
|
-
const branch = s.branch || s.gitBranch || s.git_branch || '';
|
|
564
|
-
const count = s.messageCount || s.message_count || s.count || 0;
|
|
565
|
-
const date = s.startTime || s.date || s.created || s.startedAt || '';
|
|
566
|
-
const preview = s.preview || '';
|
|
567
|
-
const time = relativeTime(date);
|
|
568
|
-
const dateStr = date
|
|
569
|
-
? new Date(date).toLocaleDateString(undefined, {
|
|
570
|
-
month: 'short',
|
|
571
|
-
day: 'numeric',
|
|
572
|
-
year: 'numeric',
|
|
573
|
-
})
|
|
574
|
-
: '';
|
|
575
|
-
|
|
576
|
-
return `<div class="session-card" data-session-id="${esc(id)}" tabindex="0" role="button">
|
|
577
|
-
<div class="session-header">
|
|
578
|
-
<span class="session-project">${esc(project)}</span>
|
|
579
|
-
<span class="session-date">${dateStr || time || ''}</span>
|
|
580
|
-
</div>
|
|
581
|
-
<div class="session-meta">
|
|
582
|
-
${branch ? `<span class="session-meta-item"><span class="material-symbols-outlined">alt_route</span>${esc(branch)}</span>` : ''}
|
|
583
|
-
<span class="session-meta-item"><span class="material-symbols-outlined">chat</span>${count} messages</span>
|
|
584
|
-
<span class="session-meta-item"><span class="material-symbols-outlined">tag</span>${esc(id.slice(0, 8))}</span>
|
|
585
|
-
</div>
|
|
586
|
-
${preview ? `<div class="session-preview">${esc(preview)}</div>` : ''}
|
|
587
|
-
</div>`;
|
|
588
|
-
})
|
|
589
|
-
.join('');
|
|
590
|
-
|
|
591
|
-
el.sessionsList.querySelectorAll('.session-card').forEach((card) => {
|
|
592
|
-
card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
|
|
593
|
-
card.addEventListener('keydown', (e) => {
|
|
594
|
-
if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async function openSessionPanel(sessionId) {
|
|
600
|
-
if (!sessionId) return;
|
|
601
|
-
try {
|
|
602
|
-
const [session, summary] = await Promise.allSettled([
|
|
603
|
-
api(`/sessions/${encodeURIComponent(sessionId)}`),
|
|
604
|
-
api(`/sessions/${encodeURIComponent(sessionId)}/summary`),
|
|
605
|
-
]);
|
|
606
|
-
const sData = session.status === 'fulfilled' ? session.value : {};
|
|
607
|
-
const sumData = summary.status === 'fulfilled' ? summary.value : null;
|
|
608
|
-
openPanel('session', { session: sData, summary: sumData, sessionId });
|
|
609
|
-
} catch (err) {
|
|
610
|
-
toast(`Failed to load session: ${err.message}`, 'error');
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ── Embeddings ─────────────────────────────────────────────────────────────
|
|
615
|
-
|
|
616
|
-
async function loadEmbeddingStats() {
|
|
617
|
-
try {
|
|
618
|
-
const data = await api('/index-status');
|
|
619
|
-
state.embeddings.stats = data;
|
|
620
|
-
renderEmbeddingStats();
|
|
621
|
-
el.statVectors.querySelector('.stat-value').textContent = data.totalEntries || 0;
|
|
622
|
-
} catch {
|
|
623
|
-
state.embeddings.stats = null;
|
|
624
|
-
renderEmbeddingStats();
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function renderEmbeddingStats() {
|
|
629
|
-
const stats = state.embeddings.stats;
|
|
630
|
-
if (!stats || !stats.totalEntries) {
|
|
631
|
-
el.embeddingsStatsGrid.innerHTML = '';
|
|
632
|
-
el.embeddingsStatus.style.display = 'none';
|
|
633
|
-
el.embeddingsEmpty.classList.remove('hidden');
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
el.embeddingsEmpty.classList.add('hidden');
|
|
638
|
-
el.embeddingsStatus.style.display = '';
|
|
639
|
-
|
|
640
|
-
const cards = [
|
|
641
|
-
{ label: 'Provider', value: stats.provider || 'Not configured', detail: '' },
|
|
642
|
-
{ label: 'Dimensions', value: stats.dimensions || 0, detail: 'vector size' },
|
|
643
|
-
{ label: 'Total Entries', value: stats.totalEntries || 0, detail: 'indexed chunks' },
|
|
644
|
-
{ label: 'Knowledge', value: stats.knowledgeEntries || 0, detail: 'knowledge vectors' },
|
|
645
|
-
{ label: 'Sessions', value: stats.sessionEntries || 0, detail: 'session vectors' },
|
|
646
|
-
{ label: 'DB Size', value: (stats.dbSizeMB || 0).toFixed(1) + ' MB', detail: 'on disk' },
|
|
647
|
-
];
|
|
648
|
-
|
|
649
|
-
el.embeddingsStatsGrid.innerHTML = cards
|
|
650
|
-
.map(
|
|
651
|
-
(c) => `
|
|
652
|
-
<div class="embedding-stat-card">
|
|
653
|
-
<span class="stat-label">${esc(c.label)}</span>
|
|
654
|
-
<span class="stat-number">${esc(String(c.value))}</span>
|
|
655
|
-
${c.detail ? `<span class="stat-detail">${esc(c.detail)}</span>` : ''}
|
|
656
|
-
</div>
|
|
657
|
-
`,
|
|
658
|
-
)
|
|
659
|
-
.join('');
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// ── Side Panel ─────────────────────────────────────────────────────────────
|
|
663
|
-
|
|
664
|
-
function openPanel(type, data) {
|
|
665
|
-
state.panel = { open: true, type, data };
|
|
666
|
-
el.sidePanel.hidden = false;
|
|
667
|
-
requestAnimationFrame(() => el.sidePanel.classList.add('open'));
|
|
668
|
-
el.contentWrapper.classList.add('panel-visible');
|
|
669
|
-
|
|
670
|
-
if (type === 'knowledge') {
|
|
671
|
-
renderKnowledgePanel(data);
|
|
672
|
-
} else if (type === 'session') {
|
|
673
|
-
renderSessionPanel(data);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
function closePanel() {
|
|
678
|
-
state.panel = { open: false, type: null, data: null };
|
|
679
|
-
el.sidePanel.classList.remove('open');
|
|
680
|
-
el.contentWrapper.classList.remove('panel-visible');
|
|
681
|
-
el.sidePanel.addEventListener(
|
|
682
|
-
'transitionend',
|
|
683
|
-
function handler() {
|
|
684
|
-
if (!state.panel.open) el.sidePanel.hidden = true;
|
|
685
|
-
el.sidePanel.removeEventListener('transitionend', handler);
|
|
686
|
-
},
|
|
687
|
-
{ once: true },
|
|
688
|
-
);
|
|
689
|
-
// Fallback: hide after 400ms if transitionend doesn't fire
|
|
690
|
-
setTimeout(() => {
|
|
691
|
-
if (!state.panel.open) el.sidePanel.hidden = true;
|
|
692
|
-
}, 400);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function stripFrontmatter(content) {
|
|
696
|
-
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
|
|
697
|
-
return match ? match[1].trim() : content;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function renderKnowledgePanel(data) {
|
|
701
|
-
el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">article</span>${esc(data.title)}`;
|
|
702
|
-
const meta = data.meta || {};
|
|
703
|
-
const entry = meta.entry || meta;
|
|
704
|
-
const category = entry.category || meta.category || '';
|
|
705
|
-
const tags = entry.tags || meta.tags || [];
|
|
706
|
-
const updated = entry.updated || meta.updated || '';
|
|
707
|
-
|
|
708
|
-
let metaHtml = '<div class="panel-meta">';
|
|
709
|
-
if (category)
|
|
710
|
-
metaHtml += `<span class="card-category" data-cat="${esc(category)}">${esc(category)}</span>`;
|
|
711
|
-
if (tags.length)
|
|
712
|
-
metaHtml += tags.map((t) => `<span class="card-tag">${esc(t)}</span>`).join('');
|
|
713
|
-
if (updated) metaHtml += `<span class="panel-meta-date">${esc(updated)}</span>`;
|
|
714
|
-
metaHtml += '</div>';
|
|
715
|
-
|
|
716
|
-
const body = stripFrontmatter(data.content);
|
|
717
|
-
el.panelBody.innerHTML = metaHtml + `<div class="prose">${renderMd(body)}</div>`;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function renderSessionPanel(data) {
|
|
721
|
-
const s = data.session || {};
|
|
722
|
-
const summary = data.summary;
|
|
723
|
-
const id = data.sessionId || s.id || '';
|
|
724
|
-
const meta = s.meta || s;
|
|
725
|
-
|
|
726
|
-
el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">terminal</span>Session ${esc(id.slice(0, 8))}`;
|
|
727
|
-
|
|
728
|
-
let html = '<div class="panel-meta">';
|
|
729
|
-
if (meta.cwd)
|
|
730
|
-
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">folder</span>${esc(meta.cwd)}</span>`;
|
|
731
|
-
if (meta.branch)
|
|
732
|
-
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">alt_route</span>${esc(meta.branch)}</span>`;
|
|
733
|
-
if (meta.startTime && meta.startTime !== 'unknown')
|
|
734
|
-
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">schedule</span>${new Date(meta.startTime).toLocaleString()}</span>`;
|
|
735
|
-
if (meta.messageCount)
|
|
736
|
-
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">chat</span>${meta.messageCount} messages</span>`;
|
|
737
|
-
html += '</div>';
|
|
738
|
-
|
|
739
|
-
if (summary && summary.topics && summary.topics.length) {
|
|
740
|
-
html +=
|
|
741
|
-
'<div class="panel-section"><h4 class="panel-section-title">Topics</h4><ul class="panel-topics">';
|
|
742
|
-
summary.topics.slice(0, 8).forEach((t) => {
|
|
743
|
-
html += `<li>${esc(t.content)}</li>`;
|
|
744
|
-
});
|
|
745
|
-
html += '</ul></div>';
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const messages = s.messages || s.conversation || [];
|
|
749
|
-
// Filter out noisy messages (tool JSON, base64, system reminders)
|
|
750
|
-
const cleanMessages = messages.filter((m) => {
|
|
751
|
-
const t = (m.content || '').trimStart();
|
|
752
|
-
if (t.startsWith('[{') || t.startsWith('{"')) return false;
|
|
753
|
-
if (t.includes('tool_use_id') || t.includes('tool_result')) return false;
|
|
754
|
-
if (t.includes('base64') || t.includes('media_type')) return false;
|
|
755
|
-
if (t.includes('<system-reminder>')) return false;
|
|
756
|
-
if (t.length < 3) return false;
|
|
757
|
-
return true;
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
if (cleanMessages.length) {
|
|
761
|
-
html += '<div class="chat-messages">';
|
|
762
|
-
cleanMessages.forEach((m) => {
|
|
763
|
-
const role = m.role || 'unknown';
|
|
764
|
-
const text = m.content || m.text || '';
|
|
765
|
-
const isUser = role === 'user';
|
|
766
|
-
const cls = isUser ? 'chat-msg user' : 'chat-msg assistant';
|
|
767
|
-
const roleIcon = isUser ? 'person' : 'smart_toy';
|
|
768
|
-
const truncated = text.length > 1500 ? text.slice(0, 1500) + '...' : text;
|
|
769
|
-
const time = m.timestamp ? relativeTime(m.timestamp) : '';
|
|
770
|
-
|
|
771
|
-
html += `<div class="${cls}">
|
|
772
|
-
<div class="chat-msg-role"><span class="material-symbols-outlined" style="font-size:14px">${roleIcon}</span> ${esc(role)} ${time ? `<span class="chat-msg-time">${time}</span>` : ''}</div>
|
|
773
|
-
<div>${isUser ? esc(truncated) : renderMd(truncated)}</div>
|
|
774
|
-
</div>`;
|
|
775
|
-
});
|
|
776
|
-
html += '</div>';
|
|
777
|
-
} else {
|
|
778
|
-
html +=
|
|
779
|
-
'<div class="empty-state"><span class="material-symbols-outlined empty-icon">chat_bubble</span><div class="empty-text">No messages available</div></div>';
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
el.panelBody.innerHTML = html;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// ── Event Binding ──────────────────────────────────────────────────────────
|
|
786
|
-
|
|
787
|
-
function bindEvents() {
|
|
788
|
-
// Tabs
|
|
789
|
-
Object.keys(el.tabs).forEach((k) => {
|
|
790
|
-
el.tabs[k].addEventListener('click', () => switchTab(k));
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
// Theme
|
|
794
|
-
el.themeToggle.addEventListener('click', toggleTheme);
|
|
795
|
-
|
|
796
|
-
// Panel close
|
|
797
|
-
el.panelClose.addEventListener('click', closePanel);
|
|
798
|
-
|
|
799
|
-
// Knowledge category chips
|
|
800
|
-
el.knowledgeCategories.addEventListener('click', (e) => {
|
|
801
|
-
const chip = e.target.closest('.chip');
|
|
802
|
-
if (!chip) return;
|
|
803
|
-
el.knowledgeCategories.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
804
|
-
chip.classList.add('active');
|
|
805
|
-
state.knowledge.activeCategory = chip.dataset.category;
|
|
806
|
-
renderKnowledge();
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
// Search input
|
|
810
|
-
el.searchInput.addEventListener('input', () => {
|
|
811
|
-
state.search.query = el.searchInput.value;
|
|
812
|
-
doSearch();
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// Search role filters
|
|
816
|
-
el.searchRoleFilters.addEventListener('click', (e) => {
|
|
817
|
-
const chip = e.target.closest('.chip');
|
|
818
|
-
if (!chip) return;
|
|
819
|
-
el.searchRoleFilters.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
820
|
-
chip.classList.add('active');
|
|
821
|
-
state.search.role = chip.dataset.role;
|
|
822
|
-
if (state.search.query.trim()) doSearch();
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
function setSearchMode(mode) {
|
|
826
|
-
el.modeRanked.classList.toggle('active', mode === 'ranked');
|
|
827
|
-
el.modeSemantic.classList.toggle('active', mode === 'semantic');
|
|
828
|
-
el.modeRegex.classList.toggle('active', mode === 'regex');
|
|
829
|
-
state.search.ranked = mode !== 'regex';
|
|
830
|
-
state.search.semantic = mode === 'semantic';
|
|
831
|
-
if (state.search.query.trim()) doSearch();
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
el.modeRanked.addEventListener('click', () => setSearchMode('ranked'));
|
|
835
|
-
el.modeSemantic.addEventListener('click', () => setSearchMode('semantic'));
|
|
836
|
-
el.modeRegex.addEventListener('click', () => setSearchMode('regex'));
|
|
837
|
-
|
|
838
|
-
// Session project filter
|
|
839
|
-
el.sessionProjectFilter.addEventListener('change', () => {
|
|
840
|
-
state.sessions.projectFilter = el.sessionProjectFilter.value;
|
|
841
|
-
renderSessions();
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
// Search scope chips
|
|
845
|
-
el.searchScopes.addEventListener('click', (e) => {
|
|
846
|
-
const chip = e.target.closest('.chip');
|
|
847
|
-
if (!chip) return;
|
|
848
|
-
el.searchScopes.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
849
|
-
chip.classList.add('active');
|
|
850
|
-
state.search.scope = chip.dataset.scope;
|
|
851
|
-
if (state.search.query.trim()) doSearch();
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
// Keyboard shortcuts
|
|
855
|
-
document.addEventListener('keydown', (e) => {
|
|
856
|
-
// Escape closes panel
|
|
857
|
-
if (e.key === 'Escape' && state.panel.open) {
|
|
858
|
-
e.preventDefault();
|
|
859
|
-
closePanel();
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// / or Ctrl+K focuses search
|
|
864
|
-
if ((e.key === '/' || (e.ctrlKey && e.key === 'k')) && !isInputFocused()) {
|
|
865
|
-
e.preventDefault();
|
|
866
|
-
switchTab('search');
|
|
867
|
-
el.searchInput.focus();
|
|
868
|
-
}
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
// Click outside panel to close
|
|
872
|
-
el.contentWrapper.addEventListener('click', (e) => {
|
|
873
|
-
if (state.panel.open && !el.sidePanel.contains(e.target)) {
|
|
874
|
-
// Only close if clicking on the main content area backdrop
|
|
875
|
-
}
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function isInputFocused() {
|
|
880
|
-
const active = document.activeElement;
|
|
881
|
-
if (!active) return false;
|
|
882
|
-
const tag = active.tagName.toLowerCase();
|
|
883
|
-
return tag === 'input' || tag === 'textarea' || tag === 'select' || active.isContentEditable;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// ── Init ───────────────────────────────────────────────────────────────────
|
|
887
|
-
|
|
888
|
-
async function init() {
|
|
889
|
-
initTheme();
|
|
890
|
-
bindEvents();
|
|
891
|
-
wsConnect();
|
|
892
|
-
|
|
893
|
-
// Load initial data in parallel
|
|
894
|
-
try {
|
|
895
|
-
await Promise.allSettled([loadKnowledge(), loadSessions(), loadEmbeddingStats()]);
|
|
896
|
-
} catch {
|
|
897
|
-
// individual loaders handle their own errors
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Hide loading overlay after a short delay if ws hasn't connected
|
|
901
|
-
setTimeout(() => {
|
|
902
|
-
el.loadingOverlay.classList.add('hidden');
|
|
903
|
-
}, 5000);
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// Panel resize handle (drag to resize like agent-tasks)
|
|
907
|
-
function initPanelResize() {
|
|
908
|
-
const panel = document.getElementById('side-panel');
|
|
909
|
-
if (!panel) return;
|
|
910
|
-
const handle = document.createElement('div');
|
|
911
|
-
handle.className = 'panel-resize-handle';
|
|
912
|
-
panel.appendChild(handle);
|
|
913
|
-
|
|
914
|
-
let isResizing = false;
|
|
915
|
-
let startX = 0;
|
|
916
|
-
let startWidth = 0;
|
|
917
|
-
|
|
918
|
-
handle.addEventListener('mousedown', (e) => {
|
|
919
|
-
isResizing = true;
|
|
920
|
-
startX = e.clientX;
|
|
921
|
-
startWidth = panel.offsetWidth;
|
|
922
|
-
document.body.style.cursor = 'col-resize';
|
|
923
|
-
document.body.style.userSelect = 'none';
|
|
924
|
-
e.preventDefault();
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
document.addEventListener('mousemove', (e) => {
|
|
928
|
-
if (!isResizing) return;
|
|
929
|
-
const dx = startX - e.clientX;
|
|
930
|
-
const newWidth = Math.max(320, Math.min(startWidth + dx, window.innerWidth * 0.8));
|
|
931
|
-
panel.style.width = newWidth + 'px';
|
|
932
|
-
panel.style.minWidth = newWidth + 'px';
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
document.addEventListener('mouseup', () => {
|
|
936
|
-
if (!isResizing) return;
|
|
937
|
-
isResizing = false;
|
|
938
|
-
document.body.style.cursor = '';
|
|
939
|
-
document.body.style.userSelect = '';
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// Start
|
|
944
|
-
if (document.readyState === 'loading') {
|
|
945
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
946
|
-
init();
|
|
947
|
-
initPanelResize();
|
|
948
|
-
});
|
|
949
|
-
} else {
|
|
950
|
-
init();
|
|
951
|
-
initPanelResize();
|
|
952
|
-
}
|
|
953
|
-
})();
|
|
1
|
+
/* agent-knowledge dashboard — vanilla JS SPA */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
6
|
+
const state = {
|
|
7
|
+
activeTab: 'knowledge',
|
|
8
|
+
knowledge: { entries: [], activeCategory: 'all' },
|
|
9
|
+
search: {
|
|
10
|
+
query: '',
|
|
11
|
+
results: [],
|
|
12
|
+
role: 'all',
|
|
13
|
+
scope: 'all',
|
|
14
|
+
ranked: true,
|
|
15
|
+
semantic: false,
|
|
16
|
+
loading: false,
|
|
17
|
+
},
|
|
18
|
+
sessions: { list: [], projectFilter: '', loading: false },
|
|
19
|
+
embeddings: { stats: null, loading: false },
|
|
20
|
+
panel: { open: false, type: null, data: null },
|
|
21
|
+
stats: { knowledgeCount: 0, sessionCount: 0 },
|
|
22
|
+
connected: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── DOM refs ───────────────────────────────────────────────────────────────
|
|
26
|
+
const $ = (id) => document.getElementById(id);
|
|
27
|
+
const el = {
|
|
28
|
+
tabs: {
|
|
29
|
+
knowledge: $('tab-knowledge'),
|
|
30
|
+
search: $('tab-search'),
|
|
31
|
+
sessions: $('tab-sessions'),
|
|
32
|
+
embeddings: $('tab-embeddings'),
|
|
33
|
+
},
|
|
34
|
+
views: {
|
|
35
|
+
knowledge: $('view-knowledge'),
|
|
36
|
+
search: $('view-search'),
|
|
37
|
+
sessions: $('view-sessions'),
|
|
38
|
+
embeddings: $('view-embeddings'),
|
|
39
|
+
},
|
|
40
|
+
knowledgeGrid: $('knowledge-grid'),
|
|
41
|
+
knowledgeEmpty: $('knowledge-empty'),
|
|
42
|
+
knowledgeCategories: $('knowledge-categories'),
|
|
43
|
+
searchInput: $('search-input'),
|
|
44
|
+
searchResults: $('search-results'),
|
|
45
|
+
searchEmpty: $('search-empty'),
|
|
46
|
+
searchRoleFilters: $('search-role-filters'),
|
|
47
|
+
modeRanked: $('mode-ranked'),
|
|
48
|
+
modeSemantic: $('mode-semantic'),
|
|
49
|
+
modeRegex: $('mode-regex'),
|
|
50
|
+
sessionsList: $('sessions-list'),
|
|
51
|
+
sessionsEmpty: $('sessions-empty'),
|
|
52
|
+
sessionProjectFilter: $('session-project-filter'),
|
|
53
|
+
searchScopes: $('search-scopes'),
|
|
54
|
+
sidePanel: $('side-panel'),
|
|
55
|
+
panelTitle: $('panel-title'),
|
|
56
|
+
panelBody: $('panel-body'),
|
|
57
|
+
panelClose: $('panel-close'),
|
|
58
|
+
connectionStatus: $('connection-status'),
|
|
59
|
+
statKnowledge: $('stat-knowledge'),
|
|
60
|
+
statSessions: $('stat-sessions'),
|
|
61
|
+
statVectors: $('stat-vectors'),
|
|
62
|
+
embeddingsStatsGrid: $('embedding-stats-grid'),
|
|
63
|
+
embeddingsEmpty: $('embeddings-empty'),
|
|
64
|
+
embeddingsStatus: $('embeddings-status'),
|
|
65
|
+
themeToggle: $('theme-toggle'),
|
|
66
|
+
version: $('version'),
|
|
67
|
+
loadingOverlay: $('loading-overlay'),
|
|
68
|
+
toastContainer: $('toast-container'),
|
|
69
|
+
contentWrapper: $('content-wrapper'),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Utilities ──────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function esc(str) {
|
|
75
|
+
const d = document.createElement('div');
|
|
76
|
+
d.textContent = str;
|
|
77
|
+
return d.innerHTML;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function debounce(fn, ms) {
|
|
81
|
+
let t;
|
|
82
|
+
return (...args) => {
|
|
83
|
+
clearTimeout(t);
|
|
84
|
+
t = setTimeout(() => fn(...args), ms);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function relativeTime(dateStr) {
|
|
89
|
+
if (!dateStr) return '';
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const then = new Date(dateStr).getTime();
|
|
92
|
+
const diff = now - then;
|
|
93
|
+
if (diff < 0) return 'just now';
|
|
94
|
+
const secs = Math.floor(diff / 1000);
|
|
95
|
+
if (secs < 60) return 'just now';
|
|
96
|
+
const mins = Math.floor(secs / 60);
|
|
97
|
+
if (mins < 60) return `${mins}m ago`;
|
|
98
|
+
const hrs = Math.floor(mins / 60);
|
|
99
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
100
|
+
const days = Math.floor(hrs / 24);
|
|
101
|
+
if (days < 30) return `${days}d ago`;
|
|
102
|
+
const months = Math.floor(days / 30);
|
|
103
|
+
if (months < 12) return `${months}mo ago`;
|
|
104
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderMd(raw) {
|
|
108
|
+
if (!raw) return '';
|
|
109
|
+
try {
|
|
110
|
+
const html = marked.parse(raw, { breaks: true, gfm: true });
|
|
111
|
+
const clean = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'] });
|
|
112
|
+
const wrapper = document.createElement('div');
|
|
113
|
+
wrapper.innerHTML = clean;
|
|
114
|
+
wrapper.querySelectorAll('pre code').forEach((block) => {
|
|
115
|
+
try {
|
|
116
|
+
hljs.highlightElement(block);
|
|
117
|
+
} catch (_) {
|
|
118
|
+
/* noop */
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return wrapper.innerHTML;
|
|
122
|
+
} catch {
|
|
123
|
+
return esc(raw);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function highlightExcerpt(text, query) {
|
|
128
|
+
if (!text || !query) return esc(text || '');
|
|
129
|
+
try {
|
|
130
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
131
|
+
const re = new RegExp(`(${escaped})`, 'gi');
|
|
132
|
+
return esc(text).replace(re, '<mark>$1</mark>');
|
|
133
|
+
} catch {
|
|
134
|
+
return esc(text);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatScore(score, metadata) {
|
|
139
|
+
if (score == null) return '';
|
|
140
|
+
const pct = Math.min(Math.max(score * 100, 0), 100);
|
|
141
|
+
const tfidf = metadata?.tfidfScore;
|
|
142
|
+
const semantic = metadata?.semanticScore;
|
|
143
|
+
const recency = metadata?.recencyMultiplier;
|
|
144
|
+
const alpha = metadata?.blendAlpha;
|
|
145
|
+
|
|
146
|
+
let tooltip = `Score: ${score.toFixed(3)}`;
|
|
147
|
+
if (tfidf != null && semantic != null) {
|
|
148
|
+
tooltip += ` (TF-IDF: ${tfidf.toFixed(2)} \u00d7 ${((alpha || 0.3) * 100).toFixed(0)}% + Semantic: ${semantic.toFixed(2)} \u00d7 ${((1 - (alpha || 0.3)) * 100).toFixed(0)}%)`;
|
|
149
|
+
}
|
|
150
|
+
if (recency != null) tooltip += ` \u00d7 recency ${(recency * 100).toFixed(0)}%`;
|
|
151
|
+
|
|
152
|
+
const recencyTag =
|
|
153
|
+
recency != null && recency < 0.95
|
|
154
|
+
? `<span class="recency-tag" title="Recency: ${(recency * 100).toFixed(0)}%">${(recency * 100).toFixed(0)}%</span>`
|
|
155
|
+
: '';
|
|
156
|
+
const scoreType =
|
|
157
|
+
tfidf != null && semantic != null
|
|
158
|
+
? '<span class="score-type hybrid">hybrid</span>'
|
|
159
|
+
: tfidf != null
|
|
160
|
+
? '<span class="score-type tfidf">tf-idf</span>'
|
|
161
|
+
: '';
|
|
162
|
+
return `<div class="score-bar" title="${tooltip}">
|
|
163
|
+
<div class="score-fill" style="width:${pct}%"></div>
|
|
164
|
+
<span class="score-label">${score.toFixed(2)}${recencyTag}${scoreType}</span>
|
|
165
|
+
</div>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toast(msg, type = 'info', duration = 4000) {
|
|
169
|
+
const t = document.createElement('div');
|
|
170
|
+
t.className = `toast toast-${type}`;
|
|
171
|
+
const icons = { info: 'info', success: 'check_circle', error: 'error', warning: 'warning' };
|
|
172
|
+
t.innerHTML = `<span class="material-symbols-outlined toast-icon">${icons[type] || 'info'}</span>
|
|
173
|
+
<span class="toast-msg">${esc(msg)}</span>`;
|
|
174
|
+
el.toastContainer.appendChild(t);
|
|
175
|
+
requestAnimationFrame(() => t.classList.add('show'));
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
t.classList.remove('show');
|
|
178
|
+
t.addEventListener('transitionend', () => t.remove());
|
|
179
|
+
}, duration);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── API ────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function api(path) {
|
|
185
|
+
const res = await fetch(`/api${path}`);
|
|
186
|
+
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
187
|
+
return res.json();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
let ws = null;
|
|
193
|
+
let wsRetry = null;
|
|
194
|
+
|
|
195
|
+
function wsConnect() {
|
|
196
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
197
|
+
ws = new WebSocket(`${proto}://${location.host}`);
|
|
198
|
+
|
|
199
|
+
ws.addEventListener('open', () => {
|
|
200
|
+
state.connected = true;
|
|
201
|
+
updateConnectionStatus();
|
|
202
|
+
el.loadingOverlay.classList.add('hidden');
|
|
203
|
+
if (wsRetry) {
|
|
204
|
+
clearTimeout(wsRetry);
|
|
205
|
+
wsRetry = null;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
ws.addEventListener('message', (evt) => {
|
|
210
|
+
try {
|
|
211
|
+
const msg = JSON.parse(evt.data);
|
|
212
|
+
handleWsMessage(msg);
|
|
213
|
+
} catch {
|
|
214
|
+
/* ignore non-json */
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
ws.addEventListener('close', () => {
|
|
219
|
+
state.connected = false;
|
|
220
|
+
updateConnectionStatus();
|
|
221
|
+
scheduleReconnect();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
ws.addEventListener('error', () => {
|
|
225
|
+
state.connected = false;
|
|
226
|
+
updateConnectionStatus();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function scheduleReconnect() {
|
|
231
|
+
if (wsRetry) return;
|
|
232
|
+
wsRetry = setTimeout(() => {
|
|
233
|
+
wsRetry = null;
|
|
234
|
+
wsConnect();
|
|
235
|
+
}, 3000);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleWsMessage(msg) {
|
|
239
|
+
switch (msg.type) {
|
|
240
|
+
case 'reload':
|
|
241
|
+
location.reload();
|
|
242
|
+
return;
|
|
243
|
+
case 'state':
|
|
244
|
+
if (msg.knowledge) {
|
|
245
|
+
state.knowledge.entries = msg.knowledge;
|
|
246
|
+
renderKnowledge();
|
|
247
|
+
}
|
|
248
|
+
if (msg.sessions) {
|
|
249
|
+
state.sessions.list = msg.sessions;
|
|
250
|
+
renderSessions();
|
|
251
|
+
}
|
|
252
|
+
if (msg.stats) {
|
|
253
|
+
state.stats.knowledgeCount = msg.stats.knowledge_entries || 0;
|
|
254
|
+
state.stats.sessionCount = msg.stats.session_count || 0;
|
|
255
|
+
if (msg.stats.version) el.version.textContent = 'v' + msg.stats.version;
|
|
256
|
+
updateStats();
|
|
257
|
+
}
|
|
258
|
+
el.loadingOverlay.classList.add('hidden');
|
|
259
|
+
break;
|
|
260
|
+
case 'knowledge:update':
|
|
261
|
+
case 'knowledge:change':
|
|
262
|
+
loadKnowledge();
|
|
263
|
+
break;
|
|
264
|
+
case 'session:update':
|
|
265
|
+
case 'session:new':
|
|
266
|
+
loadSessions();
|
|
267
|
+
break;
|
|
268
|
+
case 'stats':
|
|
269
|
+
if (msg.data) {
|
|
270
|
+
if (msg.data.knowledgeCount != null) state.stats.knowledgeCount = msg.data.knowledgeCount;
|
|
271
|
+
if (msg.data.sessionCount != null) state.stats.sessionCount = msg.data.sessionCount;
|
|
272
|
+
updateStats();
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
case 'version':
|
|
276
|
+
if (msg.data) el.version.textContent = msg.data;
|
|
277
|
+
break;
|
|
278
|
+
default:
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function updateConnectionStatus() {
|
|
284
|
+
const s = el.connectionStatus;
|
|
285
|
+
if (state.connected) {
|
|
286
|
+
s.className = 'status-badge connected';
|
|
287
|
+
s.textContent = 'Connected';
|
|
288
|
+
} else {
|
|
289
|
+
s.className = 'status-badge disconnected';
|
|
290
|
+
s.textContent = 'Disconnected';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Stats ──────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function updateStats() {
|
|
297
|
+
el.statKnowledge.querySelector('.stat-value').textContent = state.stats.knowledgeCount;
|
|
298
|
+
el.statSessions.querySelector('.stat-value').textContent = state.stats.sessionCount;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Theme ──────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
function initTheme() {
|
|
304
|
+
const saved = localStorage.getItem('agent-knowledge-theme');
|
|
305
|
+
const theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
306
|
+
applyTheme(theme);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function applyTheme(theme) {
|
|
310
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
311
|
+
localStorage.setItem('agent-knowledge-theme', theme);
|
|
312
|
+
const icon = el.themeToggle.querySelector('.theme-icon');
|
|
313
|
+
if (icon) icon.textContent = theme === 'dark' ? 'light_mode' : 'dark_mode';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toggleTheme() {
|
|
317
|
+
const cur = document.documentElement.getAttribute('data-theme') || 'light';
|
|
318
|
+
applyTheme(cur === 'dark' ? 'light' : 'dark');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Tabs ───────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function switchTab(name) {
|
|
324
|
+
if (state.activeTab === name) return;
|
|
325
|
+
state.activeTab = name;
|
|
326
|
+
|
|
327
|
+
Object.keys(el.tabs).forEach((k) => {
|
|
328
|
+
const active = k === name;
|
|
329
|
+
el.tabs[k].classList.toggle('active', active);
|
|
330
|
+
el.tabs[k].setAttribute('aria-selected', active);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
Object.keys(el.views).forEach((k) => {
|
|
334
|
+
const active = k === name;
|
|
335
|
+
el.views[k].classList.toggle('active', active);
|
|
336
|
+
el.views[k].hidden = !active;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Close side panel on tab switch
|
|
340
|
+
if (state.panel.open) closePanel();
|
|
341
|
+
|
|
342
|
+
if (name === 'knowledge' && state.knowledge.entries.length === 0) loadKnowledge();
|
|
343
|
+
if (name === 'sessions' && state.sessions.list.length === 0) loadSessions();
|
|
344
|
+
if (name === 'embeddings') loadEmbeddingStats();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Knowledge ──────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
async function loadKnowledge() {
|
|
350
|
+
try {
|
|
351
|
+
const data = await api('/knowledge');
|
|
352
|
+
const entries = Array.isArray(data) ? data : data.entries || [];
|
|
353
|
+
state.knowledge.entries = entries;
|
|
354
|
+
state.stats.knowledgeCount = entries.length;
|
|
355
|
+
updateStats();
|
|
356
|
+
renderKnowledge();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
toast(`Failed to load knowledge: ${err.message}`, 'error');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderKnowledge() {
|
|
363
|
+
const cat = state.knowledge.activeCategory;
|
|
364
|
+
const filtered =
|
|
365
|
+
cat === 'all'
|
|
366
|
+
? state.knowledge.entries
|
|
367
|
+
: state.knowledge.entries.filter((e) => e.category === cat);
|
|
368
|
+
|
|
369
|
+
if (filtered.length === 0) {
|
|
370
|
+
el.knowledgeGrid.innerHTML = '';
|
|
371
|
+
el.knowledgeEmpty.classList.remove('hidden');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
el.knowledgeEmpty.classList.add('hidden');
|
|
376
|
+
el.knowledgeGrid.innerHTML = filtered
|
|
377
|
+
.map((entry) => {
|
|
378
|
+
const cat = entry.category || 'notes';
|
|
379
|
+
const catIcons = {
|
|
380
|
+
projects: 'code',
|
|
381
|
+
people: 'group',
|
|
382
|
+
decisions: 'gavel',
|
|
383
|
+
workflows: 'account_tree',
|
|
384
|
+
notes: 'sticky_note_2',
|
|
385
|
+
};
|
|
386
|
+
const icon = catIcons[cat] || 'article';
|
|
387
|
+
const title = entry.title || entry.path || entry.id || 'Untitled';
|
|
388
|
+
const preview = entry.preview || entry.excerpt || '';
|
|
389
|
+
const tags = (entry.tags || [])
|
|
390
|
+
.slice(0, 3)
|
|
391
|
+
.map((t) => `<span class="card-tag">${esc(t)}</span>`)
|
|
392
|
+
.join('');
|
|
393
|
+
const time = relativeTime(entry.updated || entry.created);
|
|
394
|
+
|
|
395
|
+
return `<div class="knowledge-card" data-path="${esc(entry.path || entry.id || '')}" tabindex="0" role="button">
|
|
396
|
+
<span class="card-category" data-cat="${esc(cat)}">
|
|
397
|
+
<span class="material-symbols-outlined" style="font-size:14px">${icon}</span>
|
|
398
|
+
${esc(cat)}
|
|
399
|
+
</span>
|
|
400
|
+
${time ? `<span class="card-date">${time}</span>` : ''}
|
|
401
|
+
<div class="card-title">${esc(title)}</div>
|
|
402
|
+
${tags ? `<div class="card-tags">${tags}</div>` : ''}
|
|
403
|
+
</div>`;
|
|
404
|
+
})
|
|
405
|
+
.join('');
|
|
406
|
+
|
|
407
|
+
el.knowledgeGrid.querySelectorAll('.knowledge-card').forEach((card) => {
|
|
408
|
+
card.addEventListener('click', () => openKnowledgePanel(card.dataset.path));
|
|
409
|
+
card.addEventListener('keydown', (e) => {
|
|
410
|
+
if (e.key === 'Enter') openKnowledgePanel(card.dataset.path);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function openKnowledgePanel(path) {
|
|
416
|
+
if (!path) return;
|
|
417
|
+
try {
|
|
418
|
+
const data = await api(`/knowledge/${encodeURIComponent(path)}`);
|
|
419
|
+
const title = data.title || data.path || path;
|
|
420
|
+
const content = data.content || data.body || '';
|
|
421
|
+
openPanel('knowledge', { title, content, meta: data });
|
|
422
|
+
} catch (err) {
|
|
423
|
+
toast(`Failed to load entry: ${err.message}`, 'error');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Search ─────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
const doSearch = debounce(async () => {
|
|
430
|
+
const q = state.search.query.trim();
|
|
431
|
+
if (!q) {
|
|
432
|
+
state.search.results = [];
|
|
433
|
+
renderSearchResults();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
state.search.loading = true;
|
|
437
|
+
renderSearchResults();
|
|
438
|
+
try {
|
|
439
|
+
const params = new URLSearchParams({ q });
|
|
440
|
+
let endpoint;
|
|
441
|
+
if (state.search.scope !== 'all') {
|
|
442
|
+
params.set('scope', state.search.scope);
|
|
443
|
+
params.set('semantic', state.search.semantic);
|
|
444
|
+
endpoint = `/sessions/recall?${params}`;
|
|
445
|
+
} else {
|
|
446
|
+
if (state.search.role !== 'all') params.set('role', state.search.role);
|
|
447
|
+
params.set('ranked', state.search.ranked);
|
|
448
|
+
params.set('semantic', state.search.semantic);
|
|
449
|
+
endpoint = `/sessions/search?${params}`;
|
|
450
|
+
}
|
|
451
|
+
const data = await api(endpoint);
|
|
452
|
+
state.search.results = Array.isArray(data) ? data : data.results || [];
|
|
453
|
+
} catch (err) {
|
|
454
|
+
toast(`Search failed: ${err.message}`, 'error');
|
|
455
|
+
state.search.results = [];
|
|
456
|
+
}
|
|
457
|
+
state.search.loading = false;
|
|
458
|
+
renderSearchResults();
|
|
459
|
+
}, 300);
|
|
460
|
+
|
|
461
|
+
function renderSearchResults() {
|
|
462
|
+
const { results, loading, query } = state.search;
|
|
463
|
+
|
|
464
|
+
if (loading) {
|
|
465
|
+
el.searchResults.innerHTML =
|
|
466
|
+
'<div class="loading-inline"><div class="loading-spinner small"></div><span>Searching...</span></div>';
|
|
467
|
+
el.searchEmpty.classList.add('hidden');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!query.trim()) {
|
|
472
|
+
el.searchResults.innerHTML = '';
|
|
473
|
+
el.searchEmpty.classList.remove('hidden');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (results.length === 0) {
|
|
478
|
+
el.searchResults.innerHTML = '';
|
|
479
|
+
el.searchEmpty.querySelector('.empty-text').textContent = 'No results found';
|
|
480
|
+
el.searchEmpty.querySelector('.empty-hint').textContent = `No matches for "${query}"`;
|
|
481
|
+
el.searchEmpty.classList.remove('hidden');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
el.searchEmpty.classList.add('hidden');
|
|
486
|
+
el.searchResults.innerHTML = results
|
|
487
|
+
.map((r) => {
|
|
488
|
+
const sessionId = r.id || r.sessionId || r.session_id || '';
|
|
489
|
+
const excerpt = r.excerpt || r.text || r.content || '';
|
|
490
|
+
const role = r.role || '';
|
|
491
|
+
const score = r.score;
|
|
492
|
+
const meta = r.metadata;
|
|
493
|
+
const project = r.project || '';
|
|
494
|
+
const time = relativeTime(r.timestamp || r.date);
|
|
495
|
+
const roleIcon = role === 'user' ? 'person' : role === 'assistant' ? 'smart_toy' : 'chat';
|
|
496
|
+
|
|
497
|
+
return `<div class="result-item" data-session-id="${esc(sessionId)}" tabindex="0" role="button">
|
|
498
|
+
<div class="result-meta">
|
|
499
|
+
<span class="role-badge" data-role="${esc(role)}"><span class="material-symbols-outlined" style="font-size:12px">${roleIcon}</span> ${esc(role)}</span>
|
|
500
|
+
${project ? `<span class="result-project">${esc(project)}</span>` : ''}
|
|
501
|
+
${time ? `<span class="result-date">${time}</span>` : ''}
|
|
502
|
+
${score != null ? `<span class="score-container">${formatScore(score, meta)}</span>` : ''}
|
|
503
|
+
</div>
|
|
504
|
+
<div class="result-excerpt">${highlightExcerpt(excerpt, query)}</div>
|
|
505
|
+
</div>`;
|
|
506
|
+
})
|
|
507
|
+
.join('');
|
|
508
|
+
|
|
509
|
+
el.searchResults.querySelectorAll('.result-item').forEach((card) => {
|
|
510
|
+
card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
|
|
511
|
+
card.addEventListener('keydown', (e) => {
|
|
512
|
+
if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── Sessions ───────────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
async function loadSessions() {
|
|
520
|
+
if (state.sessions.loading) return;
|
|
521
|
+
state.sessions.loading = true;
|
|
522
|
+
try {
|
|
523
|
+
const data = await api('/sessions');
|
|
524
|
+
const list = Array.isArray(data) ? data : data.sessions || [];
|
|
525
|
+
state.sessions.list = list;
|
|
526
|
+
state.stats.sessionCount = list.length;
|
|
527
|
+
updateStats();
|
|
528
|
+
populateProjectFilter(list);
|
|
529
|
+
renderSessions();
|
|
530
|
+
} catch (err) {
|
|
531
|
+
toast(`Failed to load sessions: ${err.message}`, 'error');
|
|
532
|
+
}
|
|
533
|
+
state.sessions.loading = false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function populateProjectFilter(sessions) {
|
|
537
|
+
const projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))].sort();
|
|
538
|
+
const sel = el.sessionProjectFilter;
|
|
539
|
+
const current = sel.value;
|
|
540
|
+
sel.innerHTML =
|
|
541
|
+
'<option value="">All projects</option>' +
|
|
542
|
+
projects.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('');
|
|
543
|
+
sel.value = current;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function renderSessions() {
|
|
547
|
+
const filter = state.sessions.projectFilter;
|
|
548
|
+
const list = filter
|
|
549
|
+
? state.sessions.list.filter((s) => s.project === filter)
|
|
550
|
+
: state.sessions.list;
|
|
551
|
+
|
|
552
|
+
if (list.length === 0) {
|
|
553
|
+
el.sessionsList.innerHTML = '';
|
|
554
|
+
el.sessionsEmpty.classList.remove('hidden');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
el.sessionsEmpty.classList.add('hidden');
|
|
559
|
+
el.sessionsList.innerHTML = list
|
|
560
|
+
.map((s) => {
|
|
561
|
+
const id = s.sessionId || s.id || '';
|
|
562
|
+
const project = s.project || '';
|
|
563
|
+
const branch = s.branch || s.gitBranch || s.git_branch || '';
|
|
564
|
+
const count = s.messageCount || s.message_count || s.count || 0;
|
|
565
|
+
const date = s.startTime || s.date || s.created || s.startedAt || '';
|
|
566
|
+
const preview = s.preview || '';
|
|
567
|
+
const time = relativeTime(date);
|
|
568
|
+
const dateStr = date
|
|
569
|
+
? new Date(date).toLocaleDateString(undefined, {
|
|
570
|
+
month: 'short',
|
|
571
|
+
day: 'numeric',
|
|
572
|
+
year: 'numeric',
|
|
573
|
+
})
|
|
574
|
+
: '';
|
|
575
|
+
|
|
576
|
+
return `<div class="session-card" data-session-id="${esc(id)}" tabindex="0" role="button">
|
|
577
|
+
<div class="session-header">
|
|
578
|
+
<span class="session-project">${esc(project)}</span>
|
|
579
|
+
<span class="session-date">${dateStr || time || ''}</span>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="session-meta">
|
|
582
|
+
${branch ? `<span class="session-meta-item"><span class="material-symbols-outlined">alt_route</span>${esc(branch)}</span>` : ''}
|
|
583
|
+
<span class="session-meta-item"><span class="material-symbols-outlined">chat</span>${count} messages</span>
|
|
584
|
+
<span class="session-meta-item"><span class="material-symbols-outlined">tag</span>${esc(id.slice(0, 8))}</span>
|
|
585
|
+
</div>
|
|
586
|
+
${preview ? `<div class="session-preview">${esc(preview)}</div>` : ''}
|
|
587
|
+
</div>`;
|
|
588
|
+
})
|
|
589
|
+
.join('');
|
|
590
|
+
|
|
591
|
+
el.sessionsList.querySelectorAll('.session-card').forEach((card) => {
|
|
592
|
+
card.addEventListener('click', () => openSessionPanel(card.dataset.sessionId));
|
|
593
|
+
card.addEventListener('keydown', (e) => {
|
|
594
|
+
if (e.key === 'Enter') openSessionPanel(card.dataset.sessionId);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function openSessionPanel(sessionId) {
|
|
600
|
+
if (!sessionId) return;
|
|
601
|
+
try {
|
|
602
|
+
const [session, summary] = await Promise.allSettled([
|
|
603
|
+
api(`/sessions/${encodeURIComponent(sessionId)}`),
|
|
604
|
+
api(`/sessions/${encodeURIComponent(sessionId)}/summary`),
|
|
605
|
+
]);
|
|
606
|
+
const sData = session.status === 'fulfilled' ? session.value : {};
|
|
607
|
+
const sumData = summary.status === 'fulfilled' ? summary.value : null;
|
|
608
|
+
openPanel('session', { session: sData, summary: sumData, sessionId });
|
|
609
|
+
} catch (err) {
|
|
610
|
+
toast(`Failed to load session: ${err.message}`, 'error');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Embeddings ─────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
async function loadEmbeddingStats() {
|
|
617
|
+
try {
|
|
618
|
+
const data = await api('/index-status');
|
|
619
|
+
state.embeddings.stats = data;
|
|
620
|
+
renderEmbeddingStats();
|
|
621
|
+
el.statVectors.querySelector('.stat-value').textContent = data.totalEntries || 0;
|
|
622
|
+
} catch {
|
|
623
|
+
state.embeddings.stats = null;
|
|
624
|
+
renderEmbeddingStats();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function renderEmbeddingStats() {
|
|
629
|
+
const stats = state.embeddings.stats;
|
|
630
|
+
if (!stats || !stats.totalEntries) {
|
|
631
|
+
el.embeddingsStatsGrid.innerHTML = '';
|
|
632
|
+
el.embeddingsStatus.style.display = 'none';
|
|
633
|
+
el.embeddingsEmpty.classList.remove('hidden');
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
el.embeddingsEmpty.classList.add('hidden');
|
|
638
|
+
el.embeddingsStatus.style.display = '';
|
|
639
|
+
|
|
640
|
+
const cards = [
|
|
641
|
+
{ label: 'Provider', value: stats.provider || 'Not configured', detail: '' },
|
|
642
|
+
{ label: 'Dimensions', value: stats.dimensions || 0, detail: 'vector size' },
|
|
643
|
+
{ label: 'Total Entries', value: stats.totalEntries || 0, detail: 'indexed chunks' },
|
|
644
|
+
{ label: 'Knowledge', value: stats.knowledgeEntries || 0, detail: 'knowledge vectors' },
|
|
645
|
+
{ label: 'Sessions', value: stats.sessionEntries || 0, detail: 'session vectors' },
|
|
646
|
+
{ label: 'DB Size', value: (stats.dbSizeMB || 0).toFixed(1) + ' MB', detail: 'on disk' },
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
el.embeddingsStatsGrid.innerHTML = cards
|
|
650
|
+
.map(
|
|
651
|
+
(c) => `
|
|
652
|
+
<div class="embedding-stat-card">
|
|
653
|
+
<span class="stat-label">${esc(c.label)}</span>
|
|
654
|
+
<span class="stat-number">${esc(String(c.value))}</span>
|
|
655
|
+
${c.detail ? `<span class="stat-detail">${esc(c.detail)}</span>` : ''}
|
|
656
|
+
</div>
|
|
657
|
+
`,
|
|
658
|
+
)
|
|
659
|
+
.join('');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── Side Panel ─────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
function openPanel(type, data) {
|
|
665
|
+
state.panel = { open: true, type, data };
|
|
666
|
+
el.sidePanel.hidden = false;
|
|
667
|
+
requestAnimationFrame(() => el.sidePanel.classList.add('open'));
|
|
668
|
+
el.contentWrapper.classList.add('panel-visible');
|
|
669
|
+
|
|
670
|
+
if (type === 'knowledge') {
|
|
671
|
+
renderKnowledgePanel(data);
|
|
672
|
+
} else if (type === 'session') {
|
|
673
|
+
renderSessionPanel(data);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function closePanel() {
|
|
678
|
+
state.panel = { open: false, type: null, data: null };
|
|
679
|
+
el.sidePanel.classList.remove('open');
|
|
680
|
+
el.contentWrapper.classList.remove('panel-visible');
|
|
681
|
+
el.sidePanel.addEventListener(
|
|
682
|
+
'transitionend',
|
|
683
|
+
function handler() {
|
|
684
|
+
if (!state.panel.open) el.sidePanel.hidden = true;
|
|
685
|
+
el.sidePanel.removeEventListener('transitionend', handler);
|
|
686
|
+
},
|
|
687
|
+
{ once: true },
|
|
688
|
+
);
|
|
689
|
+
// Fallback: hide after 400ms if transitionend doesn't fire
|
|
690
|
+
setTimeout(() => {
|
|
691
|
+
if (!state.panel.open) el.sidePanel.hidden = true;
|
|
692
|
+
}, 400);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function stripFrontmatter(content) {
|
|
696
|
+
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
|
|
697
|
+
return match ? match[1].trim() : content;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function renderKnowledgePanel(data) {
|
|
701
|
+
el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">article</span>${esc(data.title)}`;
|
|
702
|
+
const meta = data.meta || {};
|
|
703
|
+
const entry = meta.entry || meta;
|
|
704
|
+
const category = entry.category || meta.category || '';
|
|
705
|
+
const tags = entry.tags || meta.tags || [];
|
|
706
|
+
const updated = entry.updated || meta.updated || '';
|
|
707
|
+
|
|
708
|
+
let metaHtml = '<div class="panel-meta">';
|
|
709
|
+
if (category)
|
|
710
|
+
metaHtml += `<span class="card-category" data-cat="${esc(category)}">${esc(category)}</span>`;
|
|
711
|
+
if (tags.length)
|
|
712
|
+
metaHtml += tags.map((t) => `<span class="card-tag">${esc(t)}</span>`).join('');
|
|
713
|
+
if (updated) metaHtml += `<span class="panel-meta-date">${esc(updated)}</span>`;
|
|
714
|
+
metaHtml += '</div>';
|
|
715
|
+
|
|
716
|
+
const body = stripFrontmatter(data.content);
|
|
717
|
+
el.panelBody.innerHTML = metaHtml + `<div class="prose">${renderMd(body)}</div>`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function renderSessionPanel(data) {
|
|
721
|
+
const s = data.session || {};
|
|
722
|
+
const summary = data.summary;
|
|
723
|
+
const id = data.sessionId || s.id || '';
|
|
724
|
+
const meta = s.meta || s;
|
|
725
|
+
|
|
726
|
+
el.panelTitle.innerHTML = `<span class="material-symbols-outlined panel-icon">terminal</span>Session ${esc(id.slice(0, 8))}`;
|
|
727
|
+
|
|
728
|
+
let html = '<div class="panel-meta">';
|
|
729
|
+
if (meta.cwd)
|
|
730
|
+
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">folder</span>${esc(meta.cwd)}</span>`;
|
|
731
|
+
if (meta.branch)
|
|
732
|
+
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">alt_route</span>${esc(meta.branch)}</span>`;
|
|
733
|
+
if (meta.startTime && meta.startTime !== 'unknown')
|
|
734
|
+
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">schedule</span>${new Date(meta.startTime).toLocaleString()}</span>`;
|
|
735
|
+
if (meta.messageCount)
|
|
736
|
+
html += `<span class="panel-meta-item"><span class="material-symbols-outlined" style="font-size:14px">chat</span>${meta.messageCount} messages</span>`;
|
|
737
|
+
html += '</div>';
|
|
738
|
+
|
|
739
|
+
if (summary && summary.topics && summary.topics.length) {
|
|
740
|
+
html +=
|
|
741
|
+
'<div class="panel-section"><h4 class="panel-section-title">Topics</h4><ul class="panel-topics">';
|
|
742
|
+
summary.topics.slice(0, 8).forEach((t) => {
|
|
743
|
+
html += `<li>${esc(t.content)}</li>`;
|
|
744
|
+
});
|
|
745
|
+
html += '</ul></div>';
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const messages = s.messages || s.conversation || [];
|
|
749
|
+
// Filter out noisy messages (tool JSON, base64, system reminders)
|
|
750
|
+
const cleanMessages = messages.filter((m) => {
|
|
751
|
+
const t = (m.content || '').trimStart();
|
|
752
|
+
if (t.startsWith('[{') || t.startsWith('{"')) return false;
|
|
753
|
+
if (t.includes('tool_use_id') || t.includes('tool_result')) return false;
|
|
754
|
+
if (t.includes('base64') || t.includes('media_type')) return false;
|
|
755
|
+
if (t.includes('<system-reminder>')) return false;
|
|
756
|
+
if (t.length < 3) return false;
|
|
757
|
+
return true;
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (cleanMessages.length) {
|
|
761
|
+
html += '<div class="chat-messages">';
|
|
762
|
+
cleanMessages.forEach((m) => {
|
|
763
|
+
const role = m.role || 'unknown';
|
|
764
|
+
const text = m.content || m.text || '';
|
|
765
|
+
const isUser = role === 'user';
|
|
766
|
+
const cls = isUser ? 'chat-msg user' : 'chat-msg assistant';
|
|
767
|
+
const roleIcon = isUser ? 'person' : 'smart_toy';
|
|
768
|
+
const truncated = text.length > 1500 ? text.slice(0, 1500) + '...' : text;
|
|
769
|
+
const time = m.timestamp ? relativeTime(m.timestamp) : '';
|
|
770
|
+
|
|
771
|
+
html += `<div class="${cls}">
|
|
772
|
+
<div class="chat-msg-role"><span class="material-symbols-outlined" style="font-size:14px">${roleIcon}</span> ${esc(role)} ${time ? `<span class="chat-msg-time">${time}</span>` : ''}</div>
|
|
773
|
+
<div>${isUser ? esc(truncated) : renderMd(truncated)}</div>
|
|
774
|
+
</div>`;
|
|
775
|
+
});
|
|
776
|
+
html += '</div>';
|
|
777
|
+
} else {
|
|
778
|
+
html +=
|
|
779
|
+
'<div class="empty-state"><span class="material-symbols-outlined empty-icon">chat_bubble</span><div class="empty-text">No messages available</div></div>';
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
el.panelBody.innerHTML = html;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Event Binding ──────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
function bindEvents() {
|
|
788
|
+
// Tabs
|
|
789
|
+
Object.keys(el.tabs).forEach((k) => {
|
|
790
|
+
el.tabs[k].addEventListener('click', () => switchTab(k));
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Theme
|
|
794
|
+
el.themeToggle.addEventListener('click', toggleTheme);
|
|
795
|
+
|
|
796
|
+
// Panel close
|
|
797
|
+
el.panelClose.addEventListener('click', closePanel);
|
|
798
|
+
|
|
799
|
+
// Knowledge category chips
|
|
800
|
+
el.knowledgeCategories.addEventListener('click', (e) => {
|
|
801
|
+
const chip = e.target.closest('.chip');
|
|
802
|
+
if (!chip) return;
|
|
803
|
+
el.knowledgeCategories.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
804
|
+
chip.classList.add('active');
|
|
805
|
+
state.knowledge.activeCategory = chip.dataset.category;
|
|
806
|
+
renderKnowledge();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Search input
|
|
810
|
+
el.searchInput.addEventListener('input', () => {
|
|
811
|
+
state.search.query = el.searchInput.value;
|
|
812
|
+
doSearch();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Search role filters
|
|
816
|
+
el.searchRoleFilters.addEventListener('click', (e) => {
|
|
817
|
+
const chip = e.target.closest('.chip');
|
|
818
|
+
if (!chip) return;
|
|
819
|
+
el.searchRoleFilters.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
820
|
+
chip.classList.add('active');
|
|
821
|
+
state.search.role = chip.dataset.role;
|
|
822
|
+
if (state.search.query.trim()) doSearch();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
function setSearchMode(mode) {
|
|
826
|
+
el.modeRanked.classList.toggle('active', mode === 'ranked');
|
|
827
|
+
el.modeSemantic.classList.toggle('active', mode === 'semantic');
|
|
828
|
+
el.modeRegex.classList.toggle('active', mode === 'regex');
|
|
829
|
+
state.search.ranked = mode !== 'regex';
|
|
830
|
+
state.search.semantic = mode === 'semantic';
|
|
831
|
+
if (state.search.query.trim()) doSearch();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
el.modeRanked.addEventListener('click', () => setSearchMode('ranked'));
|
|
835
|
+
el.modeSemantic.addEventListener('click', () => setSearchMode('semantic'));
|
|
836
|
+
el.modeRegex.addEventListener('click', () => setSearchMode('regex'));
|
|
837
|
+
|
|
838
|
+
// Session project filter
|
|
839
|
+
el.sessionProjectFilter.addEventListener('change', () => {
|
|
840
|
+
state.sessions.projectFilter = el.sessionProjectFilter.value;
|
|
841
|
+
renderSessions();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// Search scope chips
|
|
845
|
+
el.searchScopes.addEventListener('click', (e) => {
|
|
846
|
+
const chip = e.target.closest('.chip');
|
|
847
|
+
if (!chip) return;
|
|
848
|
+
el.searchScopes.querySelectorAll('.chip').forEach((c) => c.classList.remove('active'));
|
|
849
|
+
chip.classList.add('active');
|
|
850
|
+
state.search.scope = chip.dataset.scope;
|
|
851
|
+
if (state.search.query.trim()) doSearch();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Keyboard shortcuts
|
|
855
|
+
document.addEventListener('keydown', (e) => {
|
|
856
|
+
// Escape closes panel
|
|
857
|
+
if (e.key === 'Escape' && state.panel.open) {
|
|
858
|
+
e.preventDefault();
|
|
859
|
+
closePanel();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// / or Ctrl+K focuses search
|
|
864
|
+
if ((e.key === '/' || (e.ctrlKey && e.key === 'k')) && !isInputFocused()) {
|
|
865
|
+
e.preventDefault();
|
|
866
|
+
switchTab('search');
|
|
867
|
+
el.searchInput.focus();
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Click outside panel to close
|
|
872
|
+
el.contentWrapper.addEventListener('click', (e) => {
|
|
873
|
+
if (state.panel.open && !el.sidePanel.contains(e.target)) {
|
|
874
|
+
// Only close if clicking on the main content area backdrop
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function isInputFocused() {
|
|
880
|
+
const active = document.activeElement;
|
|
881
|
+
if (!active) return false;
|
|
882
|
+
const tag = active.tagName.toLowerCase();
|
|
883
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select' || active.isContentEditable;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ── Init ───────────────────────────────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
async function init() {
|
|
889
|
+
initTheme();
|
|
890
|
+
bindEvents();
|
|
891
|
+
wsConnect();
|
|
892
|
+
|
|
893
|
+
// Load initial data in parallel
|
|
894
|
+
try {
|
|
895
|
+
await Promise.allSettled([loadKnowledge(), loadSessions(), loadEmbeddingStats()]);
|
|
896
|
+
} catch {
|
|
897
|
+
// individual loaders handle their own errors
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Hide loading overlay after a short delay if ws hasn't connected
|
|
901
|
+
setTimeout(() => {
|
|
902
|
+
el.loadingOverlay.classList.add('hidden');
|
|
903
|
+
}, 5000);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Panel resize handle (drag to resize like agent-tasks)
|
|
907
|
+
function initPanelResize() {
|
|
908
|
+
const panel = document.getElementById('side-panel');
|
|
909
|
+
if (!panel) return;
|
|
910
|
+
const handle = document.createElement('div');
|
|
911
|
+
handle.className = 'panel-resize-handle';
|
|
912
|
+
panel.appendChild(handle);
|
|
913
|
+
|
|
914
|
+
let isResizing = false;
|
|
915
|
+
let startX = 0;
|
|
916
|
+
let startWidth = 0;
|
|
917
|
+
|
|
918
|
+
handle.addEventListener('mousedown', (e) => {
|
|
919
|
+
isResizing = true;
|
|
920
|
+
startX = e.clientX;
|
|
921
|
+
startWidth = panel.offsetWidth;
|
|
922
|
+
document.body.style.cursor = 'col-resize';
|
|
923
|
+
document.body.style.userSelect = 'none';
|
|
924
|
+
e.preventDefault();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
document.addEventListener('mousemove', (e) => {
|
|
928
|
+
if (!isResizing) return;
|
|
929
|
+
const dx = startX - e.clientX;
|
|
930
|
+
const newWidth = Math.max(320, Math.min(startWidth + dx, window.innerWidth * 0.8));
|
|
931
|
+
panel.style.width = newWidth + 'px';
|
|
932
|
+
panel.style.minWidth = newWidth + 'px';
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
document.addEventListener('mouseup', () => {
|
|
936
|
+
if (!isResizing) return;
|
|
937
|
+
isResizing = false;
|
|
938
|
+
document.body.style.cursor = '';
|
|
939
|
+
document.body.style.userSelect = '';
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Start
|
|
944
|
+
if (document.readyState === 'loading') {
|
|
945
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
946
|
+
init();
|
|
947
|
+
initPanelResize();
|
|
948
|
+
});
|
|
949
|
+
} else {
|
|
950
|
+
init();
|
|
951
|
+
initPanelResize();
|
|
952
|
+
}
|
|
953
|
+
})();
|