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:
Scott Idem
2026-04-05 20:53:06 -04:00
parent bd6532e93a
commit a4daebdc9b
33 changed files with 2985 additions and 486 deletions

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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');

View File

@@ -21,6 +21,8 @@
</script>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
</head>
<body>
@@ -101,6 +103,7 @@
<div class="ctx-row">
<button id="backend-toggle" class="ctx-btn" title="Click to switch primary backend">claude</button>
</div>
<div id="backend-model-hint"></div>
</div>
<div class="ctx-section">
<div class="ctx-section-title">Display</div>
@@ -123,16 +126,28 @@
<div id="file-modal-inner">
<div id="file-modal-header">
<span id="file-modal-title">Context Files</span>
<select id="file-select"></select>
<span class="fm-spacer"></span>
<button class="fm-btn" id="file-raw-btn">edit</button>
<button class="fm-btn active" id="file-preview-btn">preview</button>
<button class="fm-btn save" id="file-save-btn">Save</button>
<span id="file-saved-msg">saved ✓</span>
<button class="fm-btn" id="file-close-btn"></button>
</div>
<div id="file-modal-body">
<textarea id="file-editor" spellcheck="false"></textarea>
<div id="file-preview"></div>
<div id="file-modal-content">
<div id="file-sidebar-wrap">
<div id="file-sidebar"></div>
<div id="session-search-wrap">
<div id="session-search-label">Session Search</div>
<div id="session-search-row">
<input id="session-search-input" type="search" placeholder="Search sessions…" autocomplete="off">
<button id="session-search-btn">Go</button>
</div>
</div>
</div>
<div id="file-modal-body">
<textarea id="file-editor" spellcheck="false"></textarea>
<div id="file-preview"></div>
<div id="session-search-results" style="display:none"></div>
</div>
</div>
</div>
</div>
@@ -169,6 +184,8 @@
</div>
</div>
<div id="sessions-backdrop"></div>
<div id="toast-container"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Local Models</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
background: #0f1117;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 450;
-webkit-font-smoothing: antialiased;
color: #e2e8f0;
padding: 2rem 1.5rem 4rem;
}
.page { max-width: 640px; margin: 0 auto; }
/* ── Nav ── */
.page-nav {
display: flex; align-items: center; gap: 0.25rem;
margin-bottom: 1.75rem; flex-wrap: wrap;
}
.nav-link {
display: inline-flex; align-items: center;
padding: 0.3rem 0.6rem; border-radius: 6px;
font-size: 0.8rem; font-weight: 500; color: #64748b;
text-decoration: none; transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); }
.nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: #475569; }
.nav-link.nav-logout:hover { color: #94a3b8; background: none; }
/* ── Page header ── */
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2d3148; }
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.page-header p { font-size: 0.82rem; color: #94a3b8; margin-top: 0.25rem; }
/* ── Section cards ── */
.section {
background: #1a1d27; border: 1px solid #2d3148;
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
}
.section h2 {
font-size: 0.85rem; font-weight: 600; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 1.1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #2d3148;
}
/* ── Form elements ── */
.field { margin-bottom: 0.9rem; }
label {
display: block; font-size: 0.78rem; font-weight: 500;
color: #94a3b8; margin-bottom: 0.35rem;
}
input[type="text"], input[type="password"], input[type="url"], select {
width: 100%; padding: 0.6rem 0.8rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px;
color: #e2e8f0; font-size: 0.9rem; font-family: inherit;
outline: none; transition: border-color 0.15s;
}
input:focus, select:focus { border-color: #7c3aed; }
select { cursor: pointer; }
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; margin-bottom: 0; }
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
/* ── Buttons ── */
.btn {
padding: 0.6rem 1.1rem; border: none; border-radius: 6px;
font-size: 0.88rem; font-weight: 600; cursor: pointer;
transition: background 0.15s, opacity 0.15s; font-family: inherit;
}
.btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:hover { background: #6d28d9; }
.btn-secondary {
background: #1a1d27; color: #94a3b8;
border: 1px solid #2d3148;
}
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; }
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; }
.btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.5rem; }
/* ── Model list ── */
.model-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 0.9rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
margin-bottom: 0.5rem;
}
.model-row.model-active { border-color: #7c3aed; background: #13102a; }
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.model-label { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; }
.model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; }
.model-host { font-size: 0.72rem; color: #475569; }
.active-badge {
display: inline-block; margin-left: 0.5rem;
padding: 0.1rem 0.45rem; border-radius: 3px;
background: #4c1d95; color: #c4b5fd;
font-size: 0.68rem; font-weight: 600; text-transform: uppercase;
vertical-align: middle;
}
.active-label { font-size: 0.8rem; color: #a78bfa; font-weight: 500; }
.model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.row-btn {
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
font-weight: 500; cursor: pointer; font-family: inherit;
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8;
transition: border-color 0.15s, color 0.15s;
}
.row-btn:hover { border-color: #7c3aed; color: #a78bfa; }
.row-btn.danger { color: #f87171; border-color: #2d3148; }
.row-btn.danger:hover { border-color: #f87171; }
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.5rem 0; }
/* ── Fetch models ── */
#fetch-status { font-size: 0.8rem; color: #94a3b8; margin-top: 0.5rem; min-height: 1.2rem; }
#fetch-status.ok { color: #4ade80; }
#fetch-status.err { color: #f87171; }
#model-select-wrap { display: none; margin-top: 0.75rem; }
/* ── Messages ── */
.msg {
font-size: 0.85rem; text-align: center;
padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem;
}
.msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; }
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
/* ── Key hint ── */
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
</style>
</head>
<body>
<div class="page">
<nav class="page-nav">
<a href="/" class="nav-link">← Chat</a>
<a href="/help" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/local" class="nav-link active">Local Models</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page-header">
<h1>Local Models</h1>
<p>Configure your OpenAI-compatible host and models (Open WebUI, Ollama, LM Studio, etc.)</p>
</div>
<!-- SUCCESS -->
<!-- ERROR -->
<!-- ── Host ── -->
<div class="section">
<h2>Host</h2>
<p style="font-size:0.82rem; color:#94a3b8; margin-bottom:1rem; line-height:1.55;">
The API server that hosts your local models. Leave the key blank to keep the existing one.
</p>
<form method="POST" action="/settings/local/host">
<input type="hidden" name="host_id" value="{{ host_id }}">
<div class="field-row">
<div class="field">
<label for="host_label">Label</label>
<input type="text" id="host_label" name="label"
value="{{ host_label }}" placeholder="e.g. Home ML Laptop"
autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label for="host_url">API URL</label>
<input type="text" id="host_url" name="api_url"
value="{{ host_url }}" placeholder="http://192.168.x.x:3000"
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<div class="field">
<label for="host_key">API Key</label>
<input type="password" id="host_key" name="api_key"
placeholder="{{ host_key_hint }}"
autocomplete="new-password"
data-1p-ignore data-lpignore="true" data-form-type="other">
<p class="key-status">Current: {{ host_key_hint }}</p>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-primary btn-sm">Save Host</button>
<button type="button" id="fetch-btn" class="btn btn-secondary btn-sm"
{{ has_host == 'false' and 'disabled title="Save a host first"' or '' }}>
Fetch models from host
</button>
<span id="fetch-status"></span>
</div>
</form>
</div>
<!-- ── Configured models ── -->
<div class="section">
<h2>Models</h2>
{{ model_rows }}
</div>
<!-- ── Add model ── -->
<div class="section" id="add-section"{{ add_section_hidden }}>
<h2>Add Model</h2>
<div id="model-select-wrap">
<div class="field">
<label for="model-picker">Available on host</label>
<select id="model-picker">
<option value="">— select a model —</option>
</select>
</div>
</div>
<form method="POST" action="/settings/local/models/add" id="add-form">
<input type="hidden" name="host_id" value="{{ first_host_id }}">
<div class="field-row">
<div class="field">
<label for="add-label">Label <span style="color:#475569; font-weight:400">(friendly name)</span></label>
<input type="text" id="add-label" name="label"
placeholder="e.g. Qwen3 8B"
autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label for="add-model-name">Model name</label>
<input type="text" id="add-model-name" name="model_name"
placeholder="e.g. test-agent-simple"
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add Model</button>
</form>
</div>
</div>
<script>
const fetchBtn = document.getElementById('fetch-btn');
const fetchStatus = document.getElementById('fetch-status');
const picker = document.getElementById('model-picker');
const pickerWrap = document.getElementById('model-select-wrap');
const labelInput = document.getElementById('add-label');
const nameInput = document.getElementById('add-model-name');
if (fetchBtn) {
fetchBtn.addEventListener('click', async () => {
fetchBtn.disabled = true;
fetchStatus.textContent = 'Fetching…';
fetchStatus.className = '';
try {
const res = await fetch('/api/local-llm/fetch-models');
const data = await res.json();
if (data.error) {
fetchStatus.textContent = '✗ ' + data.error;
fetchStatus.className = 'err';
return;
}
picker.innerHTML = '<option value="">— select a model —</option>';
for (const m of data.models) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
opt.dataset.id = m.id;
opt.dataset.name = m.name;
picker.appendChild(opt);
}
pickerWrap.style.display = 'block';
fetchStatus.textContent = `${data.models.length} model${data.models.length !== 1 ? 's' : ''} found`;
fetchStatus.className = 'ok';
} catch (e) {
fetchStatus.textContent = '✗ ' + e.message;
fetchStatus.className = 'err';
} finally {
fetchBtn.disabled = false;
}
});
}
// Auto-fill label + model name when a model is selected from the picker
picker.addEventListener('change', () => {
const opt = picker.options[picker.selectedIndex];
if (!opt.value) return;
nameInput.value = opt.dataset.id || opt.value;
// Only pre-fill label if it looks different from the model id
if (opt.dataset.name && opt.dataset.name !== opt.dataset.id) {
labelInput.value = opt.dataset.name;
} else {
labelInput.value = '';
}
nameInput.focus();
});
</script>
</body>
</html>

View File

@@ -241,7 +241,8 @@
<label for="new_username">New username</label>
<input type="text" id="new_username" name="new_username"
value="{{ username }}"
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus>
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus
autocomplete="off" data-form-type="other">
<p style="font-size:0.75rem; color:#94a3b8; margin-top:0.3rem;">
Lowercase letters, digits, _ or - only. You will be logged out after renaming.
</p>
@@ -281,8 +282,9 @@
<div class="field">
<label for="gemini_api_key">API Key</label>
<input type="text" id="gemini_api_key" name="gemini_api_key"
placeholder="{{ gemini_key_hint }}" autocomplete="off"
spellcheck="false" data-1p-ignore data-lpignore="true">
placeholder="{{ gemini_key_hint }}"
autocomplete="new-password" spellcheck="false"
data-1p-ignore data-lpignore="true" data-form-type="other">
</div>
<button type="submit">Save Key</button>
</form>
@@ -294,6 +296,20 @@
</p>
</div>
<!-- Local models link -->
<div class="section">
<h2>Local Models</h2>
<p style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.85rem; line-height:1.55;">
Configure OpenAI-compatible hosts and models (Open WebUI, Ollama, LM Studio, etc.).
</p>
<a href="/settings/local"
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
transition:background 0.15s;">
Manage local models →
</a>
</div>
<!-- Change password -->
<div class="section">
<h2>Change Password</h2>

View File

@@ -431,6 +431,8 @@
padding: 0;
font-size: 0.85em;
}
/* Syntax highlighting — app theme controls the pre background; hljs adds token colors */
.message.assistant pre code.hljs { background: transparent; padding: 0; }
.message.system {
align-self: center;
@@ -440,6 +442,80 @@
padding: 2px 0;
}
/* ── Tool call step cards (agent mode) ── */
.tool-calls-container {
display: flex;
flex-direction: column;
gap: 3px;
margin: 4px 0 6px;
align-self: stretch;
}
.tool-call {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
font-size: 0.78rem;
}
.tool-call summary {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.35rem 0.65rem;
cursor: pointer;
list-style: none;
user-select: none;
color: var(--muted);
}
.tool-call summary::-webkit-details-marker { display: none; }
.tool-call summary::before {
content: '▶';
font-size: 0.55rem;
color: var(--muted);
transition: transform 0.12s;
flex-shrink: 0;
}
.tool-call[open] summary::before { transform: rotate(90deg); }
.tool-call summary:hover { color: var(--text); background: rgba(255,255,255,0.03); }
.tc-name {
font-weight: 600;
color: var(--accent);
font-family: 'Courier New', monospace;
}
.tc-snippet {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 36ch;
}
.tc-body {
padding: 0 0.65rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tc-section { display: flex; flex-direction: column; gap: 2px; }
.tc-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.tc-body pre {
margin: 0;
background: var(--pre-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
overflow-x: auto;
}
.message.error {
align-self: flex-start;
background: var(--error-bg);
@@ -451,7 +527,7 @@
.message.thinking { color: var(--muted); font-style: italic; }
/* Copy button */
.message.assistant { position: relative; }
.message.assistant, .message.user { position: relative; }
.copy-btn {
display: inline-flex;
@@ -471,7 +547,8 @@
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.message.assistant:hover .copy-btn { opacity: 1; }
.message.assistant:hover .copy-btn,
.message.user:hover .copy-btn { opacity: 1; }
.copy-btn:hover { color: var(--text); border-color: var(--muted); }
.copy-btn.copied { color: var(--success); border-color: var(--success-dim); }
@@ -807,22 +884,12 @@
flex-shrink: 0;
}
#file-modal-header select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
font-size: 0.85rem;
padding: 4px 8px;
cursor: pointer;
}
#file-modal-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
flex: 1;
}
.fm-spacer { flex: 1; }
.fm-btn {
background: var(--bg);
@@ -838,13 +905,153 @@
.fm-btn.active { color: var(--accent); border-color: var(--accent); }
.fm-btn.save { color: var(--accent); border-color: var(--inara-border); }
.fm-btn.save:hover { background: var(--inara-bg); }
#file-saved-msg {
font-size: 0.75rem;
color: #6abf6a;
opacity: 0;
transition: opacity 0.3s;
#file-modal-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* ── File sidebar ── */
#file-sidebar-wrap {
width: 190px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg);
}
#file-sidebar {
flex: 1;
overflow-y: auto;
}
/* ── Session search (within sidebar) ── */
#session-search-wrap {
border-top: 1px solid var(--border);
padding: 8px 8px 10px;
}
#session-search-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 5px;
}
#session-search-row {
display: flex;
gap: 4px;
}
#session-search-input {
flex: 1;
min-width: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.78rem;
padding: 3px 6px;
}
#session-search-btn {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-size: 0.78rem;
padding: 3px 8px;
cursor: pointer;
}
#session-search-btn:hover { color: var(--accent); border-color: var(--accent); }
/* ── Session search results panel ── */
#session-search-results {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
font-size: 0.82rem;
}
.sr-header { color: var(--muted); font-size: 0.72rem; margin-bottom: 10px; }
.sr-date {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin: 14px 0 4px;
}
.sr-date:first-of-type { margin-top: 0; }
.sr-excerpt {
background: var(--surface);
border-left: 2px solid var(--border);
border-radius: 0 4px 4px 0;
padding: 6px 10px;
margin-bottom: 6px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.sr-excerpt mark {
background: rgba(139,92,246,0.25);
color: var(--accent);
border-radius: 2px;
padding: 0 1px;
}
.sr-empty, .sr-error { color: var(--muted); padding: 8px 0; }
.fg-header {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 7px 10px 5px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.fg-header::before {
content: '▾';
font-size: 0.7rem;
transition: transform 0.15s;
}
.fg-header.collapsed::before { transform: rotate(-90deg); }
.fg-header.collapsed + .fg-items { display: none; }
.fg-items { display: flex; flex-direction: column; }
.file-item {
padding: 6px 10px 6px 16px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s;
}
.file-item:hover { background: var(--surface); }
.file-item.active {
background: var(--inara-bg);
border-left-color: var(--accent);
}
.file-item.missing { opacity: 0.45; }
.fi-name {
font-size: 0.8rem;
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item.active .fi-name { color: var(--accent); }
.fi-meta {
display: flex;
gap: 0.5rem;
margin-top: 2px;
font-size: 0.68rem;
color: var(--muted);
}
#file-saved-msg.show { opacity: 1; }
#file-modal-body {
flex: 1;
@@ -935,9 +1142,14 @@
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.ctx-btn:hover { color: var(--text); border-color: var(--muted); }
.ctx-btn.active { color: var(--accent); border-color: var(--accent); }
.ctx-btn.mem-on { color: var(--success); border-color: var(--success-dim); }
.ctx-btn:hover { color: var(--text); border-color: var(--muted); }
.ctx-btn.active { color: var(--accent); border-color: var(--accent); }
.ctx-btn.mem-on { color: var(--success); border-color: var(--success-dim); }
.ctx-btn.local-on { color: #f59e0b; border-color: #92400e; }
#backend-model-hint {
font-size: 0.68rem; color: #f59e0b; opacity: 0.8;
margin-top: 4px; word-break: break-all; line-height: 1.3;
}
#ctx-distill-status {
margin-top: 6px;
@@ -1173,6 +1385,48 @@
#auth-banner-close:hover { opacity: 1; }
/* ── Toasts ──────────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 0.45rem 0.85rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
color: #fff;
background: #334155;
border: 1px solid #475569;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: none;
white-space: nowrap;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #14532d; border-color: #16a34a; }
.toast.error { background: #7f1d1d; border-color: #dc2626; }
/* Sessions backdrop — hidden by default, visible only as mobile drawer overlay */
#sessions-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 98;
animation: backdrop-in 0.2s ease;
}
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
/* ── Mobile responsive ───────────────────────────────────── */
@media (max-width: 520px) {
header { padding: 8px 12px; gap: 8px; }
@@ -1233,6 +1487,36 @@
/* Larger touch targets */
#send, #stop { padding: 12px 14px; font-size: 1rem; }
/* File modal: sidebar collapses to a narrow strip */
#file-modal-inner { width: 100vw; height: 100dvh; border-radius: 0; }
#file-sidebar-wrap { width: 130px; }
.fi-meta { display: none; }
/* Sessions backdrop active on mobile */
#sessions-backdrop.open { display: block; }
/* Sessions panel → full-height drawer sliding in from the right */
#sessions-panel {
display: block !important; /* keep rendered so transition works */
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(300px, 85vw);
max-height: none;
height: 100%;
border-radius: 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
transform: translateX(110%);
transition: transform 0.25s ease;
z-index: 99;
overflow-y: auto;
}
#sessions-panel.open { transform: translateX(0); }
}
/* ── Touch devices — no hover capability ─────────────────── */