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

840 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>