atlas-os/web/atlas-command.html
Chaib Aarab 301cc75de9 init: Atlas OS — open-source patterns + tools for solo founder LLM cluster
3 browser-native chat surfaces (atlas-chat / atlas-hud / atlas-command, no build step)
+ 11 Python automation scripts (cluster, tray, finance, reminders, autonomy loops)
+ 2 patterns documented (cluster-then-polish, anti-impulse R1-R10)
MIT licensed, anonymized from production stack.
2026-05-26 16:21:16 +02:00

984 lines
46 KiB
HTML

<!doctype html>
<html lang="nl" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#000814">
<title>Atlas Command — unified cockpit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b1220'/%3E%3Cpath d='M8 22L16 8L24 22M12 18H20' stroke='%233B82F6' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
:root {
--bg: #050b16;
--panel: rgba(11,22,40,.8);
--panel-2: #0e1a30;
--border: rgba(59,130,246,.25);
--text: #cce7ff;
--text-dim: #6896c4;
--accent: #3B82F6;
--cyan: #00d4ff;
--gold: #ffb020;
--green: #00ff88;
--err: #ff4060;
--user: #2dd4bf;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: var(--bg);
background-image:
radial-gradient(circle at 10% 20%, rgba(59,130,246,.12) 0%, transparent 50%),
radial-gradient(circle at 90% 80%, rgba(0,212,255,.08) 0%, transparent 50%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
}
/* ===== LAYOUT ===== */
.app {
position: fixed; inset: 0;
display: grid;
grid-template-rows: 50px 1fr 30px;
grid-template-columns: 280px 1fr 380px;
grid-template-areas:
"header header header"
"left center right"
"footer footer footer";
gap: 8px;
padding: 8px;
}
/* ===== HEADER ===== */
header {
grid-area: header;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 16px;
display: flex; align-items: center; gap: 20px;
backdrop-filter: blur(8px);
}
.brand {
font-weight: 700; font-size: 15px; letter-spacing: 3px;
color: var(--cyan); text-shadow: 0 0 10px rgba(0,212,255,.4);
}
.brand-sub { font-size: 11px; color: var(--text-dim); margin-left: 6px; }
.live-brief {
flex: 1; font-size: 12px; color: var(--text);
font-family: 'Share Tech Mono', ui-monospace, monospace;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.live-brief .label { color: var(--cyan); margin-right: 6px; }
.live-brief .err { color: var(--err); }
.live-brief .ok { color: var(--green); }
.btn {
background: var(--panel-2); border: 1px solid var(--border);
color: var(--text); padding: 5px 10px; border-radius: 3px;
font-size: 12px; cursor: pointer; font-family: inherit;
transition: all .15s;
}
.btn:hover { border-color: var(--cyan); color: var(--cyan); }
.btn.active { background: var(--accent); color: white; border-color: var(--accent); }
.btn.voice { background: linear-gradient(135deg, var(--accent), var(--cyan)); border: none; color: white; padding: 5px 14px; }
.btn.voice.listening { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(0,212,255,.6); } 50% { box-shadow: 0 0 0 8px rgba(0,212,255,0); } }
/* ===== LEFT PANEL — Tiles ===== */
.left {
grid-area: left;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
overflow-y: auto;
}
.section-title {
font-size: 10px; color: var(--cyan); letter-spacing: 2px;
text-transform: uppercase; margin: 14px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid var(--border);
}
.section-title:first-child { margin-top: 0; }
.tile-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; margin: 2px 0;
border-radius: 4px; cursor: pointer;
text-decoration: none; color: var(--text);
font-size: 13px;
border: 1px solid transparent;
transition: all .15s;
}
.tile-row:hover { background: var(--panel-2); border-color: var(--border); }
.tile-row .icon { font-size: 16px; width: 20px; text-align: center; }
.tile-row .name { flex: 1; }
.tile-row .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
.tile-row .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.tile-row .dot.err { background: var(--err); }
.tile-row.hot { border-color: var(--accent); background: rgba(59,130,246,.08); }
/* ===== CENTER — Chat + viewer ===== */
.center {
grid-area: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
display: flex; flex-direction: column;
overflow: hidden;
}
.tabbar {
display: flex; gap: 2px; border-bottom: 1px solid var(--border);
padding: 0 8px;
}
.tab {
padding: 8px 16px; cursor: pointer; font-size: 12px;
color: var(--text-dim); border-bottom: 2px solid transparent;
transition: all .15s;
}
.tab.active { color: var(--cyan); border-color: var(--cyan); }
.tab:hover { color: var(--text); }
.view { flex: 1; overflow: hidden; position: relative; }
.view > * { display: none; height: 100%; }
.view > .active { display: block; }
.view iframe { width: 100%; height: 100%; border: none; }
/* Chat panel */
#chat-view {
display: flex; flex-direction: column;
padding: 12px;
}
#chat-msgs {
flex: 1; overflow-y: auto; padding-right: 8px;
}
.msg {
margin-bottom: 12px; padding: 10px 14px;
border-radius: 8px; font-size: 13.5px; line-height: 1.55;
}
.msg.user { background: rgba(0,212,255,.08); border: 1px solid rgba(0,212,255,.3); }
.msg.assistant { background: rgba(59,130,246,.05); border: 1px solid var(--border); }
.msg .role { font-size: 10px; color: var(--cyan); letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; }
.msg.assistant .role { color: var(--accent); }
.msg .content p { margin: 0 0 6px; }
.msg .content code { background: #0a1322; padding: 1px 6px; border-radius: 3px; font-family: ui-monospace; font-size: 12.5px; color: #93c5fd; }
.msg .content pre { background: #0a1322; padding: 10px; border-radius: 6px; overflow-x: auto; border: 1px solid var(--border); margin: 8px 0; }
.msg .content table { border-collapse: collapse; font-size: 12.5px; margin: 6px 0; }
.msg .content th, .msg .content td { border: 1px solid var(--border); padding: 4px 8px; }
.input-row {
display: flex; gap: 8px; margin-top: 10px; align-items: flex-end;
}
#chat-input {
flex: 1; background: rgba(0,212,255,.06);
border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-family: inherit; font-size: 14px;
padding: 8px 12px; outline: none; resize: none; min-height: 38px; max-height: 140px;
}
#chat-input:focus { border-color: var(--cyan); }
/* Quick Brief panel */
#brief-view {
padding: 20px; overflow-y: auto;
}
.brief-card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
margin-bottom: 14px;
}
.brief-card h3 {
font-size: 11px; color: var(--cyan); letter-spacing: 2px;
text-transform: uppercase; margin-bottom: 10px;
}
.brief-card .row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.brief-card .row .lbl { color: var(--text-dim); }
.brief-card .row .val { color: var(--text); font-weight: 500; font-family: 'Share Tech Mono', ui-monospace, monospace; }
.brief-card .val.err { color: var(--err); }
.brief-card .val.ok { color: var(--green); }
.brief-card .val.warn { color: var(--gold); }
/* ===== RIGHT PANEL — context / live ===== */
.right {
grid-area: right;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
overflow-y: auto;
}
.event {
font-family: ui-monospace, 'Cascadia Code', monospace;
font-size: 11px; padding: 5px 0;
border-bottom: 1px dashed rgba(59,130,246,.12);
color: var(--text);
}
.event .t { color: var(--text-dim); margin-right: 6px; }
.event.ok { color: var(--green); }
.event.warn { color: var(--gold); }
.event.err { color: var(--err); }
/* ===== FOOTER ===== */
footer {
grid-area: footer;
font-family: ui-monospace, monospace; font-size: 10px;
color: var(--text-dim);
display: flex; align-items: center; padding: 0 16px;
gap: 14px;
}
footer .pill {
display: flex; align-items: center; gap: 4px;
}
footer .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
footer .pill .dot.ok { background: var(--green); }
footer .pill .dot.err { background: var(--err); }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* mobile */
@media (max-width: 1100px) {
.app {
grid-template-rows: 50px 1fr 30px;
grid-template-columns: 1fr;
grid-template-areas: "header" "center" "footer";
}
.left, .right { display: none; }
}
</style>
</head>
<body>
<div class="app">
<!-- HEADER -->
<header>
<div>
<span class="brand">ATLAS COMMAND</span>
<span class="brand-sub">unified cockpit</span>
</div>
<div class="live-brief" id="brief">
<span class="label">▸ LIVE</span><span id="brief-text">laden…</span>
</div>
<select id="model-picker" class="btn" style="min-width:160px;font-family:ui-monospace,monospace;font-size:11px" title="Model + node"></select>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-dim);cursor:pointer" title="Atlas Vault RAG">
<input type="checkbox" id="rag-toggle" style="cursor:pointer"> RAG
</label>
<button class="btn voice" id="voice-btn" onclick="toggleVoice()" title="Wake: 'Atlas, ...' (Ctrl+Space)">🎤 ATLAS</button>
<button class="btn" onclick="clearHistory()" title="Clear conversation (Ctrl+Shift+Del)"></button>
<button class="btn" onclick="openPortal()" title="Original portal">PORTAL</button>
<button class="btn" onclick="toggleFullscreen()" title="Fullscreen"></button>
</header>
<!-- LEFT — tiles -->
<aside class="left">
<div class="section-title">▸ DAILY</div>
<a class="tile-row" href="http://atlas-01/portal.html" target="_blank"><span class="icon">🏠</span><span class="name">Portal (old)</span></a>
<a class="tile-row hot" href="javascript:openInline('atlas-chat.html')"><span class="icon"></span><span class="name">Atlas Chat</span><span class="dot ok"></span></a>
<a class="tile-row hot" href="javascript:openInline('atlas-hud.html')"><span class="icon">🎯</span><span class="name">Atlas HUD</span><span class="dot ok"></span></a>
<div class="section-title">▸ MESH</div>
<a class="tile-row" href="http://mesh.your-domain.tld" target="_blank"><span class="icon">🕸️</span><span class="name">MeshCentral</span></a>
<a class="tile-row" href="http://cloud.your-domain.tld" target="_blank"><span class="icon">☁️</span><span class="name">Nextcloud</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8069" target="_blank"><span class="icon">📊</span><span class="name">Odoo</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8222" target="_blank"><span class="icon">🔐</span><span class="name">Vaultwarden</span></a>
<div class="section-title">▸ AI</div>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8082" target="_blank"><span class="icon">💬</span><span class="name">Open WebUI</span></a>
<a class="tile-row" href="http://<YOUR_VPS_IP>:8899" target="_blank"><span class="icon">🤖</span><span class="name">Agent TARS</span></a>
<a class="tile-row" href="/command/mindmap.html" target="_blank"><span class="icon">🧠</span><span class="name">Mindmap</span></a>
<div class="section-title">▸ INTERNALS</div>
<a class="tile-row" href="/feeds/today.json" target="_blank"><span class="icon">📡</span><span class="name">Today feed</span></a>
<a class="tile-row" href="/priorities.json" target="_blank"><span class="icon">💰</span><span class="name">Priorities</span></a>
<a class="tile-row" href="/sprints/" target="_blank"><span class="icon">🚀</span><span class="name">Sprints</span></a>
<a class="tile-row" href="/atlas/" target="_blank"><span class="icon">🛰️</span><span class="name">Atlas dashboard</span></a>
</aside>
<!-- CENTER — chat / hud / brief tabs -->
<main class="center">
<div class="tabbar">
<div class="tab active" onclick="switchTab('chat')">CHAT</div>
<div class="tab" onclick="switchTab('brief')">BRIEF</div>
<div class="tab" onclick="switchTab('buy')">BUY?</div>
<div class="tab" onclick="switchTab('autonomy')">🌙 AUTONOMY</div>
<div class="tab" onclick="switchTab('hud')">HUD</div>
<div class="tab" onclick="switchTab('portal')">PORTAL</div>
</div>
<div class="view">
<div id="chat-view" class="active">
<div id="chat-msgs">
<div class="msg assistant">
<div class="role">ATLAS</div>
<div class="content">
<p>Welkom in de unified cockpit. Type een vraag, klik <strong>🎤 ATLAS</strong> in de top bar voor voice, of switch naar BRIEF voor live cijfers.</p>
<p style="color:var(--text-dim);font-size:12px;margin-top:8px">Sneltoetsen: <code>Ctrl+1/2/3/4</code> tabs · <code>Ctrl+Space</code> voice · <code>Ctrl+K</code> focus input</p>
</div>
</div>
</div>
<div class="input-row">
<textarea id="chat-input" placeholder="Vraag iets aan Atlas... (Enter = verstuur, Shift+Enter = nieuwe regel)" rows="1"></textarea>
<button class="btn" onclick="sendChat()">SEND</button>
</div>
</div>
<div id="brief-view"></div>
<div id="buy-view" style="padding:20px;overflow-y:auto">
<h2 style="color:var(--cyan);font-size:18px;margin-bottom:6px">🛒 Pre-buy ROI calculator (Rule R1-R3)</h2>
<p style="color:var(--text-dim);font-size:12px;margin-bottom:18px">Vul in vóór je een hardware-lot bestelt. Krijg een direct GO/NO-GO advies op basis van strategic_plan rules.</p>
<div class="brief-card">
<h3>📦 Inputs</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<label>Lot prijs (€)<br><input id="buy-price" type="number" value="800" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Aantal units<br><input id="buy-units" type="number" value="10" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Categorie<br>
<select id="buy-cat" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px">
<option value="laptop_business">Laptop business (HP/Dell/Lenovo)</option>
<option value="laptop_surface">Surface laptops</option>
<option value="monitor">Monitor 22-27"</option>
<option value="dock">Docking station</option>
<option value="peripheral">Muis / keyboard / accessoire</option>
<option value="pos">POS / kassasysteem</option>
</select>
</label>
<label>Verkoopprijs/unit (€)<br><input id="buy-sell" type="number" value="250" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Huidige voorraad (units)<br><input id="buy-stock" type="number" value="5" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
<label>Pre-sales coverage (%)<br><input id="buy-presales" type="number" value="0" min="0" max="100" style="width:100%;padding:5px;background:rgba(0,212,255,.08);border:1px solid var(--border);color:var(--text);border-radius:3px"></label>
</div>
<button class="btn" onclick="computeBuy()" style="margin-top:12px;width:100%;padding:8px;background:var(--accent);color:white;border:none">Bereken GO/NO-GO</button>
</div>
<div id="buy-result"></div>
</div>
<div id="autonomy-view" style="padding:16px;overflow-y:auto">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
<h2 style="color:var(--cyan);font-size:18px;margin:0">🌙 Autonomy Loop — live view</h2>
<span id="auto-status" style="font-family:ui-monospace,monospace;font-size:11px;color:var(--text-dim)">loading…</span>
<button class="btn" onclick="refreshAutonomy()" style="margin-left:auto;font-size:11px">🔄 refresh</button>
<label style="font-size:11px;color:var(--text-dim);cursor:pointer"><input type="checkbox" id="auto-autorefresh" checked onchange="toggleAutoRefresh()"> auto 30s</label>
</div>
<div id="autonomy-content"><p style="color:var(--text-dim)">laden…</p></div>
</div>
<div id="hud-view"><iframe src="/atlas-hud.html"></iframe></div>
<div id="portal-view"><iframe src="/portal.html"></iframe></div>
</div>
</main>
<!-- RIGHT — context / events -->
<aside class="right">
<div class="section-title">▸ CLUSTER</div>
<div id="cluster-status">
<div class="event"><span class="t">--:--</span>checking…</div>
</div>
<div class="section-title">▸ TODAY</div>
<div id="today-events">
<div class="event"><span class="t">--:--</span>laden…</div>
</div>
<div class="section-title">▸ EVENT STREAM</div>
<div id="event-stream">
<div class="event"><span class="t">--:--</span>Cockpit initialized</div>
</div>
</aside>
<!-- FOOTER -->
<footer>
<span class="pill"><span class="dot ok" id="dot-atlas01"></span>atlas-01</span>
<span class="pill"><span class="dot" id="dot-pc"></span>pc</span>
<span class="pill"><span class="dot" id="dot-met"></span>met</span>
<span class="pill"><span class="dot" id="dot-rag"></span>vault rag</span>
<span style="margin-left:auto">Atlas Command v0.1 · <span id="clock">--:--</span> · <kbd>Ctrl+Shift+P</kbd> recall</span>
</footer>
</div>
<script>
const STATE = {
currentTab: 'chat',
conversation: [],
recognition: null,
listening: false,
currentNode: 'atlas-01',
currentModel: 'qwen2.5:7b',
nodes: {
'atlas-01': { url: location.origin + '/ollama', models: [] },
'pc': { url: location.origin + '/ollama/pc', models: [] },
'met': { url: location.origin + '/ollama/met', models: [] },
},
};
const CFG = {
ollamaURL: location.origin + '/ollama',
metURL: location.origin + '/ollama/met',
pcURL: location.origin + '/ollama/pc',
wakeWord: 'atlas',
vaultKbId: '<YOUR_KB_ID>',
openWebUI: 'http://<YOUR_VPS_IP>:8082',
systemPrompt: 'Je bent Atlas — Chaib\'s assistent in de unified cockpit. Antwoord direct, NL of EN volgens input, max 3 zinnen tenzij detail gevraagd. Gegrond, geen fluff.',
};
const STORAGE_KEY = 'atlas-command-v1';
const SETTINGS_KEY = 'atlas-chat-settings'; // share JWT with atlas-chat
// ===== TABS =====
function switchTab(name) {
STATE.currentTab = name;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view > div').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.tab')[['chat','brief','buy','autonomy','hud','portal'].indexOf(name)].classList.add('active');
document.getElementById(name+'-view').classList.add('active');
if (name === 'brief') refreshBrief();
if (name === 'autonomy') refreshAutonomy();
}
// ===== AUTONOMY TAB =====
let autonomyAutoRefreshTimer = null;
async function refreshAutonomy() {
const el = document.getElementById('autonomy-content');
const statusEl = document.getElementById('auto-status');
try {
const r = await fetch('/atlas-autonomy.json?t=' + Date.now());
const d = await r.json();
const loop = d.loop_running ? '<span style="color:var(--green)">● running</span>' : '<span style="color:var(--err)">● stopped</span>';
statusEl.innerHTML = `${loop} · pending: ${d.queue_pending} · completed: ${d.queue_completed} · outputs: ${d.recent_output_count} · next fire: ${d.next_fire || '?'}`;
let html = '';
// Last completed
if (d.last_completed) {
const lc = d.last_completed;
html += `<div class="brief-card" style="border-color:rgba(0,212,255,.3)">
<h3>Last completed task</h3>
<div class="row"><span class="lbl">topic</span><span class="val ok">${lc.topic}</span></div>
<div class="row"><span class="lbl">node/model</span><span class="val">${lc.node}/${lc.model}</span></div>
<div class="row"><span class="lbl">elapsed</span><span class="val">${lc.elapsed_s}s</span></div>
<div class="row"><span class="lbl">when</span><span class="val">${lc.completed_at}</span></div>
</div>`;
}
// Recent outputs preview
if (d.recent_outputs && d.recent_outputs.length) {
html += `<div class="brief-card"><h3>📝 Recent outputs (${d.recent_outputs.length})</h3>`;
for (const o of d.recent_outputs) {
const ts = o.mtime.replace('T', ' ').slice(11, 19);
html += `<div style="border-bottom:1px dashed rgba(59,130,246,.15);padding:8px 0;font-size:12px">
<div style="display:flex;gap:8px;align-items:center">
<span style="color:var(--cyan);font-family:ui-monospace,monospace;font-size:11px">${ts}</span>
<span style="color:var(--accent);font-weight:600">${o.topic}</span>
<span style="color:var(--text-dim);font-size:10px">${o.node}/${o.model} · ${o.elapsed_s}s</span>
<span style="color:var(--gold);font-size:10px;margin-left:auto">${o.polish_status}</span>
</div>
<div style="color:var(--text);margin-top:4px;line-height:1.4">${DOMPurify.sanitize(o.body_preview.slice(0, 280))}${o.body_preview.length > 280 ? '…' : ''}</div>
</div>`;
}
html += '</div>';
} else {
html += '<div class="brief-card"><p style="color:var(--text-dim)">No outputs yet — wachten op eerste fire</p></div>';
}
// Log tail
if (d.log_tail && d.log_tail.length) {
html += `<div class="brief-card"><h3>📋 Loop log (last 15)</h3>
<pre style="font-family:ui-monospace,monospace;font-size:11px;color:var(--text-dim);overflow-x:auto;white-space:pre-wrap;line-height:1.4">${DOMPurify.sanitize(d.log_tail.slice(-15).join('\n'))}</pre>
</div>`;
}
el.innerHTML = html;
} catch (e) {
statusEl.textContent = 'error';
el.innerHTML = `<p style="color:var(--err)">Fout: ${e.message}. Endpoint /atlas-autonomy.json reachable?</p>`;
}
}
function toggleAutoRefresh() {
const on = document.getElementById('auto-autorefresh').checked;
if (autonomyAutoRefreshTimer) { clearInterval(autonomyAutoRefreshTimer); autonomyAutoRefreshTimer = null; }
if (on) {
autonomyAutoRefreshTimer = setInterval(() => {
if (STATE.currentTab === 'autonomy') refreshAutonomy();
}, 30000);
}
}
// ===== BUY? CALCULATOR (R1-R3) =====
// Category sell-through baselines (median days on shelf, derived from operator's history)
const CAT_BASELINES = {
laptop_business: { days_on_shelf: 35, margin_pct: 40 },
laptop_surface: { days_on_shelf: 50, margin_pct: 45 },
monitor: { days_on_shelf: 60, margin_pct: 30 },
dock: { days_on_shelf: 75, margin_pct: 35 },
peripheral: { days_on_shelf: 90, margin_pct: 25 },
pos: { days_on_shelf: 45, margin_pct: 50 },
};
function computeBuy() {
const price = parseFloat(document.getElementById('buy-price').value) || 0;
const units = parseInt(document.getElementById('buy-units').value) || 0;
const cat = document.getElementById('buy-cat').value;
const sell = parseFloat(document.getElementById('buy-sell').value) || 0;
const stock = parseInt(document.getElementById('buy-stock').value) || 0;
const presalesPct = parseFloat(document.getElementById('buy-presales').value) || 0;
if (units < 1 || price < 1) {
document.getElementById('buy-result').innerHTML = '<div class="brief-card"><h3 style="color:var(--err)">⚠️ Vul prijs + aantal in</h3></div>';
return;
}
const base = CAT_BASELINES[cat] || CAT_BASELINES.peripheral;
const cost_per_unit = price / units;
const total_revenue = sell * units;
const total_margin = total_revenue - price;
const roi_pct = price > 0 ? (total_margin / price) * 100 : 0;
// Holding cost: 3%/mo capital + €2/unit/mo storage estimate
const sell_through_days = base.days_on_shelf;
const months_to_clear = sell_through_days / 30;
const capital_hold = price * 0.03 * months_to_clear;
const storage_hold = units * 2 * months_to_clear;
const total_holding = capital_hold + storage_hold;
const net_margin = total_margin - total_holding;
const net_roi = price > 0 ? (net_margin / price) * 100 : 0;
// Rule checks
const rules = [];
if (stock > 8) rules.push({ pass: false, text: `R1a FAIL: huidige voorraad ${stock} > 8 units. Verkoop eerst.` });
else rules.push({ pass: true, text: `R1a OK: huidige voorraad ${stock} ≤ 8 units` });
if (presalesPct < 50) rules.push({ pass: false, text: `R1b WARN: pre-sales ${presalesPct}% < 50%. Risico.` });
else rules.push({ pass: true, text: `R1b OK: pre-sales coverage ${presalesPct}%` });
if (sell_through_days > 90) rules.push({ pass: false, text: `R1c FAIL: sell-through ${sell_through_days}d > 90d (categorie-baseline).` });
else rules.push({ pass: true, text: `R1c OK: sell-through ${sell_through_days}d ≤ 90d` });
if (net_roi < 30) rules.push({ pass: false, text: `R2 FAIL: net ROI ${net_roi.toFixed(1)}% < 30% minimum.` });
else rules.push({ pass: true, text: `R2 OK: net ROI ${net_roi.toFixed(1)}%` });
const allPass = rules.every(r => r.pass);
const verdict = allPass ? 'GO' : 'NO-GO';
const verdictColor = allPass ? 'var(--green)' : 'var(--err)';
document.getElementById('buy-result').innerHTML = `
<div class="brief-card" style="border:2px solid ${verdictColor}">
<h3 style="color:${verdictColor};font-size:24px;letter-spacing:4px">${verdict}</h3>
<div class="row"><span class="lbl">Cost per unit</span><span class="val">€${cost_per_unit.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Total revenue (potential)</span><span class="val">€${total_revenue.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Gross margin</span><span class="val">€${total_margin.toFixed(2)} (${roi_pct.toFixed(1)}%)</span></div>
<div class="row"><span class="lbl">Holding cost (est, ${months_to_clear.toFixed(1)} mnd)</span><span class="val">€${total_holding.toFixed(2)}</span></div>
<div class="row"><span class="lbl">Net margin after holding</span><span class="val ${net_margin > 0 ? 'ok' : 'err'}">€${net_margin.toFixed(2)} (${net_roi.toFixed(1)}%)</span></div>
<div class="row"><span class="lbl">Sell-through baseline</span><span class="val">${sell_through_days}d</span></div>
</div>
<div class="brief-card">
<h3>📋 Rule checks (per strategic_plan R1-R3)</h3>
${rules.map(r => `<div class="row"><span class="lbl">${r.pass ? '✅' : '❌'}</span><span class="val ${r.pass ? 'ok' : 'err'}">${r.text}</span></div>`).join('')}
${allPass ? '' : '<div style="margin-top:10px;padding:8px;background:rgba(255,64,96,.1);border:1px solid rgba(255,64,96,.3);border-radius:4px;font-size:12px;color:var(--err)"><strong>NIET KOPEN.</strong> Address de fails hierboven of wacht tot omstandigheden anders zijn.</div>'}
</div>
`;
}
// ===== LIVE BRIEF (header ticker) =====
async function refreshHeaderBrief() {
try {
const r = await fetch('/priorities.json', { cache: 'no-store' });
const d = await r.json();
const eur = d.odoo?.overdue_eur ?? 0;
const cnt = d.odoo?.overdue_count ?? 0;
const disk = d.disk_free_gb ?? '?';
const down = d.containers_down ?? 0;
const klass = (cnt > 0 || down > 0) ? 'err' : 'ok';
document.getElementById('brief-text').innerHTML = `<span class="${klass}">AR €${eur}</span> · ${cnt} open · disk ${disk}GB free · ${down} containers down · ${new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit'})}`;
} catch {
document.getElementById('brief-text').innerHTML = '<span class="err">live feed offline</span>';
}
}
async function refreshBrief() {
const el = document.getElementById('brief-view');
el.innerHTML = '<p style="color:var(--text-dim)">laden…</p>';
try {
const [pri, today, fin, strat, act] = await Promise.all([
fetch('/priorities.json').then(r=>r.json()).catch(()=>null),
fetch('/today.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-finance.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-strategy.json').then(r=>r.json()).catch(()=>null),
fetch('/atlas-activity.json').then(r=>r.json()).catch(()=>null),
]);
let html = '';
// 🌤️ SEASON-AWARE STRATEGY card (first, sets context)
if (strat) {
const cw = strat.current_window;
const btw = strat.btw_aangifte;
html += `<div class="brief-card" style="border-color:rgba(0,212,255,.5)">
<h3>🌤️ Strategy NOW — ${cw ? cw.name : 'open'} ${cw ? `(${cw.days_left}d left)` : ''}</h3>
${cw ? `
<div class="row"><span class="lbl">Signal</span><span class="val" style="font-size:12px">${cw.signal}</span></div>
<div class="row"><span class="lbl">▶ Primary push</span><span class="val ok">${strat.primary_recommendation}</span></div>
<div class="row"><span class="lbl">Framing</span><span class="val" style="font-size:11px;font-style:italic">${strat.primary_framing}</span></div>
<div class="row"><span class="lbl">Channels today</span><span class="val">${(strat.channels_today||[]).join(' · ') || 'none'}</span></div>
${cw.push?.length > 1 ? `<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Also: ${cw.push.slice(1).join(' · ')}</div>` : ''}
${(strat.avoid_today||[]).length ? `<div style="font-size:11px;color:var(--err);margin-top:6px">❌ Avoid: ${strat.avoid_today.join(' · ')}</div>` : ''}
` : '<div style="color:var(--text-dim)">Default plan — no specific window active</div>'}
<div class="row" style="margin-top:8px;border-top:1px solid var(--border);padding-top:6px;font-size:11px"><span class="lbl">Upcoming</span><span class="val">${(strat.upcoming_windows||[]).slice(0,2).map(u => u.name + ' (' + u.days_until + 'd)').join(' · ')}</span></div>
<div class="row" style="font-size:11px"><span class="lbl">BTW ${btw.quarter}</span><span class="val ${btw.days_until < 14 ? 'err' : btw.days_until < 30 ? 'warn' : 'ok'}">${btw.deadline} (${btw.days_until}d)</span></div>
</div>`;
}
// ⏱️ R10 / Activity card
if (act) {
const r10cls = act.r10_pass ? 'ok' : 'err';
html += `<div class="brief-card">
<h3>⏱️ Time today (R10 check)</h3>
<div class="row"><span class="lbl">Active hours</span><span class="val">${act.active_total_hours}h</span></div>
<div class="row"><span class="lbl">Sales</span><span class="val ok">${act.r10_sales_hours}h (${(act.by_category_pct?.sales || 0)}%)</span></div>
<div class="row"><span class="lbl">Delivery</span><span class="val">${act.by_category_pct?.delivery || 0}%</span></div>
<div class="row"><span class="lbl">Infra</span><span class="val ${act.r10_infra_hours > act.r10_sales_hours ? 'err' : ''}">${act.r10_infra_hours}h (${act.by_category_pct?.infra || 0}%)</span></div>
<div class="row"><span class="lbl">Admin / Learning / Other</span><span class="val">${act.by_category_pct?.admin || 0}% / ${act.by_category_pct?.learning || 0}% / ${act.by_category_pct?.other || 0}%</span></div>
<div class="row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px"><span class="lbl">R10 verdict</span><span class="val ${r10cls}">${act.r10_pass ? '✅ PASS (sales ≥ infra)' : '❌ FAIL (infra > sales)'}</span></div>
</div>`;
}
// FINANCE card (Inkomsten xlsx → bank-verified YTD)
if (fin) {
const concCls = fin.concentration_top_pct > 40 ? 'err' : fin.concentration_top_pct > 25 ? 'warn' : 'ok';
const mrrCls = fin.mrr_estimate_eur >= 1000 ? 'ok' : fin.mrr_estimate_eur >= 500 ? 'warn' : 'err';
html += `<div class="brief-card"><h3>📈 Finance (Inkomsten xlsx)</h3>
<div class="row"><span class="lbl">YTD revenue</span><span class="val ok">€${fin.ytd_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Avg per month</span><span class="val">€${fin.avg_per_month_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Current month</span><span class="val ${fin.current_month_eur < 500 ? 'err' : 'ok'}">€${fin.current_month_eur.toLocaleString('nl-NL')}</span></div>
<div class="row"><span class="lbl">Unique clients YTD</span><span class="val">${fin.unique_clients_ytd}</span></div>
<div class="row"><span class="lbl">MRR estimate</span><span class="val ${mrrCls}">€${fin.mrr_estimate_eur.toLocaleString('nl-NL')}/mo</span></div>
<div class="row"><span class="lbl">Concentration (top client)</span><span class="val ${concCls}">${fin.concentration_top_pct}%</span></div>
${fin.top_5_clients?.[0] ? `<div class="row"><span class="lbl">Top client</span><span class="val">${fin.top_5_clients[0].name}</span></div>` : ''}
<div class="row" style="margin-top:6px;border-top:1px solid var(--border);padding-top:6px;font-size:11px;color:var(--text-dim)"><span>As of: ${fin.as_of}</span></div>
</div>`;
}
if (pri) {
const o = pri.odoo || {};
html += `<div class="brief-card"><h3>💰 AR (Odoo)</h3>
<div class="row"><span class="lbl">Open AR</span><span class="val ${o.overdue_eur>0?'err':'ok'}">€${o.overdue_eur ?? 0}</span></div>
<div class="row"><span class="lbl">Overdue count</span><span class="val">${o.overdue_count ?? 0}</span></div>
${o.oldest_inv ? `<div class="row"><span class="lbl">Oldest</span><span class="val">${o.oldest_inv} · ${o.oldest_partner ?? '?'} · ${o.oldest_age ?? 0}d</span></div>` : ''}
</div>`;
html += `<div class="brief-card"><h3>🔧 Infrastructure</h3>
<div class="row"><span class="lbl">atlas-01 disk free</span><span class="val ${pri.disk_free_gb<10?'err':pri.disk_free_gb<30?'warn':'ok'}">${pri.disk_free_gb ?? '?'}GB</span></div>
<div class="row"><span class="lbl">Containers down</span><span class="val ${pri.containers_down>0?'err':'ok'}">${pri.containers_down ?? 0}</span></div>
<div class="row"><span class="lbl">Generated at</span><span class="val">${pri.generated_at?.replace('T',' ').slice(0,16) ?? '?'}</span></div>
</div>`;
if (pri.attention?.top?.length) {
html += `<div class="brief-card"><h3>🎯 Attention top-${pri.attention.top.length}</h3>` +
pri.attention.top.map(t => `<div class="row"><span class="lbl">${t.kind}</span><span class="val">${t.label}</span></div>`).join('') +
`</div>`;
}
}
if (today) {
const evs = (today.events || today.calendar?.events || []).slice(0,5);
if (evs.length) {
html += `<div class="brief-card"><h3>📅 Vandaag (${evs.length})</h3>` +
evs.map(e => `<div class="row"><span class="lbl">${e.start || e.time || ''}</span><span class="val">${e.title || e.summary || '?'}</span></div>`).join('') +
`</div>`;
}
}
el.innerHTML = html || '<p style="color:var(--text-dim)">geen data</p>';
} catch (e) {
el.innerHTML = '<p style="color:var(--err)">Fout: ' + e.message + '</p>';
}
}
// ===== CLUSTER STATUS + MODEL DISCOVERY =====
async function refreshCluster() {
const targets = [
{ id: 'atlas01', key: 'atlas-01' },
{ id: 'pc', key: 'pc' },
{ id: 'met', key: 'met' },
];
const cs = document.getElementById('cluster-status');
const time = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
cs.innerHTML = '';
for (const t of targets) {
const node = STATE.nodes[t.key];
const t0 = performance.now();
let status, msg;
try {
const r = await fetch(node.url + '/api/tags', { signal: AbortSignal.timeout(3000) });
if (r.ok) {
const d = await r.json();
const lat = Math.round(performance.now() - t0);
node.models = (d.models || []).map(m => m.name);
msg = `${t.key} · ${node.models.length} models · ${lat}ms`;
status = 'ok';
document.getElementById('dot-'+t.id).className = 'dot ok';
} else throw 0;
} catch {
msg = `${t.key} · offline`;
status = 'err';
node.models = [];
document.getElementById('dot-'+t.id).className = 'dot err';
}
cs.innerHTML += `<div class="event ${status}"><span class="t">${time}</span>${msg}</div>`;
}
populateModelPicker();
}
function populateModelPicker() {
const sel = document.getElementById('model-picker');
const prev = sel.value;
sel.innerHTML = '';
for (const [key, node] of Object.entries(STATE.nodes)) {
if (!node.models.length) continue;
const group = document.createElement('optgroup');
group.label = key;
for (const m of node.models) {
const opt = document.createElement('option');
opt.value = key + '|' + m;
opt.textContent = m;
group.appendChild(opt);
}
sel.appendChild(group);
}
// Restore previous or pick default
if (prev && Array.from(sel.options).some(o => o.value === prev)) {
sel.value = prev;
} else {
const def = Array.from(sel.options).find(o => o.value.includes('qwen2.5:7b'));
if (def) sel.value = def.value;
}
if (sel.value) {
const [node, model] = sel.value.split('|');
STATE.currentNode = node; STATE.currentModel = model;
}
sel.onchange = () => {
const [node, model] = sel.value.split('|');
STATE.currentNode = node; STATE.currentModel = model;
emit('ok', `model switched: ${node}/${model}`);
};
}
// Baked Vault JWT (operator's own infra, tailnet-only — expires 2026-06-23)
const BAKED_JWT = '<YOUR_OPENWEBUI_JWT_HERE>';
// ===== VAULT RAG =====
async function queryVaultRAG(query, k=3) {
let jwt = BAKED_JWT;
try {
const s = localStorage.getItem(SETTINGS_KEY);
if (s) jwt = JSON.parse(s).openwebuiJwt || BAKED_JWT;
} catch {}
if (!jwt) {
emit('warn', 'RAG: no JWT available');
return null;
}
try {
const r = await fetch(CFG.openWebUI + '/api/v1/retrieval/query/collection', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + jwt },
body: JSON.stringify({ collection_names: [CFG.vaultKbId], query, k }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) { emit('err', 'RAG HTTP ' + r.status); return null; }
const d = await r.json();
const docs = (d.documents?.[0] || []).map((doc, i) => {
const meta = d.metadatas?.[0]?.[i] || {};
return `[${meta.source || 'vault'}]\n${doc}`;
});
if (docs.length) emit('ok', `RAG: ${docs.length} chunks (vault)`);
return docs.join('\n\n---\n\n');
} catch (e) {
emit('err', 'RAG fail: ' + e.message);
return null;
}
}
// ===== HISTORY PERSISTENCE =====
function saveConversation() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(STATE.conversation.slice(-50))); } catch {}
}
function loadConversation() {
try {
const s = localStorage.getItem(STORAGE_KEY);
if (s) {
STATE.conversation = JSON.parse(s);
const msgsDiv = document.getElementById('chat-msgs');
// keep welcome msg, append history
for (const m of STATE.conversation) {
if (m.role === 'system') continue;
appendMsg(m.role, m.content);
}
}
} catch {}
}
function clearHistory() {
if (!confirm('Wis gesprek historie?')) return;
STATE.conversation = [];
localStorage.removeItem(STORAGE_KEY);
document.getElementById('chat-msgs').innerHTML = '<div class="msg assistant"><div class="role">ATLAS</div><div class="content"><p>Geschiedenis gewist. Stel een nieuwe vraag.</p></div></div>';
emit('ok', 'history cleared');
}
// ===== TODAY EVENTS =====
async function refreshToday() {
try {
const r = await fetch('/today.json');
const d = await r.json();
const events = (d.events || d.calendar?.events || []).slice(0, 4);
const el = document.getElementById('today-events');
if (!events.length) { el.innerHTML = '<div class="event"><span class="t">--:--</span>geen events</div>'; return; }
el.innerHTML = events.map(e => `<div class="event"><span class="t">${(e.start||e.time||'').slice(0,5)}</span>${(e.title||e.summary||'?').slice(0,40)}</div>`).join('');
} catch { document.getElementById('today-events').innerHTML = '<div class="event err"><span class="t">--:--</span>today.json offline</div>'; }
}
// ===== CHAT =====
async function sendChat(prefill) {
const inp = document.getElementById('chat-input');
const text = prefill || inp.value.trim();
if (!text) return;
inp.value = '';
inp.style.height = 'auto';
appendMsg('user', text);
STATE.conversation.push({ role: 'user', content: text });
// Vault RAG if toggle on
let ragContext = '';
if (document.getElementById('rag-toggle')?.checked) {
const ctx = await queryVaultRAG(text, 3);
if (ctx) ragContext = '\n\n[ATLAS VAULT CONTEXT — gegrond hierop, citeer source]\n' + ctx;
}
const msgs = [
{ role: 'system', content: CFG.systemPrompt + ragContext },
...STATE.conversation.slice(-10).filter(m => m.role !== 'system'),
];
const div = appendMsg('assistant', '');
const contentEl = div.querySelector('.content');
const node = STATE.nodes[STATE.currentNode] || STATE.nodes['atlas-01'];
let full = '';
try {
const r = await fetch(node.url + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: STATE.currentModel, messages: msgs, stream: true }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const j = JSON.parse(line);
if (j.message?.content) {
full += j.message.content;
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(full));
div.scrollIntoView({ block: 'end' });
}
} catch {}
}
}
STATE.conversation.push({ role: 'assistant', content: full });
saveConversation();
emit('ok', `chat: ${full.slice(0,40)}`);
} catch (e) {
contentEl.innerHTML = '<p style="color:var(--err)">Fout: ' + e.message + '</p>';
emit('err', 'chat fail: ' + e.message);
}
}
function appendMsg(role, text) {
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = `<div class="role">${role === 'user' ? 'JIJ' : 'ATLAS'}</div><div class="content">${DOMPurify.sanitize(marked.parse(text || ''))}</div>`;
document.getElementById('chat-msgs').appendChild(div);
div.scrollIntoView({ block: 'end' });
return div;
}
// ===== VOICE (wake = Atlas) =====
function toggleVoice() {
const btn = document.getElementById('voice-btn');
if (STATE.listening) {
STATE.recognition?.stop();
STATE.listening = false;
btn.classList.remove('listening');
emit('ok', 'voice off');
return;
}
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { emit('err', 'no SpeechRecognition in this browser'); return; }
STATE.recognition = new SR();
STATE.recognition.lang = 'nl-NL';
STATE.recognition.continuous = true;
STATE.recognition.interimResults = false;
STATE.recognition.onresult = (e) => {
for (let i = e.resultIndex; i < e.results.length; i++) {
if (!e.results[i].isFinal) continue;
let t = e.results[i][0].transcript.trim();
const low = t.toLowerCase();
const aliases = ['atlas', 'hey atlas', 'hi atlas'];
const hit = aliases.find(a => low.includes(a));
if (hit) {
for (const a of aliases) t = t.replace(new RegExp(a, 'gi'), '');
t = t.replace(/^[,.\s]+|[,.\s]+$/g, '');
if (t.length > 2) {
switchTab('chat');
sendChat(t);
}
}
}
};
STATE.recognition.onerror = (e) => emit('err', 'ASR: ' + e.error);
STATE.recognition.onend = () => { if (STATE.listening) STATE.recognition.start(); };
STATE.recognition.start();
STATE.listening = true;
btn.classList.add('listening');
emit('ok', 'voice on — say "Atlas, ..."');
}
// ===== EVENT STREAM =====
function emit(kind, text) {
const es = document.getElementById('event-stream');
const div = document.createElement('div');
div.className = 'event ' + kind;
div.innerHTML = `<span class="t">${new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit'})}</span>${text}`;
es.appendChild(div);
while (es.children.length > 25) es.firstChild.remove();
es.scrollTop = es.scrollHeight;
}
// ===== UTILS =====
function openPortal() { window.open('/portal.html', '_blank'); }
function openInline(file) { switchTab('hud'); document.querySelector('#hud-view iframe').src = '/' + file; }
function toggleFullscreen() { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }
function tick() { document.getElementById('clock').textContent = new Date().toLocaleTimeString('nl-NL'); }
// ===== KEYBINDINGS =====
document.addEventListener('keydown', (e) => {
if (e.ctrlKey) {
if (e.key === ' ') { e.preventDefault(); toggleVoice(); }
else if (e.key === 'k') { e.preventDefault(); document.getElementById('chat-input').focus(); }
else if (e.key >= '1' && e.key <= '6') {
e.preventDefault();
switchTab(['chat','brief','buy','autonomy','hud','portal'][parseInt(e.key)-1]);
}
}
});
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
// Auto-grow
setTimeout(() => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(140, e.target.scrollHeight) + 'px';
}, 0);
});
// ===== INIT =====
function init() {
refreshHeaderBrief(); setInterval(refreshHeaderBrief, 30000);
refreshCluster(); setInterval(refreshCluster, 30000);
refreshToday(); setInterval(refreshToday, 60000);
tick(); setInterval(tick, 1000);
loadConversation();
// RAG dot — green by default (baked JWT) unless explicitly cleared
document.getElementById('dot-rag').className = 'dot ok';
}
init();
</script>
</body>
</html>