Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)

This commit is contained in:
nico committed 2026-08-26 16:34:03 +02:00
commit a0243d494c
23 files changed
+2253

No files matched your search

+503
View File
@@ -0,0 +1,503 @@
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function mdLite(text) {
let s = escapeHtml(text);
s = s.replace(/`([^`]+)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700 text-amber-200 text-xs">$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$1</a>');
s = s.replace(/https?:\/\/[^\s<]+/g, '<a href="$&" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$&</a>');
return s.replace(/\n/g, '<br>');
}
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'
? '<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse inline-block mr-2"></span>'
: s.status === 'done'
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
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}<span class="truncate">${escapeHtml(s.title)}</span>`;
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 = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(text)}</div>`;
$('chat').appendChild(el);
scrollDown();
}
function addAssistantBubble(text, streaming = false) {
$('emptyState')?.remove();
const el = document.createElement('div');
el.className = 'flex';
el.innerHTML = `<div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words ${
streaming ? 'streaming' : ''
}"></div>`;
const body = el.firstChild;
body.innerHTML = mdLite(text);
$('chat').appendChild(el);
scrollDown();
return body;
}
function addToolLine(icon, text) {
$('emptyState')?.remove();
const el = document.createElement('div');
el.className = 'flex justify-start';
el.innerHTML = `<div class="text-xs text-slate-400 px-1 flex items-center gap-1"><span>${icon}</span><span class="break-all">${escapeHtml(text)}</span></div>`;
$('chat').appendChild(el);
scrollDown();
}
function addDashboardCard(session) {
const url = session.dashboard;
if (!url) return;
const el = document.createElement('div');
el.className = 'flex';
el.innerHTML = `
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
<a href="${url}" target="_blank" rel="noopener noreferrer"
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
<a href="${url}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
</div>`;
$('chat').appendChild(el);
scrollDown();
}
function addUsageLine(promptTokens, completionTokens, cost) {
const el = document.createElement('div');
el.className = 'flex justify-end';
el.innerHTML = `<div class="text-xs text-slate-500 dark:text-slate-500 px-1">🧮 ${formatTokens(promptTokens)} Eingabe · ${formatTokens(
completionTokens
)} Ausgabe · ≈ ${Number(cost || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 4 })} USD</div>`;
$('chat').appendChild(el);
scrollDown();
}
function formatTokens(n) {
return Number(n || 0).toLocaleString('de-DE');
}
function renderMessages(messages, session) {
$('chat').innerHTML = '';
if (!messages.length) {
const empty = document.createElement('div');
empty.className =
'h-full flex flex-col items-center justify-center text-center text-slate-400';
empty.innerHTML = `
<div class="text-5xl mb-4">🔎</div>
<h2 class="text-lg font-semibold text-slate-500 dark:text-slate-300">Worüber soll ich recherchieren?</h2>
<p class="text-sm max-w-md mt-2">
Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein
Dashboard mit Analyse, Empfehlungen und Handlungsplan.
</p>`;
$('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 = `
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
<a href="${d.dashboardUrl}" target="_blank" rel="noopener noreferrer"
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
<a href="${d.dashboardUrl}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
</div>`;
$('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();
}
});