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:
307
cortex/static/local_llm.html
Normal file
307
cortex/static/local_llm.html
Normal 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>
|
||||
Reference in New Issue
Block a user