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.
952 lines
34 KiB
HTML
952 lines
34 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,viewport-fit=cover">
|
|
<meta name="theme-color" content="#000814">
|
|
<title>Atlas HUD</title>
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='none' stroke='%233B82F6' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%2300d4ff'/%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: #000814;
|
|
--bg-2: #001528;
|
|
--panel: rgba(8,30,52,.6);
|
|
--border: rgba(59,130,246,.3);
|
|
--text: #cce7ff;
|
|
--text-dim: #6896c4;
|
|
--accent: #3B82F6;
|
|
--cyan: #00d4ff;
|
|
--gold: #ffb020;
|
|
--green: #00ff88;
|
|
--err: #ff4060;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body {
|
|
width: 100%; height: 100%; overflow: hidden;
|
|
background: var(--bg);
|
|
background-image:
|
|
radial-gradient(circle at 20% 30%, rgba(59,130,246,.15) 0%, transparent 50%),
|
|
radial-gradient(circle at 80% 70%, rgba(0,212,255,.1) 0%, transparent 50%);
|
|
color: var(--text);
|
|
font-family: 'Rajdhani', 'Segoe UI', -apple-system, sans-serif;
|
|
letter-spacing: .5px;
|
|
user-select: none;
|
|
}
|
|
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');
|
|
|
|
.hud {
|
|
position: fixed; inset: 0;
|
|
display: grid;
|
|
grid-template-rows: 60px 1fr 200px;
|
|
grid-template-columns: 280px 1fr 280px;
|
|
grid-template-areas:
|
|
"topnav topnav topnav"
|
|
"left center right"
|
|
"trans trans trans";
|
|
gap: 12px;
|
|
padding: 12px;
|
|
}
|
|
|
|
/* ===== TOP NAV ===== */
|
|
.topnav {
|
|
grid-area: topnav;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 0 20px;
|
|
display: flex; align-items: center; gap: 20px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
.topnav::before {
|
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
|
background: var(--accent);
|
|
box-shadow: 0 0 10px var(--accent);
|
|
}
|
|
.brand-text {
|
|
font-weight: 700; font-size: 20px; letter-spacing: 4px;
|
|
color: var(--cyan); text-shadow: 0 0 12px var(--cyan);
|
|
}
|
|
.brand-sub {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 11px; color: var(--text-dim);
|
|
margin-left: 4px;
|
|
}
|
|
.status-bar { display: flex; gap: 16px; margin-left: auto; align-items: center; }
|
|
.status-pill {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 4px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.status-pill .dot {
|
|
width: 6px; height: 6px; border-radius: 50%;
|
|
background: var(--text-dim);
|
|
box-shadow: 0 0 6px var(--text-dim);
|
|
}
|
|
.status-pill.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
.status-pill.warn .dot { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
|
|
.status-pill.err .dot { background: var(--err); box-shadow: 0 0 6px var(--err); }
|
|
.btn-icon {
|
|
background: transparent; border: 1px solid var(--border);
|
|
color: var(--text); font-size: 14px;
|
|
padding: 4px 12px; border-radius: 3px;
|
|
cursor: pointer; font-family: inherit;
|
|
transition: all .15s;
|
|
}
|
|
.btn-icon:hover { background: var(--accent); color: white; border-color: var(--accent); }
|
|
.btn-icon.active { background: var(--cyan); color: var(--bg); border-color: var(--cyan); }
|
|
|
|
/* ===== LEFT PANEL — system status ===== */
|
|
.left {
|
|
grid-area: left;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 16px;
|
|
position: relative;
|
|
overflow-y: auto;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
.panel-title {
|
|
font-size: 11px; color: var(--cyan); letter-spacing: 3px;
|
|
margin-bottom: 12px; font-weight: 600;
|
|
text-transform: uppercase;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 6px;
|
|
}
|
|
.metric {
|
|
display: flex; justify-content: space-between;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 12px; padding: 6px 0;
|
|
color: var(--text);
|
|
}
|
|
.metric-label { color: var(--text-dim); }
|
|
.metric-bar {
|
|
height: 4px; background: rgba(59,130,246,.15);
|
|
border-radius: 2px; margin: 4px 0 8px;
|
|
overflow: hidden; position: relative;
|
|
}
|
|
.metric-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--cyan), var(--accent));
|
|
box-shadow: 0 0 8px var(--cyan);
|
|
transition: width .3s;
|
|
}
|
|
.section { margin-bottom: 18px; }
|
|
.node-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 12px; font-family: 'Share Tech Mono', monospace;
|
|
padding: 4px 0;
|
|
}
|
|
.node-row .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); }
|
|
.node-row .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
.node-row .dot.err { background: var(--err); }
|
|
.node-row .name { flex: 1; }
|
|
.node-row .lat { color: var(--text-dim); font-size: 10px; }
|
|
|
|
/* ===== CENTER — circular HUD + camera ===== */
|
|
.center {
|
|
grid-area: center;
|
|
position: relative;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.hud-ring {
|
|
position: relative;
|
|
width: min(70vh, 60vw, 600px);
|
|
height: min(70vh, 60vw, 600px);
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.ring-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
.ring-svg circle { fill: none; transition: all .4s; }
|
|
.viz-canvas {
|
|
position: absolute;
|
|
inset: 12%;
|
|
border-radius: 50%;
|
|
}
|
|
.video-feed {
|
|
position: absolute; inset: 18%;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
opacity: 0;
|
|
transition: opacity .5s;
|
|
filter: hue-rotate(180deg) brightness(.9) contrast(1.2);
|
|
transform: scaleX(-1);
|
|
}
|
|
.video-feed.active { opacity: .85; }
|
|
.hud-state {
|
|
position: absolute;
|
|
font-size: 14px; letter-spacing: 4px; color: var(--cyan);
|
|
text-transform: uppercase;
|
|
text-shadow: 0 0 10px var(--cyan);
|
|
font-weight: 600;
|
|
pointer-events: none;
|
|
}
|
|
.face-box {
|
|
position: absolute; border: 1px solid var(--cyan);
|
|
box-shadow: 0 0 6px var(--cyan);
|
|
pointer-events: none;
|
|
font-family: 'Share Tech Mono', monospace; font-size: 9px;
|
|
color: var(--cyan);
|
|
padding: 2px 4px;
|
|
}
|
|
.scan-line {
|
|
position: absolute; left: 0; right: 0; height: 2px;
|
|
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
|
box-shadow: 0 0 12px var(--cyan);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.center.scanning .scan-line {
|
|
opacity: 1; animation: scan 2.5s linear infinite;
|
|
}
|
|
@keyframes scan {
|
|
0% { top: 12%; }
|
|
100% { top: 88%; }
|
|
}
|
|
|
|
/* ===== RIGHT PANEL — context / events ===== */
|
|
.right {
|
|
grid-area: right;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 16px;
|
|
position: relative;
|
|
overflow-y: auto;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
.event {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 11px; padding: 5px 0;
|
|
border-bottom: 1px dashed rgba(59,130,246,.15);
|
|
color: var(--text);
|
|
word-wrap: break-word;
|
|
}
|
|
.event .time { color: var(--text-dim); margin-right: 6px; }
|
|
.event.user { color: var(--cyan); }
|
|
.event.system { color: var(--gold); }
|
|
|
|
/* ===== TRANSCRIPT BAR (bottom) ===== */
|
|
.transcript {
|
|
grid-area: trans;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 14px 20px;
|
|
position: relative;
|
|
display: flex; flex-direction: column;
|
|
gap: 10px;
|
|
backdrop-filter: blur(6px);
|
|
overflow-y: auto;
|
|
}
|
|
.transcript-line {
|
|
display: flex; align-items: flex-start; gap: 10px;
|
|
font-size: 14px; line-height: 1.5;
|
|
}
|
|
.transcript-line .role {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
padding: 1px 6px; border-radius: 2px;
|
|
white-space: nowrap; margin-top: 4px;
|
|
}
|
|
.transcript-line .role.user { background: rgba(0,212,255,.2); color: var(--cyan); border: 1px solid var(--cyan); }
|
|
.transcript-line .role.atlas { background: rgba(59,130,246,.2); color: var(--accent); border: 1px solid var(--accent); }
|
|
.transcript-line .text { flex: 1; }
|
|
.transcript-line .text p { margin: 0 0 4px; }
|
|
.input-row {
|
|
display: flex; gap: 8px; align-items: center;
|
|
border-top: 1px solid var(--border); padding-top: 10px;
|
|
}
|
|
#input {
|
|
flex: 1; background: rgba(0,212,255,.08);
|
|
border: 1px solid var(--border); border-radius: 3px;
|
|
color: var(--text); font-family: inherit; font-size: 14px;
|
|
padding: 8px 12px; outline: none;
|
|
}
|
|
#input:focus { border-color: var(--cyan); box-shadow: 0 0 6px rgba(0,212,255,.3); }
|
|
#input::placeholder { color: var(--text-dim); }
|
|
.send-btn {
|
|
background: var(--accent); color: white;
|
|
border: none; padding: 8px 16px;
|
|
border-radius: 3px; cursor: pointer; font-family: inherit;
|
|
font-weight: 600; letter-spacing: 1px;
|
|
}
|
|
.send-btn:disabled { background: var(--text-dim); cursor: not-allowed; }
|
|
|
|
/* ===== TYPING CURSOR ===== */
|
|
.typing::after { content: '▊'; color: var(--cyan); animation: blink 1s steps(2) infinite; }
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
|
|
/* corner brackets */
|
|
.bracket {
|
|
position: absolute; width: 14px; height: 14px;
|
|
border: 2px solid var(--cyan);
|
|
pointer-events: none;
|
|
}
|
|
.bracket.tl { top: 4px; left: 4px; border-right: none; border-bottom: none; }
|
|
.bracket.tr { top: 4px; right: 4px; border-left: none; border-bottom: none; }
|
|
.bracket.bl { bottom: 4px; left: 4px; border-right: none; border-top: none; }
|
|
.bracket.br { bottom: 4px; right: 4px; border-left: none; border-top: none; }
|
|
|
|
/* ===== RESPONSIVE ===== */
|
|
@media (max-width: 900px) {
|
|
.hud {
|
|
grid-template-rows: 50px 50vh 1fr;
|
|
grid-template-columns: 1fr;
|
|
grid-template-areas: "topnav" "center" "trans";
|
|
}
|
|
.left, .right { display: none; }
|
|
}
|
|
|
|
::-webkit-scrollbar { width: 4px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
/* boot animation */
|
|
.boot-text {
|
|
position: fixed; inset: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: var(--bg); z-index: 100;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
flex-direction: column; gap: 10px;
|
|
transition: opacity .6s;
|
|
}
|
|
.boot-text.hidden { opacity: 0; pointer-events: none; }
|
|
.boot-text .big {
|
|
font-size: 32px; color: var(--cyan); letter-spacing: 8px;
|
|
text-shadow: 0 0 14px var(--cyan); font-weight: 700;
|
|
}
|
|
.boot-text .lines { font-size: 12px; color: var(--text-dim); }
|
|
.boot-text .lines div { animation: bootline .5s ease-out forwards; opacity: 0; }
|
|
@keyframes bootline { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="boot-text" id="boot">
|
|
<div class="big">A T L A S</div>
|
|
<div class="lines" id="boot-lines"></div>
|
|
</div>
|
|
|
|
<div class="hud">
|
|
|
|
<!-- TOP NAV -->
|
|
<header class="topnav">
|
|
<div>
|
|
<span class="brand-text">ATLAS HUD</span>
|
|
<span class="brand-sub">v0.1 · jarvis-class</span>
|
|
</div>
|
|
<div class="status-bar">
|
|
<span class="status-pill" id="pill-cluster"><span class="dot"></span><span>CLUSTER</span></span>
|
|
<span class="status-pill" id="pill-mic"><span class="dot"></span><span>MIC</span></span>
|
|
<span class="status-pill" id="pill-cam"><span class="dot"></span><span>CAM</span></span>
|
|
<button class="btn-icon" id="btn-camera" onclick="toggleCamera()">📷 CAM</button>
|
|
<button class="btn-icon" id="btn-mic" onclick="toggleListening()">🎤 LISTEN</button>
|
|
<button class="btn-icon" id="btn-fs" onclick="toggleFullscreen()">⛶ FULL</button>
|
|
</div>
|
|
<div class="bracket tl"></div>
|
|
<div class="bracket tr"></div>
|
|
</header>
|
|
|
|
<!-- LEFT — system status -->
|
|
<aside class="left">
|
|
<div class="bracket tl"></div>
|
|
<div class="bracket tr"></div>
|
|
<div class="section">
|
|
<div class="panel-title">▸ Cluster Nodes</div>
|
|
<div class="node-row"><span class="dot" id="dot-atlas01"></span><span class="name">atlas-01</span><span class="lat" id="lat-atlas01">—</span></div>
|
|
<div class="node-row"><span class="dot" id="dot-pc"></span><span class="name">pc (local)</span><span class="lat" id="lat-pc">—</span></div>
|
|
<div class="node-row"><span class="dot" id="dot-met"></span><span class="name">met-desktop</span><span class="lat" id="lat-met">—</span></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="panel-title">▸ Active Model</div>
|
|
<div class="metric"><span class="metric-label">model</span><span id="m-model">—</span></div>
|
|
<div class="metric"><span class="metric-label">node</span><span id="m-node">—</span></div>
|
|
<div class="metric"><span class="metric-label">last latency</span><span id="m-lat">—</span></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="panel-title">▸ System</div>
|
|
<div class="metric"><span class="metric-label">time</span><span id="m-time">—</span></div>
|
|
<div class="metric"><span class="metric-label">date</span><span id="m-date">—</span></div>
|
|
<div class="metric"><span class="metric-label">overdue €</span><span id="m-overdue">—</span></div>
|
|
<div class="metric"><span class="metric-label">disk atlas-01</span><span id="m-disk">—</span></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="panel-title">▸ Voice</div>
|
|
<div class="metric"><span class="metric-label">wake word</span><span>"atlas"</span></div>
|
|
<div class="metric"><span class="metric-label">recognition</span><span id="m-asr">native</span></div>
|
|
<div class="metric"><span class="metric-label">tts</span><span id="m-tts">native</span></div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- CENTER — circular HUD -->
|
|
<main class="center" id="center">
|
|
<div class="scan-line"></div>
|
|
<div class="hud-ring">
|
|
<svg class="ring-svg" viewBox="0 0 200 200">
|
|
<!-- outer thin ring -->
|
|
<circle cx="100" cy="100" r="95" stroke="#3B82F6" stroke-width=".5" stroke-opacity=".4" />
|
|
<!-- main ring (state color) -->
|
|
<circle id="main-ring" cx="100" cy="100" r="88" stroke="#00d4ff" stroke-width="1" stroke-dasharray="3 2" opacity=".8" />
|
|
<!-- rotating ring -->
|
|
<circle id="rotate-ring" cx="100" cy="100" r="80" stroke="#3B82F6" stroke-width=".6" stroke-dasharray="120 553">
|
|
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="20s" repeatCount="indefinite" />
|
|
</circle>
|
|
<!-- inner accent -->
|
|
<circle cx="100" cy="100" r="72" stroke="#00d4ff" stroke-width=".4" stroke-opacity=".3" />
|
|
</svg>
|
|
<canvas class="viz-canvas" id="viz" width="500" height="500"></canvas>
|
|
<video class="video-feed" id="cam" autoplay muted playsinline></video>
|
|
<div class="hud-state" id="state-label">IDLE</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- RIGHT — events / context -->
|
|
<aside class="right">
|
|
<div class="bracket tl"></div>
|
|
<div class="bracket tr"></div>
|
|
<div class="panel-title">▸ Quick Tools</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
|
|
<button class="btn-icon" onclick="toolCash()" style="text-align:left">📊 Cash check</button>
|
|
<button class="btn-icon" onclick="toolStatus()" style="text-align:left">🔧 System status</button>
|
|
<button class="btn-icon" onclick="toolToday()" style="text-align:left">📅 Today events</button>
|
|
</div>
|
|
<div class="panel-title">▸ Event Stream</div>
|
|
<div id="events">
|
|
<div class="event system"><span class="time">00:00</span>HUD initialized</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- TRANSCRIPT BAR -->
|
|
<section class="transcript">
|
|
<div class="bracket bl"></div>
|
|
<div class="bracket br"></div>
|
|
<div id="transcript"></div>
|
|
<div class="input-row">
|
|
<input id="input" placeholder="Spreek of typ — "Atlas, ..."" />
|
|
<button class="send-btn" id="send-btn">SEND</button>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// =============================================================================
|
|
// ATLAS HUD — browser-native Jarvis-class front-end
|
|
// =============================================================================
|
|
|
|
const CONFIG = {
|
|
nodes: {
|
|
'atlas-01': { url: location.origin + '/ollama', defaultModel: 'qwen2.5:7b' },
|
|
'met': { url: location.origin + '/ollama/met', defaultModel: 'llama3.1:8b' },
|
|
},
|
|
currentNode: 'atlas-01',
|
|
model: 'qwen2.5:7b',
|
|
wakeWord: 'atlas',
|
|
systemPrompt: 'Je bent Atlas, een AI-assistent voor Chaib. Antwoord kort en direct, NL of EN volgens de input. Vermijd uitweidingen — maximaal 2-3 zinnen tenzij om detail gevraagd. Spreek alsof je in een HUD verschijnt.',
|
|
voiceName: '', // auto-pick
|
|
};
|
|
|
|
let state = {
|
|
mode: 'IDLE', // IDLE / LISTENING / THINKING / SPEAKING
|
|
cameraActive: false,
|
|
micActive: false,
|
|
recognition: null,
|
|
audioCtx: null,
|
|
analyser: null,
|
|
micStream: null,
|
|
vizFrame: null,
|
|
conversation: [],
|
|
speaking: false,
|
|
};
|
|
|
|
// ===== BOOT SEQUENCE =====
|
|
async function boot() {
|
|
const lines = [
|
|
'INITIALIZING ATLAS PROTOCOL',
|
|
'CHECKING CLUSTER NODES…',
|
|
'LOADING MODEL ROUTER…',
|
|
'AUDIO PIPELINE READY',
|
|
'SYSTEM ONLINE',
|
|
];
|
|
const el = document.getElementById('boot-lines');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const d = document.createElement('div');
|
|
d.textContent = '> ' + lines[i];
|
|
el.appendChild(d);
|
|
await new Promise(r => setTimeout(r, 280));
|
|
}
|
|
await new Promise(r => setTimeout(r, 400));
|
|
document.getElementById('boot').classList.add('hidden');
|
|
setTimeout(() => document.getElementById('boot').remove(), 700);
|
|
|
|
setState('IDLE');
|
|
await refreshCluster();
|
|
await refreshPriorities();
|
|
setInterval(refreshClockOnly, 1000);
|
|
setInterval(refreshCluster, 30000);
|
|
setInterval(refreshPriorities, 60000);
|
|
emit('system', 'Cluster nodes detected: see left panel');
|
|
emit('system', 'Click 🎤 LISTEN to activate voice; "Atlas" = wake word');
|
|
setupInput();
|
|
}
|
|
|
|
// ===== STATE MACHINE =====
|
|
function setState(s) {
|
|
state.mode = s;
|
|
document.getElementById('state-label').textContent = s;
|
|
const ring = document.getElementById('main-ring');
|
|
const colors = { IDLE: '#3B82F6', LISTENING: '#00d4ff', THINKING: '#ffb020', SPEAKING: '#00ff88' };
|
|
ring.setAttribute('stroke', colors[s] || '#3B82F6');
|
|
document.getElementById('center').classList.toggle('scanning', s === 'LISTENING' || s === 'THINKING');
|
|
}
|
|
|
|
// ===== CLUSTER STATUS =====
|
|
async function refreshCluster() {
|
|
const nodes = [
|
|
{ id: 'atlas01', url: CONFIG.nodes['atlas-01'].url },
|
|
{ id: 'met', url: CONFIG.nodes.met.url },
|
|
];
|
|
let anyOk = false;
|
|
for (const n of nodes) {
|
|
const dot = document.getElementById('dot-' + n.id);
|
|
const lat = document.getElementById('lat-' + n.id);
|
|
if (!dot || !lat) continue;
|
|
const t0 = performance.now();
|
|
try {
|
|
const r = await fetch(n.url + '/api/tags', { signal: AbortSignal.timeout(4000) });
|
|
if (r.ok) {
|
|
const t = Math.round(performance.now() - t0);
|
|
dot.className = 'dot ok';
|
|
lat.textContent = t + 'ms';
|
|
anyOk = true;
|
|
} else throw 0;
|
|
} catch {
|
|
dot.className = 'dot err';
|
|
lat.textContent = 'off';
|
|
}
|
|
}
|
|
document.getElementById('pill-cluster').className = 'status-pill ' + (anyOk ? 'ok' : 'err');
|
|
document.getElementById('m-model').textContent = CONFIG.model;
|
|
document.getElementById('m-node').textContent = CONFIG.currentNode;
|
|
}
|
|
|
|
// ===== TOOL BUTTONS =====
|
|
async function toolCash() {
|
|
emit('user', 'tool: cash check');
|
|
appendTranscript('user', '📊 Cash check');
|
|
try {
|
|
const r = await fetch('/priorities.json');
|
|
const d = await r.json();
|
|
const eur = d.odoo?.overdue_eur || 0;
|
|
const cnt = d.odoo?.overdue_count || 0;
|
|
const txt = cnt > 0
|
|
? `Open openstaand AR: **€${eur}** in ${cnt} facturen. Oudste: ${d.odoo.oldest_inv} van ${d.odoo.oldest_partner} (${d.odoo.oldest_age} dagen).`
|
|
: `Open AR: **€0** — geen openstaande facturen.`;
|
|
appendTranscript('atlas', txt);
|
|
speak(`Open AR is ${eur} euro in ${cnt} facturen.`);
|
|
} catch (e) { emit('system', 'tool fail: ' + e.message); }
|
|
}
|
|
async function toolStatus() {
|
|
emit('user', 'tool: system status');
|
|
appendTranscript('user', '🔧 System status');
|
|
try {
|
|
const r = await fetch('/priorities.json');
|
|
const d = await r.json();
|
|
const txt = `Atlas-01: disk ${d.disk_free_gb}GB free · ${d.containers_down || 0} containers down. Attention score: ${d.attention?.score || 0}.`;
|
|
appendTranscript('atlas', txt);
|
|
speak(`Atlas-01 disk ${d.disk_free_gb} gigabyte free. ${d.containers_down || 0} containers down.`);
|
|
} catch (e) { emit('system', 'tool fail: ' + e.message); }
|
|
}
|
|
async function toolToday() {
|
|
emit('user', 'tool: today events');
|
|
appendTranscript('user', '📅 Vandaag');
|
|
try {
|
|
const r = await fetch('/today.json');
|
|
const d = await r.json();
|
|
const events = (d.events || d.calendar?.events || []).slice(0, 5);
|
|
if (!events.length) { appendTranscript('atlas', 'Geen events vandaag.'); speak('Geen events vandaag.'); return; }
|
|
const txt = `**Vandaag (${events.length}):**\n` + events.map(e => `- ${e.start || e.time || '?'} · ${e.title || e.summary || '?'}`).join('\n');
|
|
appendTranscript('atlas', txt);
|
|
speak(`Vandaag ${events.length} events. Eerste: ${events[0].title || events[0].summary || ''}.`);
|
|
} catch (e) { emit('system', 'tool fail: ' + e.message); }
|
|
}
|
|
|
|
// ===== FACE DETECTION (lazy-loaded face-api.js) =====
|
|
let faceApiLoaded = false;
|
|
let faceDetectorRunning = false;
|
|
async function loadFaceApi() {
|
|
if (faceApiLoaded) return true;
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js';
|
|
s.onload = resolve; s.onerror = reject;
|
|
document.head.appendChild(s);
|
|
});
|
|
const MODELS = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights';
|
|
await faceapi.nets.tinyFaceDetector.loadFromUri(MODELS);
|
|
faceApiLoaded = true;
|
|
emit('system', 'Face detection loaded');
|
|
return true;
|
|
} catch (e) {
|
|
emit('system', 'Face API load fail: ' + e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runFaceDetection() {
|
|
if (!state.cameraActive) return;
|
|
if (!faceApiLoaded) {
|
|
const ok = await loadFaceApi();
|
|
if (!ok) return;
|
|
}
|
|
if (faceDetectorRunning) return;
|
|
faceDetectorRunning = true;
|
|
const v = document.getElementById('cam');
|
|
const center = document.getElementById('center');
|
|
|
|
async function detect() {
|
|
if (!state.cameraActive) { faceDetectorRunning = false; return; }
|
|
try {
|
|
const dets = await faceapi.detectAllFaces(v, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.4 }));
|
|
// Clear old boxes
|
|
center.querySelectorAll('.face-box').forEach(el => el.remove());
|
|
const ring = document.querySelector('.hud-ring').getBoundingClientRect();
|
|
const vRect = v.getBoundingClientRect();
|
|
const sx = vRect.width / v.videoWidth;
|
|
const sy = vRect.height / v.videoHeight;
|
|
for (const d of dets) {
|
|
const b = d.box;
|
|
const box = document.createElement('div');
|
|
box.className = 'face-box';
|
|
const left = vRect.left - ring.left + (v.videoWidth - b.x - b.width) * sx; // mirror
|
|
const top = vRect.top - ring.top + b.y * sy;
|
|
box.style.left = left + 'px';
|
|
box.style.top = top + 'px';
|
|
box.style.width = (b.width * sx) + 'px';
|
|
box.style.height = (b.height * sy) + 'px';
|
|
box.innerHTML = `<span style="position:absolute;top:-14px;left:0">FACE ${Math.round(d.score*100)}%</span>`;
|
|
center.appendChild(box);
|
|
}
|
|
} catch (e) {}
|
|
setTimeout(detect, 200);
|
|
}
|
|
detect();
|
|
}
|
|
|
|
async function refreshPriorities() {
|
|
try {
|
|
const r = await fetch('/priorities.json', { signal: AbortSignal.timeout(4000) });
|
|
if (r.ok) {
|
|
const d = await r.json();
|
|
document.getElementById('m-overdue').textContent = '€' + (d.odoo?.overdue_eur || 0);
|
|
document.getElementById('m-disk').textContent = (d.disk_free_gb || '?') + 'GB free';
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function refreshClockOnly() {
|
|
const d = new Date();
|
|
document.getElementById('m-time').textContent = d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
document.getElementById('m-date').textContent = d.toLocaleDateString('nl-NL', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
// ===== CAMERA =====
|
|
async function toggleCamera() {
|
|
const v = document.getElementById('cam');
|
|
const btn = document.getElementById('btn-camera');
|
|
const pill = document.getElementById('pill-cam');
|
|
if (state.cameraActive) {
|
|
v.srcObject?.getTracks().forEach(t => t.stop());
|
|
v.srcObject = null;
|
|
v.classList.remove('active');
|
|
btn.classList.remove('active');
|
|
pill.className = 'status-pill';
|
|
state.cameraActive = false;
|
|
emit('system', 'Camera offline');
|
|
return;
|
|
}
|
|
try {
|
|
const s = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 640, facingMode: 'user' } });
|
|
v.srcObject = s;
|
|
v.classList.add('active');
|
|
btn.classList.add('active');
|
|
pill.className = 'status-pill ok';
|
|
state.cameraActive = true;
|
|
emit('system', 'Camera online');
|
|
// Kick off face detection (async load, won't block UI)
|
|
runFaceDetection();
|
|
} catch (e) {
|
|
emit('system', 'Camera fail: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ===== MIC + WAKE WORD =====
|
|
async function toggleListening() {
|
|
const btn = document.getElementById('btn-mic');
|
|
const pill = document.getElementById('pill-mic');
|
|
if (state.micActive) {
|
|
state.recognition?.stop();
|
|
state.micStream?.getTracks().forEach(t => t.stop());
|
|
if (state.vizFrame) cancelAnimationFrame(state.vizFrame);
|
|
state.micActive = false;
|
|
btn.classList.remove('active');
|
|
pill.className = 'status-pill';
|
|
setState('IDLE');
|
|
emit('system', 'Listening off');
|
|
return;
|
|
}
|
|
// Audio viz
|
|
try {
|
|
state.micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
state.audioCtx = state.audioCtx || new AudioContext();
|
|
const src = state.audioCtx.createMediaStreamSource(state.micStream);
|
|
state.analyser = state.audioCtx.createAnalyser();
|
|
state.analyser.fftSize = 256;
|
|
src.connect(state.analyser);
|
|
drawViz();
|
|
} catch (e) {
|
|
emit('system', 'Mic fail: ' + e.message);
|
|
return;
|
|
}
|
|
// Speech recognition
|
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (!SR) {
|
|
emit('system', 'Speech recognition unavailable in this browser');
|
|
return;
|
|
}
|
|
state.recognition = new SR();
|
|
state.recognition.lang = 'nl-NL';
|
|
state.recognition.continuous = true;
|
|
state.recognition.interimResults = true;
|
|
state.recognition.onresult = (e) => {
|
|
let final = '';
|
|
let interim = '';
|
|
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
const t = e.results[i][0].transcript;
|
|
if (e.results[i].isFinal) final += t;
|
|
else interim += t;
|
|
}
|
|
if (final.trim()) {
|
|
const low = final.toLowerCase().trim();
|
|
// wake word match — accept multiple aliases
|
|
const aliases = [CONFIG.wakeWord, 'hey atlas', 'atlas chat', 'jarvis'];
|
|
const hit = aliases.find(a => low.includes(a));
|
|
if (hit || state.mode === 'LISTENING') {
|
|
let payload = final;
|
|
for (const a of aliases) payload = payload.replace(new RegExp(a, 'gi'), '');
|
|
payload = payload.replace(/^[,.\s]+|[,.\s]+$/g, '');
|
|
if (payload.length > 2) {
|
|
handleVoice(payload);
|
|
}
|
|
}
|
|
}
|
|
if (interim) {
|
|
setState('LISTENING');
|
|
}
|
|
};
|
|
state.recognition.onerror = (e) => { emit('system', 'ASR error: ' + e.error); };
|
|
state.recognition.onend = () => {
|
|
if (state.micActive) state.recognition.start(); // auto-restart for continuous
|
|
};
|
|
state.recognition.start();
|
|
state.micActive = true;
|
|
btn.classList.add('active');
|
|
pill.className = 'status-pill ok';
|
|
setState('LISTENING');
|
|
emit('system', 'Listening… say "Atlas" + your question');
|
|
}
|
|
|
|
function drawViz() {
|
|
if (!state.analyser) return;
|
|
const c = document.getElementById('viz');
|
|
const ctx = c.getContext('2d');
|
|
const W = c.width, H = c.height, cx = W/2, cy = H/2;
|
|
const bufLen = state.analyser.frequencyBinCount;
|
|
const data = new Uint8Array(bufLen);
|
|
|
|
function tick() {
|
|
state.analyser.getByteFrequencyData(data);
|
|
ctx.clearRect(0, 0, W, H);
|
|
const bars = 64;
|
|
const radius = W * 0.35;
|
|
const color = state.mode === 'SPEAKING' ? '0,255,136'
|
|
: state.mode === 'THINKING' ? '255,176,32'
|
|
: state.mode === 'LISTENING' ? '0,212,255' : '59,130,246';
|
|
for (let i = 0; i < bars; i++) {
|
|
const dataIdx = Math.floor(i / bars * bufLen / 2);
|
|
const v = data[dataIdx] / 255;
|
|
const barH = 8 + v * 80;
|
|
const angle = (i / bars) * Math.PI * 2 - Math.PI/2;
|
|
const x1 = cx + Math.cos(angle) * radius;
|
|
const y1 = cy + Math.sin(angle) * radius;
|
|
const x2 = cx + Math.cos(angle) * (radius + barH);
|
|
const y2 = cy + Math.sin(angle) * (radius + barH);
|
|
ctx.strokeStyle = `rgba(${color},${0.4 + v * 0.6})`;
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
}
|
|
state.vizFrame = requestAnimationFrame(tick);
|
|
}
|
|
tick();
|
|
}
|
|
|
|
async function handleVoice(text) {
|
|
emit('user', text);
|
|
appendTranscript('user', text);
|
|
await sendToAtlas(text);
|
|
}
|
|
|
|
// ===== INPUT (text fallback) =====
|
|
function setupInput() {
|
|
const inp = document.getElementById('input');
|
|
const btn = document.getElementById('send-btn');
|
|
const submit = () => {
|
|
const t = inp.value.trim();
|
|
if (!t) return;
|
|
inp.value = '';
|
|
appendTranscript('user', t);
|
|
sendToAtlas(t);
|
|
};
|
|
btn.onclick = submit;
|
|
inp.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
|
|
}
|
|
|
|
// ===== SEND TO OLLAMA (streaming) =====
|
|
async function sendToAtlas(prompt) {
|
|
setState('THINKING');
|
|
state.conversation.push({ role: 'user', content: prompt });
|
|
|
|
const msgs = [
|
|
{ role: 'system', content: CONFIG.systemPrompt },
|
|
...state.conversation.slice(-12),
|
|
];
|
|
|
|
const t0 = performance.now();
|
|
const lineEl = appendTranscript('atlas', '');
|
|
const textEl = lineEl.querySelector('.text');
|
|
textEl.classList.add('typing');
|
|
|
|
let full = '';
|
|
try {
|
|
const nodeUrl = CONFIG.nodes[CONFIG.currentNode]?.url || CONFIG.nodes['atlas-01'].url;
|
|
const r = await fetch(nodeUrl + '/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: CONFIG.model, 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;
|
|
textEl.innerHTML = DOMPurify.sanitize(marked.parse(full));
|
|
scrollTranscript();
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
|
document.getElementById('m-lat').textContent = elapsed + 's';
|
|
emit('atlas', full.slice(0, 80) + (full.length > 80 ? '…' : ''));
|
|
state.conversation.push({ role: 'assistant', content: full });
|
|
// Speak it
|
|
speak(full);
|
|
} catch (e) {
|
|
textEl.textContent = 'Fout: ' + e.message;
|
|
emit('system', 'ATLAS error: ' + e.message);
|
|
setState('IDLE');
|
|
} finally {
|
|
textEl.classList.remove('typing');
|
|
}
|
|
}
|
|
|
|
// ===== TTS =====
|
|
function speak(text) {
|
|
if (!('speechSynthesis' in window)) { setState('IDLE'); return; }
|
|
// Strip markdown for cleaner TTS
|
|
const clean = text.replace(/[*_`#>\[\]\(\)]/g, '').replace(/\n+/g, '. ');
|
|
const u = new SpeechSynthesisUtterance(clean);
|
|
u.lang = /[Ѐ-ӿéëçàáâäæ]/i.test(clean) || /\b(de|het|een|ik|je|wij|maar|niet)\b/i.test(clean) ? 'nl-NL' : 'en-US';
|
|
u.rate = 1.05;
|
|
u.pitch = 0.95;
|
|
// pick voice
|
|
const voices = speechSynthesis.getVoices();
|
|
const v = voices.find(v => v.lang.startsWith(u.lang.split('-')[0]) && /Microsoft|Google/.test(v.name))
|
|
|| voices.find(v => v.lang.startsWith(u.lang.split('-')[0]));
|
|
if (v) u.voice = v;
|
|
u.onstart = () => { setState('SPEAKING'); state.speaking = true; };
|
|
u.onend = () => { state.speaking = false; setState(state.micActive ? 'LISTENING' : 'IDLE'); };
|
|
u.onerror = () => { state.speaking = false; setState('IDLE'); };
|
|
speechSynthesis.speak(u);
|
|
}
|
|
|
|
// ===== TRANSCRIPT + EVENTS =====
|
|
function appendTranscript(role, text) {
|
|
const t = document.getElementById('transcript');
|
|
const div = document.createElement('div');
|
|
div.className = 'transcript-line';
|
|
div.innerHTML = `<span class="role ${role}">${role === 'user' ? 'JIJ' : 'ATLAS'}</span><div class="text">${DOMPurify.sanitize(marked.parse(text || ''))}</div>`;
|
|
t.appendChild(div);
|
|
scrollTranscript();
|
|
// keep only last 12
|
|
while (t.children.length > 12) t.firstChild.remove();
|
|
return div;
|
|
}
|
|
|
|
function scrollTranscript() {
|
|
const t = document.getElementById('transcript');
|
|
t.scrollTop = t.scrollHeight;
|
|
}
|
|
|
|
function emit(kind, text) {
|
|
const events = document.getElementById('events');
|
|
const div = document.createElement('div');
|
|
div.className = 'event ' + kind;
|
|
const time = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
|
|
div.innerHTML = `<span class="time">${time}</span>${DOMPurify.sanitize(text)}`;
|
|
events.appendChild(div);
|
|
events.scrollTop = events.scrollHeight;
|
|
// keep only last 20
|
|
while (events.children.length > 20) events.firstChild.remove();
|
|
}
|
|
|
|
// ===== FULLSCREEN =====
|
|
function toggleFullscreen() {
|
|
if (document.fullscreenElement) document.exitFullscreen();
|
|
else document.documentElement.requestFullscreen();
|
|
}
|
|
|
|
// ===== START =====
|
|
window.addEventListener('load', () => {
|
|
// load voices async (Chrome)
|
|
if (speechSynthesis.onvoiceschanged !== undefined) {
|
|
speechSynthesis.onvoiceschanged = () => {};
|
|
}
|
|
boot();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|