202 lines
5.4 KiB
JavaScript
202 lines
5.4 KiB
JavaScript
// Search Chat Module — Ctrl+K command palette for searching conversations
|
|
|
|
import uiModule from './ui.js';
|
|
import sessionModule from './sessions.js';
|
|
|
|
let API_BASE = '';
|
|
let debounceTimer = null;
|
|
let selectedIndex = -1;
|
|
let results = [];
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
export function openSearch() {
|
|
const overlay = el('search-overlay');
|
|
if (!overlay) return;
|
|
overlay.classList.remove('hidden');
|
|
const input = el('search-input');
|
|
if (input) {
|
|
input.value = '';
|
|
input.focus();
|
|
}
|
|
selectedIndex = -1;
|
|
results = [];
|
|
el('search-results').innerHTML = '';
|
|
}
|
|
|
|
export function closeSearch() {
|
|
const overlay = el('search-overlay');
|
|
if (!overlay) return;
|
|
overlay.classList.add('hidden');
|
|
el('search-results').innerHTML = '';
|
|
selectedIndex = -1;
|
|
results = [];
|
|
}
|
|
|
|
export function isOpen() {
|
|
const overlay = el('search-overlay');
|
|
return overlay && !overlay.classList.contains('hidden');
|
|
}
|
|
|
|
var escapeHtml = uiModule.esc;
|
|
|
|
function highlightMatch(text, query) {
|
|
if (!query) return escapeHtml(text);
|
|
const escaped = escapeHtml(text);
|
|
const regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
|
return escaped.replace(regex, '<mark class="search-highlight">$1</mark>');
|
|
}
|
|
|
|
function formatTimestamp(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const diff = now - d;
|
|
if (diff < 86400000) {
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
if (diff < 604800000) {
|
|
return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function renderResults(data, query) {
|
|
results = data;
|
|
selectedIndex = -1;
|
|
const container = el('search-results');
|
|
if (!container) return;
|
|
|
|
if (!data || data.length === 0) {
|
|
container.innerHTML = query
|
|
? '<div class="search-empty">No results found</div>'
|
|
: '';
|
|
return;
|
|
}
|
|
|
|
// Group by session
|
|
const grouped = {};
|
|
for (const r of data) {
|
|
if (!grouped[r.session_id]) {
|
|
grouped[r.session_id] = { name: r.session_name, items: [] };
|
|
}
|
|
grouped[r.session_id].items.push(r);
|
|
}
|
|
|
|
let html = '';
|
|
let idx = 0;
|
|
for (const [sessionId, group] of Object.entries(grouped)) {
|
|
html += `<div class="search-group-header">${escapeHtml(group.name)}</div>`;
|
|
for (const item of group.items) {
|
|
const roleLabel = item.role === 'user' ? 'You' : 'AI';
|
|
html += `<div class="search-result-item" data-index="${idx}" data-session="${escapeHtml(sessionId)}">
|
|
<div class="search-result-role">${roleLabel}</div>
|
|
<div class="search-result-snippet">${highlightMatch(item.content_snippet, query)}</div>
|
|
<div class="search-result-time">${formatTimestamp(item.timestamp)}</div>
|
|
</div>`;
|
|
idx++;
|
|
}
|
|
}
|
|
container.innerHTML = html;
|
|
|
|
// Click handlers
|
|
container.querySelectorAll('.search-result-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const sid = item.dataset.session;
|
|
navigateToSession(sid);
|
|
});
|
|
});
|
|
}
|
|
|
|
function navigateToSession(sessionId) {
|
|
closeSearch();
|
|
if (sessionModule && sessionModule.selectSession) {
|
|
sessionModule.selectSession(sessionId);
|
|
}
|
|
}
|
|
|
|
function updateSelection() {
|
|
const container = el('search-results');
|
|
if (!container) return;
|
|
const items = container.querySelectorAll('.search-result-item');
|
|
items.forEach((item, i) => {
|
|
item.classList.toggle('selected', i === selectedIndex);
|
|
});
|
|
// Scroll selected into view
|
|
if (selectedIndex >= 0 && items[selectedIndex]) {
|
|
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
if (!isOpen()) return;
|
|
|
|
const container = el('search-results');
|
|
const items = container ? container.querySelectorAll('.search-result-item') : [];
|
|
const count = items.length;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
selectedIndex = count > 0 ? Math.min(selectedIndex + 1, count - 1) : -1;
|
|
updateSelection();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
updateSelection();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (selectedIndex >= 0 && items[selectedIndex]) {
|
|
const sid = items[selectedIndex].dataset.session;
|
|
navigateToSession(sid);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleInput(e) {
|
|
const query = e.target.value.trim();
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
|
|
if (!query) {
|
|
renderResults([], '');
|
|
return;
|
|
}
|
|
|
|
debounceTimer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=20`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
renderResults(data, query);
|
|
} catch (err) {
|
|
console.error('Search error:', err);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
export function init(apiBase) {
|
|
API_BASE = apiBase || '';
|
|
|
|
const input = el('search-input');
|
|
if (input) {
|
|
input.addEventListener('input', handleInput);
|
|
input.addEventListener('keydown', handleKeydown);
|
|
}
|
|
|
|
// Close on overlay click (not popup click)
|
|
const overlay = el('search-overlay');
|
|
if (overlay) {
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) closeSearch();
|
|
});
|
|
}
|
|
}
|
|
|
|
const searchChatModule = {
|
|
init,
|
|
openSearch,
|
|
closeSearch,
|
|
isOpen,
|
|
};
|
|
|
|
export default searchChatModule;
|