agent-knowledge 1.0.0 → 1.0.4

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