feat: local LLM multi-model, session search, cron proactive types, notifications, docs overhaul
Local LLM:
- user_settings.py: per-user hosts/models config (local_llm.json)
- routers/local_llm.py + static/local_llm.html: dedicated settings page
- llm_client.py: local OpenAI-compatible backend via httpx
- config.py: LOCAL_API_URL/KEY/MODEL + per-backend timeouts
- Active model shown near backend toggle (amber hint text)
Memory distillation:
- memory_distiller.py: DISTILL_BACKEND_MID/LONG .env overrides
- scheduler.py + notification.py: notify NC Talk after mid/long distill
- notification.py: outbound channel abstraction (NC Talk, extensible)
Session search:
- routers/files.py: GET /sessions/search?q= with excerpts grouped by date
- static/index.html + app.js: search UI in file sidebar with highlight
- _esc() helper to prevent XSS in search results
Proactive cron:
- cron_runner.py: new job types — message (send directly) and brief (LLM + send)
- Both support optional per-job channel override
Channels:
- routers/nextcloud_talk.py: consolidated using notification._send_nct_message()
- routers/auth.py: local backend status in /auth/status
- routers/chat.py: /backend returns {primary, fallback, local_model} object
UI / UX:
- Copy button for user messages (matching assistant)
- Autocomplete disabled on sensitive form fields
- settings.html: local model section replaced with link to /settings/local
Docs overhaul:
- MASTER.md hub + ARCH__SYSTEM/BACKENDS/PERSONA/CHANNELS/FUTURE.md
- ARCH__Intelligence_Layer.md replaced with redirect table
- CORTEX.md trimmed to vision only; README updated
- OPEN_WEBUI_API.md added to docs/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,44 @@
|
||||
const note_vis_btn_el = document.getElementById('note-vis-btn');
|
||||
const settings_btn_el = document.getElementById('settings-btn');
|
||||
const settings_dd_el = document.getElementById('settings-dropdown');
|
||||
const sessionsBackdrop = document.getElementById('sessions-backdrop');
|
||||
|
||||
// ── Close all panels/dropdowns (mutual exclusion) ─────────────
|
||||
function closeAllPanels() {
|
||||
if (mode_dropdown_el) mode_dropdown_el.classList.remove('open');
|
||||
if (settings_dd_el) settings_dd_el.classList.remove('open');
|
||||
if (sessionsPanel) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); }
|
||||
const pd = document.getElementById('persona-dropdown');
|
||||
if (pd) pd.classList.remove('open');
|
||||
}
|
||||
|
||||
// ── Toasts ────────────────────────────────────────────────────
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
function showToast(message, type = 'info', duration = 2500) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast' + (type !== 'info' ? ' ' + type : '');
|
||||
el.textContent = message;
|
||||
toastContainer.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
});
|
||||
setTimeout(() => {
|
||||
el.classList.remove('show');
|
||||
el.addEventListener('transitionend', () => el.remove(), { once: true });
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ── Syntax highlighting ───────────────────────────────────────
|
||||
function highlight_code(container) {
|
||||
if (typeof hljs === 'undefined') return;
|
||||
container.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
|
||||
}
|
||||
|
||||
// ── Utility helpers ───────────────────────────────────────────
|
||||
function _esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Lucide icon helpers ───────────────────────────────────────
|
||||
function icon_html(name, size = 16) {
|
||||
@@ -145,6 +183,7 @@
|
||||
}
|
||||
|
||||
function open_mode_dropdown() {
|
||||
closeAllPanels();
|
||||
// Build options in MRU order (least recent at top, most recent at bottom)
|
||||
// — bottom is visually closest to the button since dropdown opens upward
|
||||
const ordered = [...mode_mru].reverse();
|
||||
@@ -236,7 +275,9 @@
|
||||
// ── Settings dropdown ─────────────────────────────────────────
|
||||
settings_btn_el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
settings_dd_el.classList.toggle('open');
|
||||
const isOpen = settings_dd_el.classList.contains('open');
|
||||
closeAllPanels();
|
||||
if (!isOpen) settings_dd_el.classList.add('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!settings_dd_el.contains(e.target) && e.target !== settings_btn_el) {
|
||||
@@ -290,7 +331,9 @@
|
||||
if (personaSwitcher) {
|
||||
personaSwitcher.addEventListener('click', (e) => {
|
||||
if (personaDropEl.children.length === 0) return;
|
||||
personaDropEl.classList.toggle('open');
|
||||
const isOpen = personaDropEl.classList.contains('open');
|
||||
closeAllPanels();
|
||||
if (!isOpen) personaDropEl.classList.add('open');
|
||||
e.stopPropagation();
|
||||
});
|
||||
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
|
||||
@@ -298,23 +341,40 @@
|
||||
|
||||
// ── Backend toggle ───────────────────────────────────────────
|
||||
|
||||
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary));
|
||||
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d));
|
||||
|
||||
function setBackendUI(backend) {
|
||||
const BACKEND_CYCLE = ['claude', 'gemini', 'local'];
|
||||
const BACKEND_CLASS = { claude: '', gemini: 'mem-on', local: 'local-on' };
|
||||
const backendModelHint = document.getElementById('backend-model-hint');
|
||||
|
||||
function setBackendUI(d) {
|
||||
const backend = d.primary || d; // accept full response obj or bare string
|
||||
primaryBackend = backend;
|
||||
backendToggle.textContent = backend;
|
||||
backendToggle.className = 'ctx-btn' + (backend === 'gemini' ? ' mem-on' : '');
|
||||
const extra = BACKEND_CLASS[backend] || '';
|
||||
backendToggle.className = 'ctx-btn' + (extra ? ' ' + extra : '');
|
||||
|
||||
if (backendModelHint) {
|
||||
if (backend === 'local' && d.local_model) {
|
||||
backendModelHint.textContent = d.local_model.label || d.local_model.model_name;
|
||||
backendModelHint.style.display = '';
|
||||
} else {
|
||||
backendModelHint.textContent = '';
|
||||
backendModelHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backendToggle.addEventListener('click', async () => {
|
||||
const next = primaryBackend === 'claude' ? 'gemini' : 'claude';
|
||||
const idx = BACKEND_CYCLE.indexOf(primaryBackend);
|
||||
const next = BACKEND_CYCLE[(idx + 1) % BACKEND_CYCLE.length];
|
||||
const res = await fetch('/backend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ primary: next }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setBackendUI(d.primary);
|
||||
setBackendUI(d);
|
||||
addMessage('system', `Backend: ${d.primary} (fallback: ${d.fallback})`);
|
||||
});
|
||||
|
||||
@@ -324,17 +384,26 @@
|
||||
e.stopPropagation();
|
||||
if (sessionsPanel.classList.contains('open')) {
|
||||
sessionsPanel.classList.remove('open');
|
||||
sessionsBackdrop.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
closeAllPanels();
|
||||
const res = await fetch(`/sessions?${_fileParams}`);
|
||||
const data = await res.json();
|
||||
renderPanel(data.sessions);
|
||||
sessionsPanel.classList.add('open');
|
||||
sessionsBackdrop.classList.add('open');
|
||||
});
|
||||
|
||||
sessionsBackdrop.addEventListener('click', () => {
|
||||
sessionsPanel.classList.remove('open');
|
||||
sessionsBackdrop.classList.remove('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) {
|
||||
sessionsPanel.classList.remove('open');
|
||||
sessionsBackdrop.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -354,6 +423,7 @@
|
||||
sessionEl.textContent = '';
|
||||
addMessage('system', 'New session');
|
||||
sessionsPanel.classList.remove('open');
|
||||
sessionsBackdrop.classList.remove('open');
|
||||
inputEl.focus();
|
||||
});
|
||||
sessionsPanel.appendChild(newItem);
|
||||
@@ -408,6 +478,7 @@
|
||||
if (sessionId === s.session_id) {
|
||||
sessionEl.textContent = `session: ${newName || s.session_id}`;
|
||||
}
|
||||
if (newName) showToast('Session renamed', 'success');
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
@@ -431,7 +502,7 @@
|
||||
currentHistory = [];
|
||||
messagesEl.innerHTML = '';
|
||||
sessionEl.textContent = '';
|
||||
addMessage('system', 'Session deleted');
|
||||
showToast('Session deleted');
|
||||
}
|
||||
const res = await fetch(`/sessions?${_fileParams}`);
|
||||
const data = await res.json();
|
||||
@@ -484,6 +555,7 @@
|
||||
if (!silent) addMessage('system', `Resumed session ${id}`);
|
||||
scrollToBottom();
|
||||
sessionsPanel.classList.remove('open');
|
||||
sessionsBackdrop.classList.remove('open');
|
||||
inputEl.focus();
|
||||
persist_session();
|
||||
}
|
||||
@@ -529,6 +601,7 @@
|
||||
if (role === 'assistant' && typeof marked !== 'undefined') {
|
||||
div.dataset.raw = text;
|
||||
div.innerHTML = marked.parse(text);
|
||||
highlight_code(div);
|
||||
div.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
@@ -544,7 +617,9 @@
|
||||
div.appendChild(label);
|
||||
div.appendChild(content);
|
||||
} else {
|
||||
div.dataset.raw = text;
|
||||
div.textContent = text;
|
||||
div.appendChild(makeCopyBtn(div));
|
||||
}
|
||||
|
||||
// Wrap user/assistant messages so action buttons can be attached
|
||||
@@ -699,6 +774,7 @@
|
||||
if (role === 'assistant' && typeof marked !== 'undefined') {
|
||||
div.dataset.raw = text;
|
||||
div.innerHTML = marked.parse(text);
|
||||
highlight_code(div);
|
||||
div.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
@@ -709,6 +785,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent tool-call step cards ────────────────────────────────
|
||||
function renderToolCalls(toolCalls, beforeEl) {
|
||||
if (!toolCalls || toolCalls.length === 0) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tool-calls-container';
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'tool-call';
|
||||
|
||||
// Summary: name + first arg value snippet
|
||||
const args = tc.args || {};
|
||||
const argKeys = Object.keys(args);
|
||||
let argSnippet = '';
|
||||
if (argKeys.length > 0) {
|
||||
const firstVal = String(args[argKeys[0]]);
|
||||
argSnippet = firstVal.length > 60 ? firstVal.slice(0, 60) + '…' : firstVal;
|
||||
}
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'tc-name';
|
||||
nameSpan.textContent = tc.tool;
|
||||
summary.appendChild(nameSpan);
|
||||
if (argSnippet) {
|
||||
const snippetSpan = document.createElement('span');
|
||||
snippetSpan.className = 'tc-snippet';
|
||||
snippetSpan.textContent = argSnippet;
|
||||
summary.appendChild(snippetSpan);
|
||||
}
|
||||
details.appendChild(summary);
|
||||
|
||||
// Expanded body
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tc-body';
|
||||
|
||||
if (argKeys.length > 0) {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'tc-section';
|
||||
const lbl = document.createElement('span');
|
||||
lbl.className = 'tc-label';
|
||||
lbl.textContent = 'args';
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(args, null, 2);
|
||||
sec.appendChild(lbl);
|
||||
sec.appendChild(pre);
|
||||
body.appendChild(sec);
|
||||
}
|
||||
|
||||
const resultStr = tc.result || '';
|
||||
const truncated = resultStr.length > 400;
|
||||
const sec2 = document.createElement('div');
|
||||
sec2.className = 'tc-section';
|
||||
const lbl2 = document.createElement('span');
|
||||
lbl2.className = 'tc-label';
|
||||
lbl2.textContent = 'result';
|
||||
const pre2 = document.createElement('pre');
|
||||
pre2.textContent = truncated ? resultStr.slice(0, 400) + '\n…[truncated]' : resultStr;
|
||||
sec2.appendChild(lbl2);
|
||||
sec2.appendChild(pre2);
|
||||
body.appendChild(sec2);
|
||||
|
||||
details.appendChild(body);
|
||||
container.appendChild(details);
|
||||
}
|
||||
|
||||
beforeEl.parentElement.insertBefore(container, beforeEl);
|
||||
}
|
||||
|
||||
function makeCopyBtn(div) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy-btn';
|
||||
@@ -722,6 +868,7 @@
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
showToast('Copied to clipboard', 'success', 1800);
|
||||
btn.innerHTML = icon_html('check', 12) + ' copied';
|
||||
render_icons();
|
||||
btn.classList.add('copied');
|
||||
@@ -762,7 +909,7 @@
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
addMessage('system', `Note save failed: ${err.message}`);
|
||||
showToast(`Note save failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,11 +1091,7 @@
|
||||
currentHistory.push({ role: 'assistant', content: job.response || '' });
|
||||
attachHistoryControls(thinkingDiv, assistHistIdx);
|
||||
|
||||
const n = job.tool_calls?.length || 0;
|
||||
if (n) {
|
||||
const names = job.tool_calls.map(t => t.name).join(', ');
|
||||
addMessage('system', `⚡ ${n} tool call${n !== 1 ? 's' : ''}: ${names}`);
|
||||
}
|
||||
renderToolCalls(job.tool_calls, thinkingDiv.parentElement);
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
@@ -989,17 +1132,94 @@
|
||||
|
||||
// ── File editor ──────────────────────────────────────────────
|
||||
const fileModal = document.getElementById('file-modal');
|
||||
const fileSelect = document.getElementById('file-select');
|
||||
const fileSidebar = document.getElementById('file-sidebar');
|
||||
const fileEditor = document.getElementById('file-editor');
|
||||
const filePreview = document.getElementById('file-preview');
|
||||
const fileRawBtn = document.getElementById('file-raw-btn');
|
||||
const filePreviewBtn = document.getElementById('file-preview-btn');
|
||||
const fileSaveBtn = document.getElementById('file-save-btn');
|
||||
const fileSavedMsg = document.getElementById('file-saved-msg');
|
||||
const fileCloseBtn = document.getElementById('file-close-btn');
|
||||
const filesBtn = document.getElementById('files-btn');
|
||||
|
||||
let fileMode = 'preview'; // 'edit' or 'preview'
|
||||
let fileMode = 'preview'; // 'edit' or 'preview'
|
||||
let activeFileName = null;
|
||||
|
||||
// File groups — controls sidebar order and section labels
|
||||
const FILE_GROUPS = [
|
||||
{ label: 'Identity', files: ['IDENTITY.md', 'SOUL.md', 'PROTOCOLS.md', 'CONTEXT_TIERS.md'] },
|
||||
{ label: 'Memory', files: ['MEMORY_LONG.md', 'MEMORY_MID.md', 'MEMORY_SHORT.md'] },
|
||||
{ label: 'Profile', files: ['USER.md', 'HELP.md'] },
|
||||
];
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return 'empty';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
return (bytes / 1024).toFixed(1) + ' KB';
|
||||
}
|
||||
|
||||
function fmtModified(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return 'today';
|
||||
const diff = (now - d) / 86400000;
|
||||
if (diff < 2) return 'yesterday';
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function renderFileSidebar(files) {
|
||||
const byName = Object.fromEntries(files.map(f => [f.name, f]));
|
||||
fileSidebar.innerHTML = '';
|
||||
|
||||
for (const group of FILE_GROUPS) {
|
||||
const groupEl = document.createElement('div');
|
||||
groupEl.className = 'file-group';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'fg-header';
|
||||
header.textContent = group.label;
|
||||
header.addEventListener('click', () => header.classList.toggle('collapsed'));
|
||||
groupEl.appendChild(header);
|
||||
|
||||
const items = document.createElement('div');
|
||||
items.className = 'fg-items';
|
||||
|
||||
for (const fname of group.files) {
|
||||
const f = byName[fname];
|
||||
if (!f) continue;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item' + (f.exists ? '' : ' missing');
|
||||
item.dataset.name = fname;
|
||||
if (fname === activeFileName) item.classList.add('active');
|
||||
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'fi-name';
|
||||
nameEl.textContent = fname;
|
||||
item.appendChild(nameEl);
|
||||
|
||||
const metaEl = document.createElement('div');
|
||||
metaEl.className = 'fi-meta';
|
||||
metaEl.innerHTML = `<span>${fmtSize(f.size)}</span>`
|
||||
+ (f.modified ? `<span>${fmtModified(f.modified)}</span>` : '');
|
||||
item.appendChild(metaEl);
|
||||
|
||||
item.addEventListener('click', () => loadFile(fname));
|
||||
items.appendChild(item);
|
||||
}
|
||||
|
||||
groupEl.appendChild(items);
|
||||
fileSidebar.appendChild(groupEl);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveFile(name) {
|
||||
activeFileName = name;
|
||||
fileSidebar.querySelectorAll('.file-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.name === name);
|
||||
});
|
||||
document.getElementById('file-modal-title').textContent = name;
|
||||
}
|
||||
|
||||
function setFileMode(mode) {
|
||||
fileMode = mode;
|
||||
@@ -1023,27 +1243,22 @@
|
||||
}
|
||||
|
||||
async function loadFile(name) {
|
||||
setActiveFile(name);
|
||||
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
|
||||
if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; }
|
||||
const data = await res.json();
|
||||
fileEditor.value = data.content;
|
||||
document.getElementById('file-modal-title').textContent = name;
|
||||
setFileMode(fileMode);
|
||||
}
|
||||
|
||||
async function openFileModal() {
|
||||
// Populate the file list
|
||||
const res = await fetch(`/files?${_fileParams}`);
|
||||
const res = await fetch(`/files?${_fileParams}`);
|
||||
const data = await res.json();
|
||||
fileSelect.innerHTML = '';
|
||||
for (const f of data.files) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.name;
|
||||
opt.textContent = f.name + (f.exists ? '' : ' (missing)');
|
||||
fileSelect.appendChild(opt);
|
||||
}
|
||||
renderFileSidebar(data.files);
|
||||
fileModal.classList.add('open');
|
||||
await loadFile(fileSelect.value);
|
||||
// Load first existing file
|
||||
const first = data.files.find(f => f.exists) || data.files[0];
|
||||
if (first) await loadFile(first.name);
|
||||
}
|
||||
|
||||
filesBtn.addEventListener('click', () => {
|
||||
@@ -1051,21 +1266,24 @@
|
||||
openFileModal();
|
||||
});
|
||||
|
||||
fileSelect.addEventListener('change', () => loadFile(fileSelect.value));
|
||||
|
||||
fileRawBtn.addEventListener('click', () => setFileMode('edit'));
|
||||
filePreviewBtn.addEventListener('click', () => setFileMode('preview'));
|
||||
|
||||
fileSaveBtn.addEventListener('click', async () => {
|
||||
const name = fileSelect.value;
|
||||
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`, {
|
||||
if (!activeFileName) return;
|
||||
const res = await fetch(`/files/${encodeURIComponent(activeFileName)}?${_fileParams}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: fileEditor.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fileSavedMsg.classList.add('show');
|
||||
setTimeout(() => fileSavedMsg.classList.remove('show'), 2000);
|
||||
showToast('File saved', 'success');
|
||||
// Refresh sidebar to update size/modified
|
||||
const listRes = await fetch(`/files?${_fileParams}`);
|
||||
const listData = await listRes.json();
|
||||
renderFileSidebar(listData.files);
|
||||
} else {
|
||||
showToast('Save failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1075,6 +1293,66 @@
|
||||
if (e.target === fileModal) fileModal.classList.remove('open');
|
||||
});
|
||||
|
||||
// ── Session search ────────────────────────────────────────────
|
||||
const sessionSearchInput = document.getElementById('session-search-input');
|
||||
const sessionSearchBtn = document.getElementById('session-search-btn');
|
||||
const sessionSearchResults = document.getElementById('session-search-results');
|
||||
|
||||
function _showFileView() {
|
||||
fileEditor.style.display = '';
|
||||
filePreview.style.display = '';
|
||||
sessionSearchResults.style.display = 'none';
|
||||
}
|
||||
|
||||
function _showSearchResults(html) {
|
||||
fileEditor.style.display = 'none';
|
||||
filePreview.style.display = 'none';
|
||||
sessionSearchResults.style.display = '';
|
||||
sessionSearchResults.innerHTML = html;
|
||||
}
|
||||
|
||||
async function runSessionSearch() {
|
||||
const q = sessionSearchInput.value.trim();
|
||||
if (q.length < 2) return;
|
||||
sessionSearchBtn.disabled = true;
|
||||
sessionSearchBtn.textContent = '…';
|
||||
try {
|
||||
const res = await fetch(`/sessions/search?q=${encodeURIComponent(q)}&${_fileParams}&limit=30`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { _showSearchResults(`<p class="sr-error">Error: ${data.detail || res.status}</p>`); return; }
|
||||
if (!data.matches.length) {
|
||||
_showSearchResults(`<p class="sr-empty">No results for "<strong>${_esc(q)}</strong>" in ${data.total_files_searched} session file(s).</p>`);
|
||||
return;
|
||||
}
|
||||
let html = `<div class="sr-header">${data.matches.length} result(s) for "<strong>${_esc(q)}</strong>" across ${data.total_files_searched} session(s)</div>`;
|
||||
let lastDate = null;
|
||||
for (const m of data.matches) {
|
||||
if (m.date !== lastDate) {
|
||||
html += `<div class="sr-date">${m.date}</div>`;
|
||||
lastDate = m.date;
|
||||
}
|
||||
const hi = m.excerpt.replace(new RegExp(_esc(q), 'gi'), s => `<mark>${_esc(s)}</mark>`);
|
||||
html += `<div class="sr-excerpt">${hi}</div>`;
|
||||
}
|
||||
_showSearchResults(html);
|
||||
} catch (e) {
|
||||
_showSearchResults(`<p class="sr-error">Search failed: ${e.message}</p>`);
|
||||
} finally {
|
||||
sessionSearchBtn.disabled = false;
|
||||
sessionSearchBtn.textContent = 'Go';
|
||||
}
|
||||
}
|
||||
|
||||
sessionSearchBtn.addEventListener('click', runSessionSearch);
|
||||
sessionSearchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') runSessionSearch();
|
||||
});
|
||||
|
||||
// When a file is clicked, switch back from search results to editor
|
||||
fileSidebar.addEventListener('click', () => {
|
||||
if (sessionSearchResults.style.display !== 'none') _showFileView();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
|
||||
|
||||
Reference in New Issue
Block a user