Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)
This commit is contained in:
commit
a0243d494c
23 files changed
+2253
No files matched your search
+503
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user