const state = {
me: null,
sessions: [],
current: null,
stream: null,
streamingEl: null,
activityShown: false,
};
const $ = (id) => document.getElementById(id);
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function mdLite(text) {
let s = escapeHtml(text);
s = s.replace(/`([^`]+)`/g, '$1');
s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
s = s.replace(/\*([^*]+)\*/g, '$1');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1');
s = s.replace(/https?:\/\/[^\s<]+/g, '$&');
return s.replace(/\n/g, '
');
}
function toolLabel(msg) {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
const icon = icons[msg.tool_name] || '🔧';
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
return { icon, text: text?.slice(0, 200) };
}
// ---------------------------------------------------------------------------
// Session-Liste / Sidebar
// ---------------------------------------------------------------------------
async function loadSessions() {
const res = await fetch('/api/sessions');
state.sessions = await res.json();
renderSessions();
}
function renderSessions() {
const list = $('sessionList');
list.innerHTML = '';
for (const s of state.sessions) {
const active = state.current?.id === s.id;
const dot =
s.job === 'running' || s.job === 'queued'
? ''
: s.status === 'done'
? ''
: '';
const btn = document.createElement('button');
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${
active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800'
}`;
btn.innerHTML = `${dot}${escapeHtml(s.title)}`;
btn.onclick = () => openSession(s.id);
list.appendChild(btn);
}
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
$('queueStatus').classList.toggle('hidden', !qs);
}
// ---------------------------------------------------------------------------
// Chat-Rendering
// ---------------------------------------------------------------------------
function addUserMessage(text) {
$('emptyState')?.remove();
const el = document.createElement('div');
el.className = 'flex justify-end';
el.innerHTML = `
Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein Dashboard mit Analyse, Empfehlungen und Handlungsplan.
`; $('chat').appendChild(empty); } addDashboardCard(session); for (const m of messages) { if (m.role === 'user') addUserMessage(m.content); else if (m.role === 'assistant') { if (m.content) addAssistantBubble(m.content); } else if (m.role === 'tool') { const { icon, text } = toolLabel(m); addToolLine(icon, text); } } if (session.cost_estimate > 0) { addUsageLine(session.prompt_tokens, session.completion_tokens, session.cost_estimate); } scrollDown(); } function scrollDown() { const chat = $('chat'); chat.scrollTop = chat.scrollHeight; } // --------------------------------------------------------------------------- // Activity-Bar (Job-Fortschritt) // --------------------------------------------------------------------------- function showActivity() { $('activityBar').classList.remove('hidden'); $('activityLog').classList.add('hidden'); $('toggleLog').textContent = 'Details zeigen'; state.activityShown = true; } function setActivity(text, icon = '') { $('activityText').textContent = `${icon} ${text}`; } function logActivity(text) { const div = document.createElement('div'); div.textContent = text; $('activityLog').appendChild(div); $('activityLog').scrollTop = $('activityLog').scrollHeight; } function hideActivity() { $('activityBar').classList.add('hidden'); state.activityShown = false; } $('toggleLog').onclick = () => { const log = $('activityLog'); const hidden = log.classList.toggle('hidden'); $('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen'; }; // --------------------------------------------------------------------------- // SSE-Stream // --------------------------------------------------------------------------- function connectStream(sessionId) { state.stream?.close(); const es = new EventSource(`/api/sessions/${sessionId}/stream`); state.stream = es; es.addEventListener('status', (e) => { const d = JSON.parse(e.data); if (d.sessionStatus === 'running' || d.jobStatus === 'queued') showActivity(); if (d.sessionStatus === 'done') { setTimeout(hideActivity, 1500); loadSessions(); } }); es.addEventListener('progress', (e) => { const d = JSON.parse(e.data); showActivity(); setActivity(d.text, d.icon); }); es.addEventListener('step', (e) => { logActivity(JSON.parse(e.data).text); }); es.addEventListener('thinking', (e) => { const d = JSON.parse(e.data); setActivity('Denke nach…', '🧠'); logActivity(d.text.slice(0, 300)); }); es.addEventListener('token', (e) => { const d = JSON.parse(e.data); showActivity(); setActivity('Schreibe Antwort…', '✍️'); if (!state.streamingEl) { state.streamingEl = addAssistantBubble('', true); } state.streamingEl.innerHTML = mdLite(state.streamingEl.textContent + d.text); scrollDown(); }); es.addEventListener('done', async (e) => { const d = JSON.parse(e.data); if (state.streamingEl) { state.streamingEl.innerHTML = mdLite(d.text || state.streamingEl.textContent); state.streamingEl = null; } else if (d.text) { addAssistantBubble(d.text); } hideActivity(); if (d.usage) { addUsageLine(d.usage.promptTokens, d.usage.completionTokens, d.costEstimate); } if (d.dashboardUrl) { const card = document.createElement('div'); card.className = 'flex'; card.innerHTML = ` `; $('chat').appendChild(card); scrollDown(); notify(`"${sessionTitle()}" – Dashboard ist bereit`); } loadSessions(); $('sendBtn').disabled = false; }); es.addEventListener('error', (e) => { if (!e.data) return; const d = JSON.parse(e.data); addToolLine('⚠️', d.text || 'Ein Fehler ist aufgetreten'); hideActivity(); $('sendBtn').disabled = false; loadSessions(); }); es.onerror = () => { setTimeout(() => { if (state.current) connectStream(state.current.id); }, 4000); }; } function sessionTitle() { return state.current?.title || 'Recherche'; } // --------------------------------------------------------------------------- // Benachrichtigungen + Push // --------------------------------------------------------------------------- function notify(body) { if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('✅ Recherche fertig', { body, icon: '/icons/icon-192.png' }); } catch {} } } async function enableNotifications() { if (!('Notification' in window) || !('serviceWorker' in navigator)) return; if (Notification.permission === 'denied') return; if (Notification.permission !== 'granted') { try { await Notification.requestPermission(); } catch { return; } } try { const reg = await navigator.serviceWorker.ready; const { key } = await (await fetch('/api/push/vapid-key')).json(); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key, }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub.toJSON()), }); } catch (e) { console.error('Push-Subscription fehlgeschlagen:', e); } } // --------------------------------------------------------------------------- // Session-Verwaltung // --------------------------------------------------------------------------- async function openSession(id) { state.stream?.close(); state.streamingEl = null; hideActivity(); const res = await fetch(`/api/sessions/${id}`); const data = await res.json(); state.current = data.session; renderMessages(data.messages, data.session); $('mobileTitle').textContent = data.session.title; renderSessions(); const q = state.sessions.find((s) => s.id === id); if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') { showActivity(); } updateShareHint(); connectStream(id); } async function newSession() { const res = await fetch('/api/sessions', { method: 'POST' }); const session = await res.json(); await loadSessions(); await openSession(session.id); $('input').focus(); } function copyText(text, btn, label) { try { navigator.clipboard.writeText(text).then( () => { btn.textContent = '✅ Link kopiert!'; setTimeout(() => (btn.textContent = label), 2500); }, () => { window.prompt(label, text); btn.textContent = label; } ); } catch { window.prompt(label, text); btn.textContent = label; } } function updateShareHint() { $('shareHint').innerHTML = ''; if (!state.current) return; const btn = document.createElement('button'); btn.className = 'text-blue-400 hover:underline text-xs'; btn.textContent = '🔗 Chat teilen'; btn.onclick = async () => { const res = await fetch(`/api/sessions/${state.current.id}/share`); if (!res.ok) { btn.textContent = '⚠️ Fehler beim Teilen'; setTimeout(() => (btn.textContent = '🔗 Chat teilen'), 2500); return; } const { fullUrl } = await res.json(); copyText(fullUrl, btn, '🔗 Chat teilen'); }; $('shareHint').appendChild(btn); if (state.current.dashboard) { $('shareHint').appendChild(document.createTextNode(' · ')); const dashBtn = document.createElement('button'); dashBtn.className = 'text-emerald-400 hover:underline text-xs'; dashBtn.textContent = '📊 Dashboard teilen'; dashBtn.onclick = async () => { const full = `${location.origin}${state.current.dashboard}`; copyText(full, dashBtn, '📊 Dashboard teilen'); }; $('shareHint').appendChild(dashBtn); } } // --------------------------------------------------------------------------- // Senden // --------------------------------------------------------------------------- async function sendMessage(text) { if (!state.current) await newSession(); $('sendBtn').disabled = true; addUserMessage(text); const res = await fetch(`/api/sessions/${state.current.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); addToolLine('⚠️', data.error || 'Senden fehlgeschlagen'); $('sendBtn').disabled = false; return; } enableNotifications(); showActivity(); setActivity('In Warteschlange…', '⏳'); await loadSessions(); const s = state.sessions.find((x) => x.id === state.current.id); if (s?.title) state.current.title = s.title; connectStream(state.current.id); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- window.addEventListener('load', async () => { const meRes = await fetch('/api/me'); if (!meRes.ok) return (location.href = '/login.html'); state.me = await meRes.json(); $('userName').textContent = `👤 ${state.me.name}`; if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } await loadSessions(); const params = new URLSearchParams(location.search); const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id; if (target) await openSession(target); if (!target) { const s = await (await fetch('/api/sessions', { method: 'POST' })).json(); await openSession(s.id); } }); $('chatForm').addEventListener('submit', (e) => { e.preventDefault(); const text = $('input').value.trim(); if (!text) return; $('input').value = ''; $('input').style.height = 'auto'; sendMessage(text); }); $('input').addEventListener('input', () => { $('input').style.height = 'auto'; $('input').style.height = Math.min($('input').scrollHeight, 160) + 'px'; }); $('newSessionBtn').onclick = newSession; $('menuBtn').onclick = () => $('sidebar').classList.toggle('hidden'); $('logoutBtn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login.html'; }; // Enter sendet, Shift+Enter = Zeilenumbruch $('input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('chatForm').requestSubmit(); } });