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.
840 lines
29 KiB
HTML
840 lines
29 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="#0b1220">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<title>Atlas Chat</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%3Ctext x='16' y='22' text-anchor='middle' font-family='system-ui,sans-serif' font-weight='700' font-size='18' fill='%233B82F6'%3EA%3C/text%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: #0b1220;
|
||
--surface: #111a2e;
|
||
--surface-2: #1a253f;
|
||
--border: #1f2c4a;
|
||
--text: #e6edf6;
|
||
--text-dim: #8b96b1;
|
||
--accent: #3b82f6;
|
||
--accent-soft: rgba(59,130,246,.15);
|
||
--user: #2dd4bf;
|
||
--assistant: #93c5fd;
|
||
--err: #f87171;
|
||
--ok: #4ade80;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; height: 100%; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
.app { display: grid; grid-template-columns: 280px 1fr; height: 100vh; height: 100dvh; }
|
||
|
||
/* SIDEBAR */
|
||
.sidebar {
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex; flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.brand {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.brand-logo {
|
||
width: 30px; height: 30px; border-radius: 6px;
|
||
background: linear-gradient(135deg, var(--accent), #1e40af);
|
||
color: white; font-weight: 800; font-size: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.brand-text { font-weight: 600; letter-spacing: .2px; }
|
||
.brand-sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
|
||
|
||
.btn-new {
|
||
margin: 12px;
|
||
padding: 10px 14px;
|
||
background: var(--accent);
|
||
color: white; border: none; border-radius: 8px;
|
||
font-weight: 500; cursor: pointer; font-size: 13px;
|
||
display: flex; align-items: center; gap: 8px; justify-content: center;
|
||
transition: background .15s;
|
||
}
|
||
.btn-new:hover { background: #2563eb; }
|
||
|
||
.history {
|
||
flex: 1; overflow-y: auto; padding: 6px;
|
||
}
|
||
.history-item {
|
||
padding: 10px 12px; margin: 2px 0;
|
||
border-radius: 6px; cursor: pointer;
|
||
font-size: 13px; color: var(--text-dim);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.history-item:hover { background: var(--surface-2); color: var(--text); }
|
||
.history-item.active { background: var(--accent-soft); color: var(--text); border-left: 2px solid var(--accent); padding-left: 10px; }
|
||
.history-empty { padding: 16px; font-size: 12px; color: var(--text-dim); text-align: center; }
|
||
|
||
.sidebar-footer {
|
||
border-top: 1px solid var(--border); padding: 10px 14px;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
}
|
||
.node-status {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 11px; color: var(--text-dim);
|
||
}
|
||
.node-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); }
|
||
.node-dot.ok { background: var(--ok); }
|
||
.node-dot.err { background: var(--err); }
|
||
.settings-btn {
|
||
background: none; border: 1px solid var(--border); color: var(--text-dim);
|
||
padding: 6px 10px; border-radius: 6px; font-size: 11px;
|
||
cursor: pointer; margin-top: 6px;
|
||
}
|
||
.settings-btn:hover { color: var(--text); border-color: var(--accent); }
|
||
|
||
/* MAIN CHAT */
|
||
.main { display: flex; flex-direction: column; overflow: hidden; }
|
||
.topbar {
|
||
padding: 12px 20px; border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; gap: 12px;
|
||
background: var(--surface);
|
||
}
|
||
.model-select {
|
||
background: var(--surface-2); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 6px;
|
||
padding: 6px 10px; font-size: 13px; cursor: pointer;
|
||
min-width: 200px;
|
||
}
|
||
.menu-toggle {
|
||
display: none;
|
||
background: none; border: none; color: var(--text);
|
||
font-size: 22px; cursor: pointer; padding: 4px;
|
||
}
|
||
.title-bar { flex: 1; font-size: 14px; color: var(--text-dim); }
|
||
.title-bar strong { color: var(--text); font-weight: 500; }
|
||
|
||
.chat-area {
|
||
flex: 1; overflow-y: auto; padding: 20px;
|
||
scroll-behavior: smooth;
|
||
}
|
||
.welcome {
|
||
max-width: 720px; margin: 60px auto; text-align: center;
|
||
}
|
||
.welcome h1 {
|
||
font-size: 28px; margin: 0 0 12px; font-weight: 600;
|
||
background: linear-gradient(135deg, var(--accent), #93c5fd);
|
||
-webkit-background-clip: text; background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
.welcome p { color: var(--text-dim); margin: 0 0 24px; line-height: 1.6; }
|
||
.suggestions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||
.suggestion {
|
||
padding: 14px; background: var(--surface);
|
||
border: 1px solid var(--border); border-radius: 10px;
|
||
cursor: pointer; text-align: left; font-size: 13px;
|
||
transition: all .15s;
|
||
}
|
||
.suggestion:hover { border-color: var(--accent); background: var(--surface-2); }
|
||
.suggestion strong { display: block; color: var(--accent); margin-bottom: 4px; font-size: 12px; }
|
||
|
||
.message {
|
||
max-width: 800px; margin: 0 auto 20px;
|
||
padding: 16px 18px; border-radius: 12px;
|
||
}
|
||
.message.user {
|
||
background: var(--accent-soft);
|
||
border: 1px solid rgba(59,130,246,.3);
|
||
color: var(--text);
|
||
}
|
||
.message.assistant {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.message.error {
|
||
background: rgba(248,113,113,.1); border-color: rgba(248,113,113,.3);
|
||
}
|
||
.msg-meta {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 11px; color: var(--text-dim); margin-bottom: 8px;
|
||
}
|
||
.msg-role { font-weight: 600; }
|
||
.msg-role.user { color: var(--user); }
|
||
.msg-role.assistant { color: var(--assistant); }
|
||
.msg-model { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; opacity: .7; }
|
||
.msg-content { font-size: 14.5px; line-height: 1.65; }
|
||
.msg-content p { margin: 0 0 10px; }
|
||
.msg-content p:last-child { margin-bottom: 0; }
|
||
.msg-content code {
|
||
background: #0a1322; padding: 1px 6px; border-radius: 4px;
|
||
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||
font-size: 13px; color: #93c5fd;
|
||
}
|
||
.msg-content pre {
|
||
background: #0a1322; padding: 12px; border-radius: 8px;
|
||
overflow-x: auto; border: 1px solid var(--border); margin: 10px 0;
|
||
}
|
||
.msg-content pre code { background: transparent; padding: 0; color: var(--text); }
|
||
.msg-content ul, .msg-content ol { padding-left: 20px; margin: 8px 0; }
|
||
.msg-content li { margin: 4px 0; }
|
||
.msg-content blockquote {
|
||
border-left: 3px solid var(--accent); padding-left: 12px;
|
||
margin: 10px 0; color: var(--text-dim);
|
||
}
|
||
.msg-content table { border-collapse: collapse; margin: 10px 0; font-size: 13px; }
|
||
.msg-content th, .msg-content td {
|
||
border: 1px solid var(--border); padding: 6px 10px;
|
||
}
|
||
.msg-content th { background: var(--surface-2); }
|
||
.msg-content a { color: var(--accent); }
|
||
|
||
.input-area {
|
||
padding: 12px 20px 16px;
|
||
border-top: 1px solid var(--border);
|
||
background: var(--surface);
|
||
}
|
||
.input-wrap {
|
||
max-width: 800px; margin: 0 auto;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
display: flex; align-items: flex-end; gap: 6px;
|
||
transition: border-color .15s;
|
||
}
|
||
.input-wrap:focus-within { border-color: var(--accent); }
|
||
#input {
|
||
flex: 1;
|
||
background: transparent; border: none; outline: none;
|
||
color: var(--text); font-family: inherit; font-size: 14.5px;
|
||
padding: 10px 12px; resize: none;
|
||
max-height: 200px; min-height: 38px;
|
||
line-height: 1.5;
|
||
}
|
||
.send-btn {
|
||
background: var(--accent); color: white;
|
||
border: none; border-radius: 8px;
|
||
width: 36px; height: 36px;
|
||
cursor: pointer; font-size: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all .15s;
|
||
}
|
||
.send-btn:hover { background: #2563eb; }
|
||
.send-btn:disabled { background: var(--border); cursor: not-allowed; }
|
||
.send-btn.stop { background: var(--err); }
|
||
.input-hint {
|
||
max-width: 800px; margin: 6px auto 0;
|
||
font-size: 11px; color: var(--text-dim);
|
||
display: flex; justify-content: space-between;
|
||
}
|
||
|
||
/* settings modal */
|
||
.modal-bg {
|
||
display: none; position: fixed; inset: 0;
|
||
background: rgba(0,0,0,.6); z-index: 100;
|
||
align-items: center; justify-content: center;
|
||
}
|
||
.modal-bg.open { display: flex; }
|
||
.modal {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 12px; padding: 20px; max-width: 480px; width: 90%;
|
||
}
|
||
.modal h2 { margin-top: 0; font-size: 16px; }
|
||
.modal label { display: block; margin-top: 12px; font-size: 12px; color: var(--text-dim); }
|
||
.modal input {
|
||
width: 100%; padding: 8px 10px; margin-top: 4px;
|
||
background: var(--surface-2); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 6px;
|
||
font-family: ui-monospace, monospace; font-size: 12px;
|
||
}
|
||
.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; }
|
||
.modal-actions button {
|
||
padding: 8px 14px; border-radius: 6px; cursor: pointer;
|
||
font-size: 13px; border: 1px solid var(--border);
|
||
background: var(--surface-2); color: var(--text);
|
||
}
|
||
.modal-actions button.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||
|
||
/* mobile */
|
||
@media (max-width: 768px) {
|
||
.app { grid-template-columns: 1fr; }
|
||
.sidebar {
|
||
position: fixed; left: -280px; top: 0; bottom: 0; width: 280px;
|
||
z-index: 50; transition: left .25s;
|
||
}
|
||
.sidebar.open { left: 0; }
|
||
.menu-toggle { display: block; }
|
||
.suggestions { grid-template-columns: 1fr; }
|
||
.topbar { padding: 10px 14px; }
|
||
.chat-area { padding: 14px; }
|
||
.input-area { padding: 10px 14px 14px; }
|
||
.model-select { min-width: 140px; max-width: 200px; }
|
||
}
|
||
|
||
/* scrollbar */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
||
|
||
/* streaming cursor */
|
||
.cursor::after {
|
||
content: '▊'; color: var(--accent); animation: blink 1s steps(2) infinite;
|
||
}
|
||
@keyframes blink { 50% { opacity: 0; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="app">
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="brand">
|
||
<div class="brand-logo">A</div>
|
||
<div>
|
||
<div class="brand-text">Atlas Chat</div>
|
||
<div class="brand-sub">cluster · vault · tools</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn-new" onclick="newConversation()">+ Nieuw gesprek</button>
|
||
|
||
<div class="history" id="history">
|
||
<div class="history-empty">Geen gesprekken</div>
|
||
</div>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="node-status">
|
||
<span class="node-dot" id="dot-atlas01"></span>
|
||
<span>atlas-01</span>
|
||
<span id="lat-atlas01" style="margin-left:auto"></span>
|
||
</div>
|
||
<div class="node-status">
|
||
<span class="node-dot" id="dot-pc"></span>
|
||
<span>pc</span>
|
||
<span id="lat-pc" style="margin-left:auto"></span>
|
||
</div>
|
||
<div class="node-status">
|
||
<span class="node-dot" id="dot-met"></span>
|
||
<span>met</span>
|
||
<span id="lat-met" style="margin-left:auto"></span>
|
||
</div>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-dim);margin-top:8px;cursor:pointer">
|
||
<input type="checkbox" id="rag-toggle" style="cursor:pointer"> Atlas Vault RAG
|
||
</label>
|
||
<button class="settings-btn" onclick="openSettings()">⚙ Instellingen</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN -->
|
||
<main class="main">
|
||
<div class="topbar">
|
||
<button class="menu-toggle" onclick="toggleSidebar()">☰</button>
|
||
<select class="model-select" id="model-select"></select>
|
||
<div class="title-bar"><strong id="title">Atlas Chat</strong></div>
|
||
</div>
|
||
|
||
<div class="chat-area" id="chat">
|
||
<div class="welcome" id="welcome">
|
||
<h1>Atlas Chat</h1>
|
||
<p>Lokale cluster (3 nodes, 25 modellen) of Claude API als je een sleutel hebt gezet.<br>
|
||
Geen Claude-UI nodig.</p>
|
||
<div class="suggestions">
|
||
<div class="suggestion" onclick="suggest(this)">
|
||
<strong>📊 Cash check</strong>
|
||
Wat staat er open in Odoo en wat is de status?
|
||
</div>
|
||
<div class="suggestion" onclick="suggest(this)">
|
||
<strong>📋 Sprint plan</strong>
|
||
Maak een dag-planning voor vandaag op basis van Asana
|
||
</div>
|
||
<div class="suggestion" onclick="suggest(this)">
|
||
<strong>✍️ Outreach</strong>
|
||
Schrijf een cold-WhatsApp voor een Tilburgse barbershop
|
||
</div>
|
||
<div class="suggestion" onclick="suggest(this)">
|
||
<strong>🔧 Server check</strong>
|
||
Welke containers op atlas-01 draaien niet?
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-area">
|
||
<div class="input-wrap">
|
||
<textarea id="input" placeholder="Typ je vraag... (Enter = verstuur, Shift+Enter = nieuwe regel)" rows="1"></textarea>
|
||
<button class="send-btn" id="send-btn" onclick="sendOrStop()">➤</button>
|
||
</div>
|
||
<div class="input-hint">
|
||
<span id="status-hint">Gereed</span>
|
||
<span id="token-hint"></span>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
</div>
|
||
|
||
<!-- SETTINGS MODAL -->
|
||
<div class="modal-bg" id="modal" onclick="if(event.target===this)closeSettings()">
|
||
<div class="modal">
|
||
<h2>Atlas Chat instellingen</h2>
|
||
<label>Anthropic API key (optioneel — voor Claude modellen)</label>
|
||
<input type="password" id="anthropic-key" placeholder="sk-ant-..." />
|
||
<label>Open WebUI JWT (optioneel — voor Vault RAG)</label>
|
||
<input type="password" id="openwebui-jwt" placeholder="eyJ..." />
|
||
<label>System prompt (default)</label>
|
||
<input type="text" id="system-prompt" placeholder="Je bent Atlas — Chaib's AI assistent..." />
|
||
<div class="modal-actions">
|
||
<button onclick="clearStorage()" style="margin-right:auto;color:var(--err)">Wis alles</button>
|
||
<button onclick="closeSettings()">Annuleer</button>
|
||
<button class="primary" onclick="saveSettings()">Opslaan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ===== CONFIG =====
|
||
const NODES = {
|
||
'atlas-01': { url: location.origin + '/ollama', display: 'atlas-01 (vps)' },
|
||
'met': { url: location.origin + '/ollama/met', display: 'met (desktop)' },
|
||
// PC nog niet bereikbaar via tailnet vanaf atlas-01 (Windows bind issue)
|
||
};
|
||
const STORAGE_KEY = 'atlas-chat-v1';
|
||
const SETTINGS_KEY = 'atlas-chat-settings';
|
||
const VAULT_KB_ID = '<YOUR_KB_ID>';
|
||
const OPENWEBUI_URL = 'http://<YOUR_VPS_IP>:8082';
|
||
|
||
// ===== STATE =====
|
||
let state = {
|
||
conversations: {},
|
||
current: null,
|
||
models: [],
|
||
currentModel: null,
|
||
streaming: false,
|
||
abortController: null,
|
||
};
|
||
// Baked Vault JWT (operator's own infra, tailnet-only — expires 2026-06-23)
|
||
const BAKED_JWT = '<YOUR_OPENWEBUI_JWT_HERE>';
|
||
const BAKED_JWT_EXPIRES = '2026-06-23';
|
||
let settings = {
|
||
anthropicKey: '',
|
||
openwebuiJwt: BAKED_JWT,
|
||
systemPrompt: 'Je bent Atlas — Chaib\'s AI assistent. Antwoord direct, NL of EN volgens de vraag, kort en gegrond.',
|
||
};
|
||
|
||
// ===== INIT =====
|
||
async function init() {
|
||
loadStorage();
|
||
loadSettings();
|
||
await refreshNodes();
|
||
await loadModels();
|
||
renderHistory();
|
||
bindInput();
|
||
if (!state.current) newConversation();
|
||
}
|
||
|
||
function loadStorage() {
|
||
try {
|
||
const s = localStorage.getItem(STORAGE_KEY);
|
||
if (s) {
|
||
const d = JSON.parse(s);
|
||
state.conversations = d.conversations || {};
|
||
state.current = d.current || null;
|
||
}
|
||
} catch (e) { console.warn('storage load fail', e); }
|
||
}
|
||
|
||
function saveStorage() {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||
conversations: state.conversations, current: state.current,
|
||
}));
|
||
} catch (e) { console.warn('storage save fail', e); }
|
||
}
|
||
|
||
function loadSettings() {
|
||
try {
|
||
const s = localStorage.getItem(SETTINGS_KEY);
|
||
if (s) settings = { ...settings, ...JSON.parse(s) };
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ===== NODES =====
|
||
async function refreshNodes() {
|
||
for (const [name, conf] of Object.entries(NODES)) {
|
||
const dotId = 'dot-' + name.replace('-', '');
|
||
const latId = 'lat-' + name.replace('-', '');
|
||
const dot = document.getElementById(dotId);
|
||
const lat = document.getElementById(latId);
|
||
const t0 = performance.now();
|
||
try {
|
||
const r = await fetch(conf.url + '/api/tags', { signal: AbortSignal.timeout(4000) });
|
||
if (r.ok) {
|
||
const t = Math.round(performance.now() - t0);
|
||
if (dot) dot.className = 'node-dot ok';
|
||
if (lat) lat.textContent = t + 'ms';
|
||
const d = await r.json();
|
||
conf.models = (d.models || []).map(m => m.name);
|
||
} else throw new Error('http ' + r.status);
|
||
} catch (e) {
|
||
if (dot) dot.className = 'node-dot err';
|
||
if (lat) lat.textContent = 'off';
|
||
conf.models = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== VAULT RAG =====
|
||
async function queryVaultRAG(query, k=3) {
|
||
if (!settings.openwebuiJwt) return null;
|
||
try {
|
||
const r = await fetch(OPENWEBUI_URL + '/api/v1/retrieval/query/collection', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + settings.openwebuiJwt },
|
||
body: JSON.stringify({ collection_names: [VAULT_KB_ID], query, k }),
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (!r.ok) 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}`;
|
||
});
|
||
return docs.join('\n\n---\n\n');
|
||
} catch (e) {
|
||
console.warn('vault RAG fail', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadModels() {
|
||
const sel = document.getElementById('model-select');
|
||
sel.innerHTML = '';
|
||
for (const [name, conf] of Object.entries(NODES)) {
|
||
if (!conf.models) continue;
|
||
for (const m of conf.models) {
|
||
const opt = document.createElement('option');
|
||
opt.value = name + '|' + m;
|
||
opt.textContent = `${m} (${name})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
// add Claude option if key set
|
||
if (settings.anthropicKey) {
|
||
const sep = document.createElement('option');
|
||
sep.disabled = true; sep.textContent = '─── Cloud ───';
|
||
sel.appendChild(sep);
|
||
for (const m of ['claude-sonnet-4-5-20250929', 'claude-opus-4-20250514', 'claude-haiku-4-5']) {
|
||
const opt = document.createElement('option');
|
||
opt.value = 'anthropic|' + m;
|
||
opt.textContent = m + ' (cloud)';
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
// Default = qwen2.5:7b on atlas-01
|
||
const def = Array.from(sel.options).find(o => o.value.includes('qwen2.5:7b'));
|
||
if (def) sel.value = def.value;
|
||
state.currentModel = sel.value;
|
||
sel.onchange = () => { state.currentModel = sel.value; };
|
||
}
|
||
|
||
// ===== CONVERSATIONS =====
|
||
function newConversation() {
|
||
const id = 'c-' + Date.now();
|
||
state.conversations[id] = { id, title: 'Nieuw gesprek', messages: [], created: Date.now() };
|
||
state.current = id;
|
||
saveStorage(); renderHistory(); renderChat();
|
||
document.getElementById('input').focus();
|
||
}
|
||
|
||
function selectConversation(id) {
|
||
state.current = id;
|
||
saveStorage(); renderHistory(); renderChat();
|
||
if (window.innerWidth <= 768) toggleSidebar();
|
||
}
|
||
|
||
function renderHistory() {
|
||
const h = document.getElementById('history');
|
||
const list = Object.values(state.conversations).sort((a,b)=>b.created-a.created);
|
||
if (!list.length) { h.innerHTML = '<div class="history-empty">Geen gesprekken</div>'; return; }
|
||
h.innerHTML = '';
|
||
for (const c of list) {
|
||
const div = document.createElement('div');
|
||
div.className = 'history-item' + (c.id === state.current ? ' active' : '');
|
||
div.textContent = c.title || 'Naamloos';
|
||
div.onclick = () => selectConversation(c.id);
|
||
h.appendChild(div);
|
||
}
|
||
}
|
||
|
||
function renderChat() {
|
||
const chat = document.getElementById('chat');
|
||
const conv = state.conversations[state.current];
|
||
if (!conv || !conv.messages.length) {
|
||
chat.innerHTML = document.getElementById('welcome')?.outerHTML
|
||
|| '<div class="welcome"><p style="color:var(--text-dim)">Stel een vraag.</p></div>';
|
||
document.getElementById('title').textContent = conv?.title || 'Atlas Chat';
|
||
return;
|
||
}
|
||
chat.innerHTML = '';
|
||
for (const m of conv.messages) appendMessage(m, false);
|
||
document.getElementById('title').textContent = conv.title;
|
||
chat.scrollTop = chat.scrollHeight;
|
||
}
|
||
|
||
function appendMessage(m, save=true) {
|
||
const chat = document.getElementById('chat');
|
||
document.getElementById('welcome')?.remove();
|
||
const div = document.createElement('div');
|
||
div.className = 'message ' + m.role + (m.error ? ' error' : '');
|
||
div.dataset.idx = (state.conversations[state.current]?.messages.length || 0) - 1;
|
||
const meta = `<div class="msg-meta"><span class="msg-role ${m.role}">${m.role === 'user' ? 'Jij' : 'Atlas'}</span>${m.model ? `<span class="msg-model">· ${m.model}</span>` : ''}${m.elapsed ? `<span class="msg-model">· ${m.elapsed}s</span>` : ''}</div>`;
|
||
div.innerHTML = meta + `<div class="msg-content">${renderMd(m.content)}</div>`;
|
||
chat.appendChild(div);
|
||
if (save) {
|
||
state.conversations[state.current].messages.push(m);
|
||
saveStorage();
|
||
}
|
||
chat.scrollTop = chat.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function renderMd(text) {
|
||
if (!text) return '';
|
||
const html = marked.parse(text, { breaks: true, gfm: true });
|
||
return DOMPurify.sanitize(html);
|
||
}
|
||
|
||
// ===== SEND =====
|
||
function bindInput() {
|
||
const inp = document.getElementById('input');
|
||
inp.addEventListener('input', () => {
|
||
inp.style.height = 'auto';
|
||
inp.style.height = Math.min(200, inp.scrollHeight) + 'px';
|
||
});
|
||
inp.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendOrStop();
|
||
}
|
||
});
|
||
}
|
||
|
||
function sendOrStop() {
|
||
if (state.streaming) {
|
||
if (state.abortController) state.abortController.abort();
|
||
return;
|
||
}
|
||
const inp = document.getElementById('input');
|
||
const text = inp.value.trim();
|
||
if (!text) return;
|
||
inp.value = ''; inp.style.height = 'auto';
|
||
send(text);
|
||
}
|
||
|
||
async function send(text) {
|
||
const conv = state.conversations[state.current];
|
||
if (!conv) return;
|
||
// First message → set title
|
||
if (!conv.messages.length) {
|
||
conv.title = text.slice(0, 50).trim() + (text.length > 50 ? '…' : '');
|
||
document.getElementById('title').textContent = conv.title;
|
||
renderHistory();
|
||
}
|
||
appendMessage({ role: 'user', content: text });
|
||
|
||
// Vault RAG: if toggle is on + JWT set, fetch context first
|
||
let ragContext = '';
|
||
if (document.getElementById('rag-toggle')?.checked) {
|
||
setStatus('Atlas Vault RAG zoeken…');
|
||
const ctx = await queryVaultRAG(text, 3);
|
||
if (ctx) ragContext = '\n\n[ATLAS VAULT CONTEXT — gebruik dit als bron, citeer source-bestanden indien relevant]\n' + ctx;
|
||
else setStatus('RAG faalde (geen JWT?) — doorgaan zonder vault');
|
||
}
|
||
|
||
// Build messages array
|
||
const msgs = [];
|
||
if (settings.systemPrompt || ragContext) {
|
||
msgs.push({ role: 'system', content: (settings.systemPrompt || '') + ragContext });
|
||
}
|
||
for (const m of conv.messages) {
|
||
if (m.role === 'user' || m.role === 'assistant') {
|
||
msgs.push({ role: m.role, content: m.content });
|
||
}
|
||
}
|
||
|
||
// Pick endpoint
|
||
const [node, model] = state.currentModel.split('|');
|
||
state.streaming = true;
|
||
setSendBtn('stop');
|
||
setStatus('Bezig met ' + model + ' op ' + node + '…');
|
||
|
||
// Placeholder assistant message for streaming
|
||
const t0 = performance.now();
|
||
appendMessage({ role: 'assistant', content: '', model: `${node}/${model}` });
|
||
const asstDiv = document.querySelector('.message.assistant:last-of-type .msg-content');
|
||
asstDiv.classList.add('cursor');
|
||
|
||
state.abortController = new AbortController();
|
||
try {
|
||
if (node === 'anthropic') {
|
||
await streamAnthropic(model, msgs, asstDiv, t0);
|
||
} else {
|
||
await streamOllama(NODES[node], model, msgs, asstDiv, t0);
|
||
}
|
||
// Persist final assistant message
|
||
const finalText = asstDiv.dataset.fulltext || '';
|
||
const lastMsg = conv.messages[conv.messages.length - 1];
|
||
if (lastMsg && lastMsg.role === 'assistant') {
|
||
lastMsg.content = finalText;
|
||
lastMsg.elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||
}
|
||
saveStorage();
|
||
} catch (e) {
|
||
asstDiv.innerHTML = renderMd('**Fout:** ' + (e.message || 'onbekend')) ;
|
||
const lastMsg = conv.messages[conv.messages.length - 1];
|
||
if (lastMsg) { lastMsg.content = '**Fout:** ' + e.message; lastMsg.error = true; }
|
||
saveStorage();
|
||
} finally {
|
||
asstDiv.classList.remove('cursor');
|
||
state.streaming = false;
|
||
state.abortController = null;
|
||
setSendBtn('send');
|
||
setStatus('Gereed');
|
||
}
|
||
}
|
||
|
||
async function streamOllama(nodeConf, model, msgs, div, t0) {
|
||
const r = await fetch(nodeConf.url + '/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model, messages: msgs, stream: true }),
|
||
signal: state.abortController.signal,
|
||
});
|
||
if (!r.ok) throw new Error('Ollama HTTP ' + r.status);
|
||
const reader = r.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', full = '';
|
||
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;
|
||
div.dataset.fulltext = full;
|
||
div.innerHTML = renderMd(full);
|
||
document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;
|
||
}
|
||
if (j.done) {
|
||
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||
setStatus(`Klaar in ${elapsed}s`);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function streamAnthropic(model, msgs, div, t0) {
|
||
const sysMsg = msgs.find(m => m.role === 'system')?.content;
|
||
const filtered = msgs.filter(m => m.role !== 'system');
|
||
const r = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': settings.anthropicKey,
|
||
'anthropic-version': '2023-06-01',
|
||
'anthropic-dangerous-direct-browser-access': 'true',
|
||
},
|
||
body: JSON.stringify({
|
||
model, max_tokens: 4096, system: sysMsg,
|
||
messages: filtered, stream: true,
|
||
}),
|
||
signal: state.abortController.signal,
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.text();
|
||
throw new Error('Anthropic ' + r.status + ': ' + err.slice(0, 200));
|
||
}
|
||
const reader = r.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', full = '';
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
let lines = buf.split('\n');
|
||
buf = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data:')) continue;
|
||
const data = line.slice(5).trim();
|
||
if (data === '[DONE]') continue;
|
||
try {
|
||
const j = JSON.parse(data);
|
||
if (j.type === 'content_block_delta' && j.delta?.text) {
|
||
full += j.delta.text;
|
||
div.dataset.fulltext = full;
|
||
div.innerHTML = renderMd(full);
|
||
document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== UI HELPERS =====
|
||
function setSendBtn(mode) {
|
||
const b = document.getElementById('send-btn');
|
||
if (mode === 'stop') { b.classList.add('stop'); b.textContent = '◼'; }
|
||
else { b.classList.remove('stop'); b.textContent = '➤'; }
|
||
}
|
||
function setStatus(t) { document.getElementById('status-hint').textContent = t; }
|
||
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
|
||
function suggest(el) {
|
||
const inp = document.getElementById('input');
|
||
inp.value = el.querySelector('strong').nextSibling.textContent.trim();
|
||
inp.focus(); inp.dispatchEvent(new Event('input'));
|
||
}
|
||
|
||
// ===== SETTINGS =====
|
||
function openSettings() {
|
||
document.getElementById('anthropic-key').value = settings.anthropicKey;
|
||
document.getElementById('openwebui-jwt').value = settings.openwebuiJwt;
|
||
document.getElementById('system-prompt').value = settings.systemPrompt;
|
||
document.getElementById('modal').classList.add('open');
|
||
}
|
||
function closeSettings() { document.getElementById('modal').classList.remove('open'); }
|
||
function saveSettings() {
|
||
settings.anthropicKey = document.getElementById('anthropic-key').value.trim();
|
||
settings.openwebuiJwt = document.getElementById('openwebui-jwt').value.trim();
|
||
settings.systemPrompt = document.getElementById('system-prompt').value.trim();
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
closeSettings();
|
||
loadModels();
|
||
}
|
||
function clearStorage() {
|
||
if (!confirm('Alles wissen (gesprekken + instellingen)?')) return;
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
localStorage.removeItem(SETTINGS_KEY);
|
||
location.reload();
|
||
}
|
||
|
||
// ===== START =====
|
||
init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|