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

+6
View File
@@ -0,0 +1,6 @@
node_modules
data
dashboards
.env
.git
*.log
+19
View File
@@ -0,0 +1,19 @@
# --- Pflicht ---
# DeepSeek API Key (https://platform.deepseek.com/api_keys)
DEEPSEEK_API_KEY=sk-xxxx
# Gemeinsames Login-Passwort für die App (hinter NGINX mit IP-Prüfung)
APP_PASSWORD=ein-sicheres-passwort
# --- Optional (werden automatisch erzeugt wenn fehlend) ---
# Geheimer Schlüssel zum Signieren der Login-Cookies
SESSION_SECRET=
# Port der App (NGINX proxied auf diesen; in Docker per EXPOSE/PORTS)
PORT=3000
# DeepSeek Modell
MODEL=deepseek-v4-flash
# Thinking-Mode des Modells: enabled | disabled
THINKING=enabled
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
dashboards/*.html
.env
+18
View File
@@ -0,0 +1,18 @@
# Research Chat – Web-App mit Playwright-Chromium
FROM node:26-slim
# Playwright-Systemabhängigkeiten (Chromium) werden mit --with-deps installiert
WORKDIR /app
# Erst Abhängigkeiten (Docker-Layer-Cache)
COPY package.json package-lock.json* ./
RUN npm install --no-fund --no-audit
# Chromium + Systemdeps installieren (--with-deps braucht apt-get, in slim enthalten)
RUN npx playwright install --with-deps chromium
# App-Code
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
+186
View File
@@ -0,0 +1,186 @@
# Research Agent – Web-Chat-Oberfläche
Browser-Chatoberfläche für den Research-Agent: DeepSeek v4 Flash recherchiert per Playwright im
Internet, verifiziert alle Links und generiert HTML-Dashboards – ohne dass Opencode lokal laufen muss.
## Funktionen
- 💬 Chat-Oberfläche mit Live-Fortschritt (SSE): Suchen, Seitenbesuche, Link-Verifizierung in Echtzeit
- 🔎 Volle Research-Funktion: DuckDuckGo-Suche, Seiten lesen, Links verifizieren, HTML-Dashboard generieren
- 🔔 Web-Push-Benachrichtigung, wenn die Recherche fertig ist (auch wenn der Browser zu ist)
- 📱 PWA: installierbar auf Desktop & Handy (Chrome/Android; iOS 16.4+ für Push)
- 👤 Eine gemeinsame Anmeldung (Name + Passwort), Sessions sind dem Namen zugeordnet
- 🔗 Sessions als read-only Teil-Link ohne Login teilbar
- 📦 SQLite-Speicherung, keine externe Datenbank nötig
## Setup
```bash
cd webapp
npm install
cp .env.example .env
# .env editieren: DEEPSEEK_API_KEY und APP_PASSWORD setzen (Pflicht)
npx playwright install chromium # falls Chromium fehlt
npm start # läuft auf Port 3000
```
Fehlende Werte wie `SESSION_SECRET` und die VAPID-Keys werden beim ersten Start automatisch
erzeugt und in die `.env` geschrieben.
## Konfiguration (.env)
| Variable | Pflicht | Beschreibung |
|---|---|---|
| `DEEPSEEK_API_KEY` | ja | Key von https://platform.deepseek.com/api_keys |
| `APP_PASSWORD` | ja | Gemeinsames Login-Passwort |
| `PORT` | nein | Standard 3000 |
| `MODEL` | nein | Standard `deepseek-v4-flash` |
| `THINKING` | nein | `enabled` (Standard) oder `disabled` |
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
## Deployment mit Docker/Podman (empfohlen)
Alles läuft in einem Container (Node + Playwright-Chromium). Auf dem Server reicht:
```bash
git clone <repo-url> researchChat
cd researchChat
cp .env.example .env
# .env editieren: DEEPSEEK_API_KEY und APP_PASSWORD setzen
```
**Mit Docker:**
```bash
docker compose up -d
```
**Mit Podman (Bazzite/Fedora Atomic):**
```bash
podman compose up -d
```
Beim ersten Start wird das Image gebaut (npm install + Chromium-Download, dauert ein paar Minuten).
Danach läuft die App auf Port 3000. Daten bleiben über die Volumes erhalten:
| Host-Verzeichnis | Inhalt |
|---|---|
| `./data/` | SQLite-Datenbank (Sessions, Nachrichten, Push-Abos) |
| `./dashboards/` | Generierte HTML-Dashboards |
| `./.env` | Konfiguration (generierte Secrets werden ergänzt) |
Nützliche Befehle:
```bash
podman compose logs -f # Logs ansehen
podman compose restart # Neustart
podman compose pull # neues Image holen (nach Rebuild)
```
## Alternativ: Direkt mit Node (ohne Container)
```bash
npm install
npx playwright install --with-deps chromium # Chromium + Systemdeps
npm start
```
## Optional: NGINX davor (Reverse Proxy + IP-Prüfung + HTTPS)
### systemd-Unit (`/etc/systemd/system/research-web.service`, falls ohne Docker)
```ini
[Unit]
Description=Research Agent WebApp
After=network.target
[Service]
WorkingDirectory=/var/home/nico/dev/researchChat
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now research-web
```
**Mit Docker/Podman** entfällt die systemd-Unit — `restart: unless-stopped` in der
docker-compose.yaml übernimmt das. Der NGINX-Block bleibt der gleiche:
### NGINX (Reverse Proxy + IP-Prüfung + HTTPS)
```nginx
server {
listen 443 ssl;
server_name research.deinedomain.de;
# SSL-Zertifikat (certbot --nginx)
ssl_certificate /etc/letsencrypt/live/research.deinedomain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/research.deinedomain.de/privkey.pem;
# IP-Prüfung: nur deine IPs kommen rein
allow 123.45.67.89; # deine Heim-IP
allow 10.0.0.0/8; # ggf. Heimnetz/VPN
# deny all; # Kommentar entfernen, wenn IP-Check aktiv sein soll
# (für /share/ entfernen, damit Teil-Links extern funktionieren)
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade; # SSE/EventSource
proxy_set_header Connection "upgrade";
proxy_buffering off; # wichtig für Live-Streaming
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
Tipp: Möchte man Teil-Links (`/share/...`) auch für Personen außerhalb der eigenen IP
freigeben, kann man die `allow/deny`-Regeln nur für `location /share/` weglassen
(und `location /` mit deny schützen) – die Teil-Ansicht ist read-only und benötigt keinen Login.
## Teilen
In jeder Session gibt es „🔗 Teilen-Link kopieren“. Der Link zeigt den Chat-Verlauf und das
Dashboard read-only, ganz ohne Login.
## Projektstruktur
```
researchChat/
├── Dockerfile # Node + Playwright-Chromium im Container
├── docker-compose.yaml # Ein-Befehl-Deployment (Docker & Podman)
├── server.js # Express-App: Auth, APIs, SSE, Web Push, statische Dateien
├── lib/
│ ├── config.js # .env-Laden, Secret/VAPID-Autogenerierung
│ ├── db.js # SQLite (node:sqlite, keine nativen Module)
│ ├── tools.js # Playwright-Tools: search, browse, verify, save_dashboard
│ ├── research.js # DeepSeek Tool-Calling-Loop (Agent)
│ └── queue.js # FIFO-Job-Queue (1 Recherche gleichzeitig)
├── public/ # Frontend (Vanilla JS + Tailwind CDN)
│ ├── index.html # Chat-UI
│ ├── login.html # Anmeldung
│ ├── share.html # Read-only Teil-Ansicht
│ ├── app.js
│ ├── sw.js # Service Worker (Push + Notifications)
│ └── manifest.json # PWA-Manifest
├── dashboards/ # generierte HTML-Dashboards (Host-Volume)
├── data/research.db # SQLite-Datenbank (Host-Volume)
└── scripts/make-icons.js
```
## Hinweise
- Recherchen dauern mehrere Minuten – die Fortschrittsleiste zeigt den aktuellen Schritt
- Nur **eine** Recherche läuft gleichzeitig; weitere Anfragen zeigen „Recherche läuft…“
- DuckDuckGo blockiert nach ~4-5 Suchen: Der Agent wechselt automatisch auf Fallback-Suchmaschinen
- Der Browser-Context bleibt pro Server-Lauf erhalten (weniger CAPTCHAs durch stabile Session-Cookies)
- API-Kosten: DeepSeek v4 Flash kostet ~$0,44/M Input- und ~$0,66/M Output-Token – eine Recherche kostet Cent-Beträge
- Auf SELinux-Systemen (Bazzite/Fedora Atomic) sind die Volume-Mounts mit `:Z` markiert – Docker ignoriert das Flag, dort reicht die Compose-Datei unverändert
+24
View File
@@ -0,0 +1,24 @@
services:
research:
build: .
container_name: research-chat
restart: unless-stopped
ports:
- "3000:3000"
env_file: .env
environment:
# Port im Container immer 3000 (auch wenn PORT in .env anders ist)
PORT: "3000"
volumes:
# Persistente Daten (SQLite, generierte Secrets in .env) und Dashboards.
# :Z nötig für SELinux-Systeme (Bazzite/Fedora Atomic), damit der Container
# die Host-Dateien lesen kann. Docker ignoriert das Flag.
- ./data:/app/data:Z
- ./dashboards:/app/dashboards:Z
- ./.env:/app/.env:Z
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 5
start_period: 20s
+63
View File
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const webpush = require('web-push');
const ENV_PATH = path.join(__dirname, '..', '.env');
function loadEnv() {
const env = {};
if (fs.existsSync(ENV_PATH)) {
for (const line of fs.readFileSync(ENV_PATH, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !line.trim().startsWith('#')) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
return env;
}
function saveEnv(env) {
const lines = [];
for (const [k, v] of Object.entries(env)) lines.push(`${k}=${v}`);
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
}
function ensure(key, generator) {
if (process.env[key] || env[key]) return process.env[key] || env[key];
const value = generator();
env[key] = value;
saveEnv(env);
return value;
}
const env = loadEnv();
function ensureVapidPair() {
if ((process.env.VAPID_PUBLIC_KEY || env.VAPID_PUBLIC_KEY) && (process.env.VAPID_PRIVATE_KEY || env.VAPID_PRIVATE_KEY)) {
return {
publicKey: process.env.VAPID_PUBLIC_KEY || env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY || env.VAPID_PRIVATE_KEY,
};
}
const pair = webpush.generateVAPIDKeys();
env.VAPID_PUBLIC_KEY = pair.publicKey;
env.VAPID_PRIVATE_KEY = pair.privateKey;
saveEnv(env);
return pair;
}
const vapid = ensureVapidPair();
const config = {
port: Number(process.env.PORT || env.PORT || 3000),
apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '',
appPassword: process.env.APP_PASSWORD || env.APP_PASSWORD || '',
model: process.env.MODEL || env.MODEL || 'deepseek-v4-flash',
thinking: process.env.THINKING || env.THINKING || 'enabled',
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey,
};
module.exports = { config };
+194
View File
@@ -0,0 +1,194 @@
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const crypto = require('crypto');
const DATA_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DATA_DIR, 'research.db');
const fs = require('fs');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT 'Neue Recherche',
owner_name TEXT NOT NULL,
share_token TEXT UNIQUE,
status TEXT DEFAULT 'idle',
dashboard TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_name TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL,
endpoint TEXT UNIQUE NOT NULL,
keys TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const now = () => new Date().toISOString();
const uid = () => crypto.randomBytes(12).toString('hex');
const shareToken = () => crypto.randomBytes(16).toString('hex');
// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung
const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
function computeCost(u) {
return (
((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
(u.completionTokens || 0) * PRICES.output) /
1e6
);
}
// Migration: Usage-Spalten ergänzen (falls Tabelle älter ist)
{
const cols = db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name);
const add = (name, def) => {
if (!cols.includes(name)) db.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${def}`);
};
add('prompt_tokens', 'INTEGER DEFAULT 0');
add('completion_tokens', 'INTEGER DEFAULT 0');
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL DEFAULT 0');
}
const stmts = {
createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?)`
),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
listSessions: db.prepare(`SELECT * FROM sessions ORDER BY updated_at DESC`),
updateSession: db.prepare(
`UPDATE sessions SET title = ?, status = ?, dashboard = ?, updated_at = ? WHERE id = ?`
),
setSessionStatus: db.prepare(
`UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?`
),
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
addUsage: db.prepare(
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
cost_estimate = cost_estimate + ? WHERE id = ?`
),
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
insertMessage: db.prepare(
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
updateMessageContent: db.prepare(`UPDATE messages SET content = ? WHERE id = ?`),
deleteMessages: db.prepare(`DELETE FROM messages WHERE session_id = ?`),
insertPushSub: db.prepare(
`INSERT INTO push_subscriptions (user_name, endpoint, keys, created_at) VALUES (?, ?, ?, ?)`
),
deletePushSub: db.prepare(`DELETE FROM push_subscriptions WHERE endpoint = ?`),
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
};
function createSession(ownerName, title = 'Neue Recherche') {
const id = uid();
stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
return stmts.getSession.get(id);
}
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
stmts.insertMessage.run(
sessionId,
role,
content ?? null,
toolName ?? null,
toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null,
now()
);
return stmts.lastMessage.get(sessionId);
}
function getSessionMessages(sessionId) {
return stmts.listMessages.all(sessionId).map((m) => ({
...m,
tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : null,
}));
}
/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
function toApiMessages(sessionId) {
const msgs = getSessionMessages(sessionId);
const out = [];
for (const m of msgs) {
if (m.role === 'user') out.push({ role: 'user', content: m.content });
else if (m.role === 'assistant') {
const msg = { role: 'assistant' };
if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.content) msg.content = m.content;
else if (!m.tool_calls) msg.content = '';
out.push(msg);
} else if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
}
}
return out.slice(-60);
}
module.exports = {
db,
createSession,
createMessage,
getSession: stmts.getSession.get.bind(stmts.getSession),
getSessionByShare: stmts.getSessionByShare.get.bind(stmts.getSessionByShare),
listSessions: stmts.listSessions.all.bind(stmts.listSessions),
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
setSessionUsage: (id, u) =>
stmts.addUsage.run(
u.promptTokens || 0,
u.completionTokens || 0,
u.cacheHitTokens || 0,
u.cacheMissTokens || 0,
computeCost(u),
id
),
computeCost,
deleteSession: (id) => stmts.deleteSession.run(id),
getSessionMessages,
toApiMessages,
upsertPushSub(userName, endpoint, keys) {
try {
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
} catch {
stmts.deletePushSub.run(endpoint);
stmts.insertPushSub.run(userName, endpoint, JSON.stringify(keys), now());
}
},
removePushSub: stmts.deletePushSub.run.bind(stmts.deletePushSub),
listPushSubs: stmts.listPushSubs.all.bind(stmts.listPushSubs),
};
+48
View File
@@ -0,0 +1,48 @@
const { EventEmitter } = require('node:events');
const events = new EventEmitter();
events.setMaxListeners(100);
let queue = [];
let running = false;
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
function enqueue(job) {
queue.push(job);
events.emit('job-queued', job.sessionId);
processNext();
}
/** Job aus der Warteschlange holen und ausführen. */
async function processNext() {
if (running || queue.length === 0) return;
const job = queue.shift();
running = true;
try {
await job.run();
} catch (e) {
job.onError?.(e);
} finally {
running = false;
events.emit('job-finished', job.sessionId);
processNext();
}
}
function queuedBefore(sessionId) {
return queue.findIndex((j) => j.sessionId === sessionId);
}
function isRunning(sessionId) {
return running;
}
function cancelQueued(sessionId) {
queue = queue.filter((j) => j.sessionId !== sessionId);
}
function state() {
return { running, queued: queue.map((j) => j.sessionId) };
}
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state };
+341
View File
@@ -0,0 +1,341 @@
const tools = require('./tools');
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
## WICHTIG: Denke weiter als die Frage!
- Erweitere das Thema: Was ist das tiefere Bedürfnis? Welche verwandten Aspekte, überraschenden Perspektiven und Hintergrundwissen wären wertvoll?
- Bringe dem Benutzer Dinge bei, von denen er noch nicht wusste, dass er sie wissen will.
## Workflow
1. PLANE 5-8 Suchanfragen, breiter als die wörtliche Frage: 2-3 direkte, 2-3 erweiterte, 1-2 überraschende Perspektiven. Suche darf auf Englisch sein (liefert mehr Ergebnisse), das Dashboard wird auf Deutsch.
2. SUCH mit dem search-Tool über DuckDuckGo (die HTML-Version wird automatisch genutzt). WICHTIG: Warte IMMER 3-5 Sekunden zwischen Suchanfragen. Maximal 4-5 DuckDuckGo-Suchen, danach Fallbacks:
- Startpage: browse("https://www.startpage.com/do/dsearch?query=SUCHE")
- Google: browse("https://www.google.com/search?q=SUCHE")
- Direkt auf bekannte Seiten gehen (Wikipedia, offizielle Projektseiten)
3. BESUCHE die 3-5 relevantesten Ergebnisse pro Suche mit dem browse-Tool. Merke dir die EXAKTE URL der Seite, auf der du die Information findest (niemals generische Homepages verlinken!).
4. VERIFIZIERE JEDEN Link, der ins Dashboard kommt, mit dem verify-Tool (Seite lädt UND Inhalt passt zum Kontext). Nur verifizierte Links kommen ins Dashboard! Wenn ein Link nicht passt, suche den richtigen nach.
5. STRUKTURIERE die Ergebnisse in logische Kategorien (nach Plattform, Typ, Reife oder Einfachheit).
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
7. GENERIERE das Dashboard mit dem save_dashboard-Tool (Detailstruktur unten) und fasse dann in deiner finalen Antwort das Wichtigste zusammen.
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
- Header: Titel + Datum + Anzahl verifizierter Links
- Einleitung & Analyse (3-5 Absätze, visuell abgesetzt mit eigenem Styling/Badge "Analyse & Empfehlung")
- Kategorien mit Karten: Titel mit Link (target="_blank" rel="noopener noreferrer"), grüner "Verifiziert"-Badge, Beschreibung (2-3 Sätze), Feature-Liste, "Besuchen"-Button
- Footer: verwendete Suchbegriffe, geprüfte vs. verifizierte Quellen, Zeitstempel
- ALLES auf Deutsch!
## Regeln
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
- Die finale Antwort ist eine deutsche Zusammenfassung (max. 300 Wörter): wichtigste Erkenntnisse + Link zum Dashboard. Details gehören ins Dashboard, nicht in die Antwort.
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard mit dem save_dashboard-Tool, indem du DENSELBEN Dateinamen wie zuvor verwendest.
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
const TOOL_DEFINITIONS = [
{
type: 'function',
function: {
name: 'search',
description:
'Durchsucht das Web über DuckDuckGo (HTML-Version). Liefert die Top-10 Ergebnisse mit Titel, URL und Kurzbeschreibung. Warte zwischen Suchen 3-5 Sekunden und nutze maximal 4-5 DuckDuckGo-Suchen pro Session, danach Fallback-Suchmaschinen über browse().',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Suchanfrage, z.B. "fediverse instagram alternative pixelfed"' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'browse',
description:
'Besucht eine URL und liest den sichtbaren Text der Seite (Titel + Inhalt). Nutze das zum Lesen von Artikeln, Projektseiten und Fallback-Suchmaschinen (Startpage: https://www.startpage.com/do/dsearch?query=..., Google: https://www.google.com/search?q=...).',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL, die besucht werden soll' },
},
required: ['url'],
},
},
},
{
type: 'function',
function: {
name: 'verify',
description:
'Verifiziert einen Link: Prüft, ob die Seite erfolgreich lädt und echten Inhalt hat (keine 404-/Fehlerseite). JEDER Link, der ins Dashboard kommt, MUSS mit diesem Tool verifiziert werden.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL, die verifiziert werden soll' },
},
required: ['url'],
},
},
},
{
type: 'function',
function: {
name: 'save_dashboard',
description:
'Speichert das fertige HTML-Dashboard als Datei auf dem Server und liefert die URL zurück. Übergib die KOMPLETTE standalone HTML-Datei (mit Tailwind CDN, deutsch, verifizierte Links mit Badges, Einleitung mit Analyse). Nutze bei Folge-Nachrichten den bisherigen Dateinamen, um das Dashboard zu aktualisieren.',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Titel des Dashboards, z.B. "Fediverse-Alternativen zu Instagram"' },
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "fediverse-alternativen". Für Updates denselben Namen wie vorher verwenden.' },
html: { type: 'string', description: 'Die komplette standalone HTML-Datei des Dashboards' },
},
required: ['title', 'filename', 'html'],
},
},
},
];
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
function summarizeTool(toolName, output) {
let data;
try {
data = JSON.parse(output);
} catch {
return output;
}
switch (toolName) {
case 'search':
return `Suche ergab ${data.results?.length ?? 0} Ergebnisse: ${
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
}`;
case 'browse':
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}`;
case 'verify':
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
case 'save_dashboard':
return `Dashboard gespeichert: ${data.url || ''}`;
default:
return String(output).slice(0, 1000);
}
}
const MAX_ITERATIONS = 40;
async function chatCompletion(config, messages) {
const body = {
model: config.model,
messages,
tools: TOOL_DEFINITIONS,
stream: true,
max_tokens: 32000,
stream_options: { include_usage: true },
};
if (config.thinking !== 'disabled') {
body.thinking = { type: 'enabled' };
body.reasoning_effort = 'medium';
}
const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.text().catch(() => '');
throw new Error(`DeepSeek API Fehler ${resp.status}: ${err.slice(0, 500)}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const delta = { content: '', reasoning: '', toolCalls: null, finishReason: null, usage: null };
let toolCallIndex = -1;
const feed = (line) => {
if (!line.startsWith('data:')) return;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
let json;
try {
json = JSON.parse(payload);
} catch {
return;
}
if (json.usage) delta.usage = json.usage;
const choice = json.choices?.[0];
if (!choice) return;
const d = choice.delta || {};
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
if (d.content) delta.content += d.content;
if (d.tool_calls) {
for (const tc of d.tool_calls) {
if (tc.index != null) {
if (tc.index > toolCallIndex) {
toolCallIndex = tc.index;
if (!delta.toolCalls) delta.toolCalls = [];
delta.toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } });
}
const cur = delta.toolCalls[tc.index];
if (tc.id) cur.id = tc.id;
if (tc.function?.name) cur.function.name += tc.function.name;
if (tc.function?.arguments) cur.function.arguments += tc.function.arguments;
}
}
}
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.trim()) feed(line);
}
}
if (buffer.trim()) feed(buffer);
return {
content: delta.content,
reasoning: delta.reasoning,
toolCalls: delta.toolCalls,
finishReason: delta.finishReason,
usage: delta.usage,
};
}
/**
* Führt den Research-Job für eine Session aus.
* @param {object} config {apiKey, model, thinking}
* @param {object} session Session aus DB
* @param {string} userMessageText Text der neuen Benutzernachricht
* @param {object} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
* @param {object} db Datenbankmodul
*/
async function runResearchJob(config, session, userMessageText, emit, db) {
let dashboardUrl = session.dashboard || null;
let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null;
const handlers = {
async search({ query }) {
emit.progress('🔍', `Suche: ${query}`);
const result = await tools.search(query, (m) => emit.step(m));
if (result.blocked) emit.progress('⚠️', 'DuckDuckGo blockiert – Fallback-Strategie nutzen');
return JSON.stringify(result);
},
async browse({ url }) {
emit.progress('🌐', `Besuche: ${url}`);
const result = await tools.browse(url, (m) => emit.step(m));
return JSON.stringify(result);
},
async verify({ url }) {
emit.progress('✅', `Verifiziere: ${url}`);
const result = await tools.verify(url, (m) => emit.step(m));
return JSON.stringify(result);
},
async save_dashboard({ title, filename, html }) {
emit.progress('📊', 'Dashboard wird gespeichert…');
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
dashboardUrl = saved.url;
dashboardFile = saved.file.replace('.html', '');
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
return JSON.stringify(saved);
},
};
const apiMessages = db.toApiMessages(session.id);
const text = userMessageText;
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
emit.progress('🤖', 'Research startet…');
const trackUsage = (u) => {
if (!u) return;
usage.promptTokens += u.prompt_tokens || 0;
usage.completionTokens += u.completion_tokens || 0;
usage.cacheHitTokens += u.prompt_cache_hit_tokens || 0;
usage.cacheMissTokens += u.prompt_cache_miss_tokens || 0;
if (!usage.cacheHitTokens && !usage.cacheMissTokens) {
usage.cacheMissTokens = u.prompt_tokens || 0;
}
};
const finishJob = (finalText, dashboardUrl) => {
db.setSessionUsage(session.id, usage);
db.setSessionStatus(session.id, 'done');
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
const costEstimate = db.computeCost(usage);
emit.done({ text: finalText, dashboardUrl, usage, costEstimate });
return { text: finalText, dashboardUrl, usage, costEstimate };
};
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
const result = await chatCompletion(config, apiMessages);
trackUsage(result.usage);
if (result.reasoning) emit.thinking(result.reasoning);
if (result.toolCalls && result.toolCalls.length > 0) {
const assistantMsg = {
role: 'assistant',
content: result.content || null,
tool_calls: result.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.function.name, arguments: tc.function.arguments },
})),
};
db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls });
apiMessages.push(assistantMsg);
for (const tc of result.toolCalls) {
const fn = handlers[tc.function.name];
let output;
if (!fn) {
output = JSON.stringify({ error: `Unbekanntes Tool: ${tc.function.name}` });
} else {
try {
const args = JSON.parse(tc.function.arguments || '{}');
output = await fn(args);
} catch (e) {
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
}
}
db.createMessage(session.id, {
role: 'tool',
toolName: tc.function.name,
toolCallId: tc.id,
content: summarizeTool(tc.function.name, output).slice(0, 8000),
});
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) });
}
continue;
}
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : '');
db.createMessage(session.id, { role: 'assistant', content: finalText });
return finishJob(finalText, dashboardUrl);
}
db.createMessage(session.id, {
role: 'assistant',
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
});
db.setSessionUsage(session.id, usage);
db.setSessionStatus(session.id, 'done');
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) };
}
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
+154
View File
@@ -0,0 +1,154 @@
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
let browser = null;
const USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
/** DDG-Redirect-URLs (duckduckgo.com/l/?uddg=...) auf die echte URL auflösen. */
function realUrl(href) {
try {
const u = new URL(href);
if (u.hostname === 'duckduckgo.com' && u.pathname.startsWith('/l/') && u.searchParams.get('uddg')) {
return u.searchParams.get('uddg');
}
} catch {}
return href;
}
async function getBrowser() {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
channel: 'chromium',
headless: true,
args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'],
});
}
return browser;
}
async function withPage(url, fn) {
const b = await getBrowser();
const page = await b.newPage({ userAgent: USER_AGENT, viewport: { width: 1280, height: 900 } });
try {
return await fn(page);
} finally {
await page.close();
}
}
async function goto(page, url, timeout = 30000) {
let resp;
try {
resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
} catch (e) {
return { ok: false, error: `Navigation fehlgeschlagen: ${e.message.slice(0, 200)}` };
}
await page.waitForTimeout(1200);
return {
ok: resp ? resp.status() < 400 : true,
status: resp ? resp.status() : 0,
finalUrl: page.url(),
};
}
/** DuckDuckGo HTML-Suche: Ergebnisse parsen. */
async function search(query, log) {
const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const results = await withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { nav, results: [], note: 'Suchseite nicht erreichbar' };
await page.waitForTimeout(2500);
const links = await page
.locator('a.result__a')
.evaluateAll((els) =>
els.slice(0, 10).map((a) => ({
title: (a.textContent || '').trim(),
url: a.href,
}))
);
for (const l of links) l.url = realUrl(l.url);
const snippets = await page
.locator('.result__snippet')
.evaluateAll((els) => els.slice(0, 10).map((e) => (e.textContent || '').trim()));
const blocked =
(await page.title().catch(() => '')) === 'CAPTCHA' ||
(await page.locator('text=CAPTCHA').count().catch(() => 0)) > 0;
return {
nav,
blocked,
results: links.map((l, i) => ({ ...l, snippet: snippets[i] || '' })),
};
});
log?.(`Suche "${query}" -> ${results.results.length} Ergebnisse`);
return results;
}
/** Seite besuchen und lesbaren Text extrahieren. */
async function browse(url, log) {
return withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
const data = await page.evaluate(() => {
const title = document.title || '';
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n');
return { title, text: text.slice(0, 15000) };
});
log?.(`Besuche ${url} -> ${data.title}`);
return { nav, title: data.title, text: data.text };
});
}
/** Link prüfen: lädt die Seite erfolgreich und hat sie echten Inhalt? */
async function verify(url, log) {
return withPage(url, async (page) => {
const nav = await goto(page, url, 25000);
if (!nav.ok) {
log?.(`Verifizierung FEHLGESCHLAGEN (${nav.status || 'Timeout'}): ${url}`);
return { url, ok: false, reason: `Seite lädt nicht (${nav.status || 'Timeout'})` };
}
const title = await page.title().catch(() => '');
const bodyLen = await page.evaluate(() => document.body?.innerText?.length || 0).catch(() => 0);
const badTitle = /404|not found|seite nicht gefunden|page not found/i.test(title);
const ok = !badTitle && bodyLen > 200;
log?.(`${ok ? 'VERIFIZIERT' : 'FEHLGESCHLAGEN'}: ${url}`);
return {
url,
ok,
reason: ok ? '' : badTitle ? 'Fehlerseite' : 'Kein nennenswerter Inhalt',
title,
};
});
}
/** Dashboard als HTML-Datei speichern. Gibt den URL-Pfad zurück. */
function saveDashboard(sessionId, filename, html, log) {
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
const safeName = (filename || 'dashboard')
.toLowerCase()
.replace(/[^a-z0-9äöü-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dashboard';
const file = `${sessionId}-${safeName}.html`;
fs.writeFileSync(path.join(DASHBOARD_DIR, file), html, 'utf8');
log?.(`Dashboard gespeichert: ${file}`);
return { file, url: `/dashboards/${file}` };
}
async function closeBrowser() {
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
}
module.exports = { search, browse, verify, saveDashboard, closeBrowser };
+17
View File
@@ -0,0 +1,17 @@
{
"name": "research-chat",
"version": "1.0.0",
"description": "Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright)",
"main": "server.js",
"type": "commonjs",
"scripts": {
"start": "node server.js",
"icons": "node scripts/make-icons.js"
},
"license": "ISC",
"dependencies": {
"express": "^4.19.2",
"playwright": "1.62.1",
"web-push": "^3.6.7"
}
}
+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, '&amp;')
.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);
})
);
});
+33
View File
@@ -0,0 +1,33 @@
const path = require('path');
const fs = require('fs');
const { chromium } = require('playwright');
const OUT = path.join(__dirname, '..', 'public', 'icons');
const SVG = (bg) => `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="${bg}"/>
<g transform="translate(96,96) scale(1.3)" fill="none" stroke="#fff" stroke-width="26" stroke-linecap="round">
<circle cx="100" cy="100" r="62"/>
<line x1="146" y1="146" x2="230" y2="230"/>
<circle cx="230" cy="230" r="14" fill="#fff" stroke="none"/>
</g>
<g fill="#fff" font-family="Arial" font-weight="bold" font-size="120" text-anchor="middle">
<text x="256" y="410">R</text>
</g>
</svg>`;
async function renderSvgToPng(svg, file, size) {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: size, height: size } });
await page.setContent(svg);
await page.screenshot({ path: path.join(OUT, file), clip: { x: 0, y: 0, width: size, height: size } });
await browser.close();
console.log('Erzeugt:', file);
}
(async () => {
fs.mkdirSync(OUT, { recursive: true });
await renderSvgToPng(SVG('#2563eb'), 'icon-192.png', 192);
await renderSvgToPng(SVG('#2563eb'), 'icon-512.png', 512);
await renderSvgToPng(SVG('#1e3a8a'), 'icon-maskable-512.png', 512);
})();
+328
View File
@@ -0,0 +1,328 @@
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
const webpush = require('web-push');
const { config } = require('./lib/config');
const db = require('./lib/db');
const queue = require('./lib/queue');
const { runResearchJob } = require('./lib/research');
const { closeBrowser } = require('./lib/tools');
const app = express();
app.set('trust proxy', 1);
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
const server = http.createServer(app);
// ---------------------------------------------------------------------------
// Auth (eine Anmeldung: Name + gemeinsames Passwort, signiertes Cookie)
// ---------------------------------------------------------------------------
function signToken(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyToken(token) {
try {
const [body, sig] = token.split('.');
const expected = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
if (sig !== expected) return null;
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: 30 * 24 * 3600 * 1000,
path: '/',
};
}
function requireAuth(req, res, next) {
const payload = verifyToken(req.cookies?.research_auth);
if (!payload) return res.status(401).json({ error: 'Nicht angemeldet' });
req.user = payload;
next();
}
app.use((req, res, next) => {
req.cookies = Object.fromEntries(
(req.headers.cookie || '')
.split(';')
.map((c) => c.trim().split('='))
.filter(([k]) => k)
.map(([k, ...v]) => [k, decodeURIComponent(v.join('='))])
);
next();
});
app.post('/api/login', (req, res) => {
const { name, password } = req.body || {};
const pw = String(config.appPassword || '');
if (!pw || !password || password.length !== pw.length) {
return res.status(401).json({ error: 'Falsches Passwort' });
}
const a = crypto.createHash('sha256').update(password).digest();
const b = crypto.createHash('sha256').update(pw).digest();
if (!crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'Falsches Passwort' });
}
const safeName = String(name || 'Nutzer').slice(0, 40);
const token = signToken({ n: safeName, exp: Date.now() + 30 * 24 * 3600 * 1000 });
res.cookie('research_auth', token, cookieOptions(req));
res.json({ name: safeName });
});
app.post('/api/logout', (req, res) => {
res.clearCookie('research_auth', cookieOptions(req));
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// Sessions & Nachrichten
// ---------------------------------------------------------------------------
app.get('/api/me', requireAuth, (req, res) => res.json({ name: req.user.n }));
app.get('/api/sessions', requireAuth, (req, res) => {
const q = queue.state();
const sessions = db.listSessions().map((s) => ({
...s,
job: s.status === 'running' ? 'running' : q.queued.includes(s.id) ? 'queued' : 'idle',
}));
res.json(sessions);
});
app.post('/api/sessions', requireAuth, (req, res) => {
const session = db.createSession(req.user.n);
res.json(session);
});
app.get('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session || (session.owner_name !== req.user.n && !session.share_token)) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
res.json({ session, messages: db.getSessionMessages(session.id) });
});
app.get('/api/sessions/:id/share', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
const url = `/share/${session.share_token}`;
res.json({ url, fullUrl: `${req.protocol}://${req.get('host')}${url}` });
});
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
queue.cancelQueued(session.id);
db.deleteSession(session.id);
res.json({ ok: true });
});
app.get('/api/share/:token', (req, res) => {
const session = db.getSessionByShare(req.params.token);
if (!session) return res.status(404).json({ error: 'Link ungültig' });
res.json({ session, messages: db.getSessionMessages(session.id) });
});
// ---------------------------------------------------------------------------
// Job starten + SSE-Streaming
// ---------------------------------------------------------------------------
function emitToSession(sessionId, type, data) {
queue.events.emit('session-event', sessionId, type, data);
}
app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'Leere Nachricht' });
if (session.status === 'running' || session.status === 'queued') {
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
}
if (queue.state().running || queue.state().queued.length > 0) {
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
}
db.createMessage(session.id, { role: 'user', content: text });
if (session.title === 'Neue Recherche') {
db.setSessionTitle(session.id, text.slice(0, 70));
}
db.setSessionStatus(session.id, 'queued');
const job = {
sessionId: session.id,
async run() {
db.setSessionStatus(session.id, 'running');
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
const emit = {
token: (t) => emitToSession(session.id, 'token', { text: t }),
thinking: (t) => emitToSession(session.id, 'thinking', { text: t }),
progress: (icon, text) => emitToSession(session.id, 'progress', { icon, text }),
step: (text) => emitToSession(session.id, 'step', { text }),
done: (result) => {
emitToSession(session.id, 'done', result);
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
sendPush(session, result);
},
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
};
await runResearchJob(
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl },
db.getSession(session.id),
text,
emit,
db
);
},
async onError(e) {
console.error('Job-Fehler:', e);
db.setSessionStatus(session.id, 'done');
emitToSession(session.id, 'error', { text: `Fehler: ${e.message}` });
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'error' });
},
};
queue.enqueue(job);
res.status(202).json({ ok: true, status: 'queued' });
});
app.get('/api/sessions/:id/stream', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) return res.status(404).end();
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const send = (type, data) => {
if (res.writableEnded) return;
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
const onEvent = (sessionId, type, data) => {
if (sessionId === session.id) send(type, data);
};
queue.events.on('session-event', onEvent);
const q = queue.state();
const jobStatus = session.status === 'running' ? 'running' : q.queued.includes(session.id) ? 'queued' : 'idle';
send('status', { sessionStatus: session.status, jobStatus });
const heartbeat = setInterval(() => send('ping', { t: Date.now() }), 25000);
req.on('close', () => {
clearInterval(heartbeat);
queue.events.off('session-event', onEvent);
});
});
// ---------------------------------------------------------------------------
// Web Push
// ---------------------------------------------------------------------------
webpush.setVapidDetails(
'mailto:research@localhost',
config.vapidPublicKey,
config.vapidPrivateKey
);
app.get('/api/push/vapid-key', (req, res) => res.json({ key: config.vapidPublicKey }));
app.post('/api/push/subscribe', requireAuth, (req, res) => {
const { endpoint, keys } = req.body || {};
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Ungültige Subscription' });
}
db.upsertPushSub(req.user.n, endpoint, keys);
res.json({ ok: true });
});
function sendPush(session, result) {
const payload = JSON.stringify({
title: '✅ Recherche fertig',
body: `"${session.title}" – Dashboard ist bereit`,
url: `/s/${session.id}`,
});
for (const sub of db.listPushSubs()) {
webpush
.sendNotification(
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
payload,
{ TTL: 3600 }
)
.then(() => console.log('Push gesendet an', sub.endpoint))
.catch((e) => {
if (e.statusCode === 404 || e.statusCode === 410) {
db.removePushSub(sub.endpoint);
} else {
console.error('Push-Fehler:', e.message);
}
});
}
}
// ---------------------------------------------------------------------------
// Statische Dateien & Dashboard-Dateien
// ---------------------------------------------------------------------------
app.use('/dashboards', express.static(path.join(__dirname, 'dashboards'), { maxAge: '1h' }));
app.use('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json')));
app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js')));
app.use('/icons', express.static(path.join(__dirname, 'public', 'icons')));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/share/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share.html'));
});
app.get('/s/:id', requireAuth, (req, res) => {
res.redirect(`/?session=${req.params.id}`);
});
app.get('/', (req, res) => {
const payload = verifyToken(req.cookies?.research_auth);
if (!payload) return res.sendFile(path.join(__dirname, 'public', 'login.html'));
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// ---------------------------------------------------------------------------
if (!config.apiKey) {
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – Recherche wird nicht funktionieren.');
}
if (!config.appPassword) {
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');
}
server.listen(config.port, () => {
console.log(`Research WebApp läuft auf http://localhost:${config.port}`);
});
process.on('SIGINT', async () => {
await closeBrowser();
process.exit(0);
});
process.on('SIGTERM', async () => {
await closeBrowser();
process.exit(0);
});