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.
984 lines
46 KiB
HTML
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>
|