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();
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Research Agent</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icons/icon-192.png">
<meta name="theme-color" content="#0f172a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
document.documentElement.classList.add('dark');
</script>
</head>
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 h-screen flex overflow-hidden">
<!-- Sidebar -->
<aside id="sidebar" class="w-64 shrink-0 dark:bg-slate-900 bg-white border-r dark:border-slate-800 border-slate-200 flex flex-col hidden md:flex">
<div class="p-4 border-b dark:border-slate-800 border-slate-200 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl">🔎</span>
<span class="font-bold">Research Agent</span>
</div>
<button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button>
</div>
<button id="newSessionBtn"
class="m-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
+ Neue Recherche
</button>
<div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div>
<div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200">
<div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div>
<div id="queueStatus" class="hidden"><span class="inline-block w-2 h-2 bg-amber-400 rounded-full mr-1 animate-pulse"></span>Recherche läuft…</div>
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Mobile header -->
<div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
<button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button>
<span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span>
</div>
<!-- Activity bar -->
<div id="activityBar" class="hidden px-4 py-2 text-sm border-b dark:border-slate-800 border-slate-200 bg-slate-50 dark:bg-slate-900">
<div id="activityLine" class="flex items-center gap-2">
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
<span id="activityText">Starte…</span>
</div>
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
</div>
<!-- Chat -->
<div id="chat" class="flex-1 overflow-y-auto p-4 space-y-4">
<div id="emptyState" class="h-full flex flex-col items-center justify-center text-center text-slate-400">
<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>
</div>
</div>
<!-- Input -->
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
<form id="chatForm" class="flex gap-2 items-end">
<textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“"
class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea>
<button id="sendBtn" type="submit"
class="px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-semibold">
Senden
</button>
</form>
<p class="text-xs text-slate-400 mt-2" id="shareHint"></p>
</div>
</main>
<script src="/app.js"></script>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Research Agent – Anmeldung</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icons/icon-192.png">
<meta name="theme-color" content="#0f172a">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
document.documentElement.classList.add('dark');
</script>
</head>
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="dark:bg-slate-900 bg-white rounded-2xl shadow-xl p-8 border dark:border-slate-800 border-slate-200">
<div class="text-center mb-6">
<div class="text-4xl mb-2">🔎</div>
<h1 class="text-xl font-bold">Research Agent</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Dein Web-Researcher, überall erreichbar</p>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" for="name">Dein Name</label>
<input id="name" type="text" required placeholder="z.B. Nico" autocomplete="name"
class="w-full px-3 py-2 rounded-lg border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium mb-1" for="password">Passwort</label>
<input id="password" type="password" required autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<p id="error" class="hidden text-sm text-red-500"></p>
<button type="submit"
class="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition">
Anmelden
</button>
</form>
<p class="text-xs text-center text-slate-400 mt-6">
Läuft auf deinem eigenen Server · DeepSeek v4 Flash
</p>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
const nameField = document.getElementById('name');
const passwordField = document.getElementById('password');
form.addEventListener('submit', async (e) => {
e.preventDefault();
error.classList.add('hidden');
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameField.value, password: passwordField.value }),
});
if (res.ok) {
location.href = '/';
} else {
const data = await res.json().catch(() => ({}));
error.textContent = data.error || 'Anmeldung fehlgeschlagen';
error.classList.remove('hidden');
}
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Research Agent",
"short_name": "Research",
"description": "Web-Recherche mit DeepSeek v4 Flash – verifizierte Quellen, Analyse und Dashboards",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"lang": "de",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte Recherche</title>
<link rel="icon" href="/icons/icon-192.png">
<meta name="theme-color" content="#0f172a">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
document.documentElement.classList.add('dark');
</script>
</head>
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 min-h-screen">
<div class="max-w-3xl mx-auto p-4 md:p-8">
<header class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<span class="text-2xl">🔎</span>
<span class="font-bold">Research Agent</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">geteilt</span>
</div>
<span id="dateLabel" class="text-sm text-slate-400"></span>
</header>
<div id="loading" class="text-center py-16 text-slate-400">Lade Recherche…</div>
<div id="content" class="hidden space-y-4"></div>
<footer class="mt-8 text-center text-xs text-slate-500">
<p>Generiert mit dem Research Agent · DeepSeek v4 Flash</p>
</footer>
</div>
<script>
const content = document.getElementById('content');
const loading = document.getElementById('loading');
const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const 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(/\[([^\]]+)\]\((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>');
};
const toolLabel = (m) => {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
const icon = icons[m.tool_name] || '🔧';
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
return { icon, text: (text || '').slice(0, 200) };
};
const renderDashboardCard = (url) => `
<div class="dark:bg-slate-800 bg-white rounded-2xl border dark:border-slate-700 border-slate-200 overflow-hidden">
<div class="p-4 flex flex-wrap items-center gap-3">
<div class="flex-1 min-w-[200px]">
<div class="text-sm font-semibold">📊 Dashboard</div>
<div class="text-xs text-slate-400">Analyse, verifizierte Quellen, Empfehlungen & Handlungsplan</div>
</div>
<a href="${url}" target="_blank" rel="noopener noreferrer"
class="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="px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
</div>
<iframe src="${url}" class="w-full border-t dark:border-slate-700 border-slate-200" style="height: 65vh; min-height: 400px;" loading="lazy"></iframe>
</div>`;
async function load() {
const token = location.pathname.split('/').pop();
const res = await fetch(`/api/share/${token}`);
if (!res.ok) {
loading.textContent = '⚠️ Dieser Link ist ungültig oder wurde gelöscht.';
return;
}
const { session, messages } = await res.json();
loading.classList.add('hidden');
content.classList.remove('hidden');
document.title = `${session.title} – Research Agent`;
document.getElementById('dateLabel').textContent = new Date(session.created_at).toLocaleDateString('de-DE');
content.innerHTML = `
<h1 class="text-2xl font-bold">${escapeHtml(session.title)}</h1>
${session.dashboard ? renderDashboardCard(session.dashboard) : ''}`;
const chat = document.createElement('div');
chat.className = 'space-y-3 mt-2';
for (const m of messages) {
if (m.role === 'user') {
chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
} else if (m.role === 'assistant') {
if (m.content)
chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
} else if (m.role === 'tool') {
const { icon, text } = toolLabel(m);
chat.innerHTML += `<div class="flex"><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></div>`;
}
}
content.appendChild(chat);
}
load();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
self.addEventListener('push', (e) => {
let data = { title: '🔎 Research Agent', body: 'Eine Recherche ist fertig!' };
try {
data = JSON.parse(e.data.text());
} catch {}
e.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
tag: 'research-done',
data: { url: data.url || '/' },
})
);
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const url = e.notification.data?.url || '/';
e.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ('focus' in client) return client.focus();
}
return self.clients.openWindow(url);
})
);
});